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.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index fa149f6d1174..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 = () => {}; @@ -373,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 635b68d10ee0..38ee19f7978b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,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 @@ -133,6 +141,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 @@ -223,6 +239,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 @@ -253,6 +277,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/.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 4dc489fafa68..034e505b06ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,11 +78,13 @@ 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 @@ -95,11 +97,38 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## 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 +141,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 8e6870a03acd..1b2f59b57592 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,13 +1,12 @@ { "name": "@t3tools/desktop", - "version": "0.0.89", + "version": "0.0.90", "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 ae6cd05961b6..933a763dfa35 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -116,7 +116,26 @@ 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`); @@ -149,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 68febbc6386f..7dffe9fe3b1f 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -147,7 +147,7 @@ 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; 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 58fff8297898..1e572412d469 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -5,7 +5,6 @@ import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/code 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"; @@ -112,40 +111,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - 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", @@ -198,54 +163,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/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 6355b65a633c..9e729aaeff51 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -151,89 +151,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); - it.effect("replaces an owned launcher before a later foreign PATH command", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); - const link = (yield* before.install).installedPath!; - const foreignDirectory = path.join(home, "foreign"); - const foreign = path.join(foreignDirectory, "t3"); - yield* fs.makeDirectory(foreignDirectory); - yield* fs.writeFileString(foreign, "#!/bin/sh\n", { mode: 0o755 }); - process.env.PATH = [path.dirname(link), foreignDirectory].join(":"); - - const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); - expect(yield* after.install).toMatchObject({ installedPath: link, onPath: true }); - expect(yield* fs.readLink(link)).toBe(path.join(home, "new-t3", "bin", "t3")); - expect(yield* fs.readFileString(foreign)).toBe("#!/bin/sh\n"); - }).pipe(Effect.scoped), - ); - - it.effect("preserves the old launcher when an earlier foreign command rejects migration", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); - const link = (yield* before.install).installedPath!; - const original = yield* fs.readLink(link); - const foreignDirectory = path.join(home, "foreign"); - const foreign = path.join(foreignDirectory, "t3"); - yield* fs.makeDirectory(foreignDirectory); - yield* fs.writeFileString(foreign, "#!/bin/sh\n", { mode: 0o755 }); - process.env.PATH = [foreignDirectory, path.dirname(link)].join(":"); - - const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); - expect((yield* Effect.flip(after.install)).message).toContain(foreign); - expect(yield* fs.readLink(link)).toBe(original); - expect(yield* fs.readFileString(foreign)).toBe("#!/bin/sh\n"); - }).pipe(Effect.scoped), - ); - - it.effect.each(["non-executable file", "directory"] as const)( - "skips a %s named t3 earlier on PATH", - (kind) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const skippedDirectory = path.join(home, "skipped"); - const skipped = path.join(skippedDirectory, "t3"); - yield* fs.makeDirectory(skippedDirectory); - if (kind === "directory") { - yield* fs.makeDirectory(skipped); - } else { - yield* fs.writeFileString(skipped, "not a command\n", { mode: 0o644 }); - } - const link = path.join(home, ".local", "bin", "t3"); - process.env.PATH = [skippedDirectory, path.dirname(link)].join(":"); - - const command = yield* commandIn({ home }); - expect((yield* command.state).shadowedBy).toBeUndefined(); - expect(yield* command.install).toMatchObject({ installedPath: link, onPath: true }); - expect(yield* fs.exists(skipped)).toBe(true); - expect((yield* fs.stat(skipped)).type).toBe(kind === "directory" ? "Directory" : "File"); - }).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; @@ -307,15 +224,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 a8ac69b34535..0b446f8ada30 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -8,7 +8,6 @@ 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"; @@ -267,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; 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/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 295f63eafe48..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"; @@ -179,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", () => { @@ -393,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 { @@ -672,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/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 d27251d7b2f9..143a6e5fd87c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -119,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" }, @@ -248,32 +166,6 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); - 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("still prepares the portal entry when the bundled icon cannot be copied", () => { - getSwitchValueMock.mockReturnValue(""); - copyFileSyncMock.mockImplementation(() => { - throw new Error("missing bundled icon"); - }); - return Effect.gen(function* () { - yield* DesktopPreReadyPlatform.make; - const contents = writeFileSyncMock.mock.calls[0]?.[1]; - assert.include(contents, "MimeType=x-scheme-handler/t3code;"); - assert.include(contents, "Icon="); - assert.equal(setDesktopNameMock.mock.calls.length, 1); - }).pipe(Effect.provideService(HostProcess.Platform, "linux")); - }); - it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => 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 2ef3c7e2508b..5e068a928e08 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[] = []; @@ -804,226 +682,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; @@ -1072,71 +730,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; @@ -1284,42 +877,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; @@ -1353,113 +910,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; @@ -1509,93 +959,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 eaf7fc308456..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), ); @@ -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,361 +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 bounds the quit finalizer when backends hang", () => - Effect.scoped( - Effect.gen(function* () { - const teardownStarted = yield* Queue.unbounded(); - const teardownFinished = yield* Queue.unbounded(); - const allowTeardown = yield* Deferred.make(); - - const makeInstance = (name: string) => - 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"); - const instance2 = yield* makeInstance("instance2"); - - 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); - assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); - - yield* TestClock.adjust(Duration.seconds(5)); - yield* Fiber.join(quitFiber); - assert.equal(yield* Queue.size(teardownFinished), 0); - - yield* Deferred.succeed(allowTeardown, undefined); - const finished = yield* Queue.takeN(teardownFinished, 2); - assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b27567316185..b79da5e0bb87 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -317,7 +317,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 dcf32467da0b..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,72 +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"), - setSshRunner: () => Effect.die("unexpected SSH runner update"), - 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(); @@ -523,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, @@ -564,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 7e75d9998141..0e72a46146e0 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); @@ -88,24 +40,6 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); - it.effect("opens Zed's ssh deep link", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const results = yield* Effect.all([ - electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), - electronShell.openExternal("zed://ssh/example.com/"), - ]); - - assert.deepEqual(results, [true, true]); - assert.deepEqual(openExternalMock.mock.calls, [ - ["zed://ssh/user@example.com/home/user/project"], - ["zed://ssh/example.com/"], - ]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - it.effect("opens the JetBrains Toolbox ssh deep link", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -168,29 +102,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 1e005d26fdf5..000000000000 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const { autoUpdaterMock } = vi.hoisted(() => ({ - 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([])), - on: vi.fn(), - quitAndInstall: vi.fn(), - removeListener: vi.fn(), - setFeedURL: vi.fn(), - }, -})); - -vi.mock("electron-updater", () => ({ - autoUpdater: 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(); - }); - - 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)), - ); -}); 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/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 3459719e2f54..b5fe707f4cae 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(), @@ -22,17 +20,10 @@ vi.mock("electron", () => ({ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; -import * as ElectronDialog from "../../electron/ElectronDialog.ts"; -import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, getRelayTelemetryEnabled, - getWindowFullscreenState, - pasteAsText, - pickProjectFavicon, probeRemoteEditors, - revealWindow, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -88,23 +79,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(); @@ -218,143 +192,6 @@ describe("getRelayTelemetryEnabled", () => { ); }); -describe("getWindowFullscreenState", () => { - it.effect("reads the current native window state", () => { - const window = { isFullScreen: () => true } as Electron.BrowserWindow; - - return Effect.gen(function* () { - assert.isTrue(yield* getWindowFullscreenState.handler()); - }).pipe( - Effect.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - currentMainOrFirst: Effect.succeedSome(window), - }), - ), - ); - }); -}); - -describe("pasteAsText", () => { - it.effect( - "pastes into the focused guest only after the main renderer acknowledges the menu action", - () => { - const paste = vi.fn(); - const mainPaste = vi.fn(); - const window = { - webContents: { id: 42, paste: mainPaste }, - isDestroyed: () => false, - } as unknown as Electron.BrowserWindow; - focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false }); - ownerWindow.mockReturnValue(window); - - return Effect.gen(function* () { - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - assert.equal(mainPaste.mock.calls.length, 0); - - yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); - assert.equal(paste.mock.calls.length, 1); - ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - ownerWindow.mockReturnValue(null); // Detached contents. - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - ownerWindow.mockReturnValue(window); - focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true }); - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - focusedWebContents.mockReturnValue(null); - yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); - assert.equal(paste.mock.calls.length, 1); - }).pipe( - Effect.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - main: Effect.succeedSome(window), - }), - ), - ); - }, - ); -}); - -describe("revealWindow", () => { - it.effect("reveals the main window only when it asks", () => { - const window = { webContents: { id: 42 } } as unknown as Electron.BrowserWindow; - const reveal = vi.fn(() => Effect.void); - return Effect.gen(function* () { - yield* revealWindow.handler(undefined, { sender: { id: 99 } }); - assert.equal(reveal.mock.calls.length, 0); - yield* revealWindow.handler(undefined, { sender: { id: 42 } }); - assert.deepEqual(reveal.mock.calls, [[window]]); - }).pipe( - Effect.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - main: Effect.succeedSome(window), - reveal, - }), - ), - ); - }); -}); - -describe("pickProjectFavicon", () => { - const pickerLayer = ( - pickFiles: () => Effect.Effect>, - settings?: DesktopAppSettings.DesktopSettings, - ) => - Layer.mergeAll( - Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeedNone, - }), - DesktopAppSettings.layerTest(settings), - ); - - it.effect("opens a single-image picker from the project directory", () => - Effect.gen(function* () { - const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon - .handler("/project") - .pipe(Effect.provide(pickerLayer(pickFiles))); - - assert.strictEqual(result, "/pictures/icon.png"); - assert.deepEqual(pickFiles.mock.calls, [ - [ - { - owner: Option.none(), - defaultPath: Option.some("/project"), - multiple: false, - filters: [ - { - name: "Images", - extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"], - }, - ], - }, - ], - ]); - }), - ); - - it.effect("does not open a picker while the local environment is off", () => - Effect.gen(function* () { - const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon.handler("/project").pipe( - Effect.provide( - pickerLayer(pickFiles, { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - localEnvironmentEnabled: false, - }), - ), - ); - - assert.strictEqual(result, null); - assert.strictEqual(pickFiles.mock.calls.length, 0); - }), - ); -}); - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 6d87a23d4dbe..8e8203ea24bb 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -322,33 +322,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/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/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/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 eace39003e39..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]/", @@ -338,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/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 d421898732a1..45d2aeafdbfa 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(() => ({ @@ -235,119 +233,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; @@ -365,50 +250,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 ece37631f805..ebe4c260eea2 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,8 +1,6 @@ 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, @@ -34,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); @@ -48,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 = {}) => ({ @@ -90,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); @@ -161,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" }; @@ -458,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; @@ -468,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) => { @@ -542,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) => { @@ -605,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], @@ -797,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* () { @@ -898,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 }) => { @@ -1036,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* () { @@ -1172,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 @@ -1226,6 +897,7 @@ describe("PreviewManager", () => { yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); yield* manager.createTab("tab_favicon_latest"); @@ -1234,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); @@ -1250,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; @@ -1295,6 +939,7 @@ describe("PreviewManager", () => { yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); yield* manager.createTab("tab_favicon_navigation"); @@ -1303,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, @@ -1314,2723 +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/"); + yield* changes.until(() => initial.fetch.mock.calls.length === 1); - expect(preview.reload).toHaveBeenCalledOnce(); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); - - 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; - yield* manager.registerWebview("tab_favicon_reregister", 42); + 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.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, + 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, - 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: (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"); + webContents: { setBackgroundThrottling: firstWindowThrottling }, + } as never); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: replacementWindowThrottling }, + } as never); - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "" }], - }); - expect(states.at(-1)?.colorScheme).toBe("system"); + 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]]); }), ), ); - 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", () => + effectIt.effect("releases frame capture when the main window closes", () => 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 } }, + 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_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" }], - }); + 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]]); - const unclaimed = makeAttachingGuest(45); - yield* manager.prepareWebview(unclaimed.wc); - expect(unclaimed.attach).toHaveBeenCalledTimes(1); - unclaimed.destroy(); + 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; - expect(unclaimed.detach).toHaveBeenCalledTimes(1); - expect(claimed.detach).not.toHaveBeenCalled(); + + 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("reattaches a debugger that Chromium dropped on its own", () => + effectIt.effect("does not arm recording after the main window closes during warmup", () => 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); - - guest.chromiumDetach(); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - expect(guest.attach).toHaveBeenCalledTimes(2); - }), - ), - ); - - 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", - 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).toHaveBeenCalledOnce(); - 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(3); - - capturePage.mockClear(); - const captureCause = new Error("capture failed"); - capturePage.mockRejectedValue(captureCause); - 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(3); - 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("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"); - - 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("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"); - }); - 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_recording_warmup_failure"); - yield* manager.registerWebview("tab_recording_warmup_failure", 42); - - yield* manager.startRecording("tab_recording_warmup_failure"); - expect(capturePage).toHaveBeenCalledTimes(2); - - 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 } }, {}]); - - yield* manager.stopRecording("tab_recording_warmup_failure"); - }), - ), - ); - - effectIt.effect("serializes recording source acquisition with webview replacement", () => - 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, - }), - ); - expect(states.at(-1)?.pictureInPicture).toBe(true); - 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); + 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); - 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); + 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); - 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); + yield* manager.createTab("tab_recording_warmup_failure"); + yield* manager.registerWebview("tab_recording_warmup_failure", 42); + yield* manager.startRecording("tab_recording_warmup_failure"); expect(capturePage).toHaveBeenCalledTimes(2); - expect(toJPEG).toHaveBeenCalledOnce(); - expect(send).toHaveBeenCalledOnce(); - - page = "changed-page"; - yield* TestClock.adjust(100); - 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") }), - ); - - // 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], + ]); }), ), ); @@ -4454,82 +2474,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, @@ -4601,23 +2545,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( @@ -4667,6 +2594,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", @@ -4711,171 +2639,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* () { @@ -4897,71 +2662,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 b42ebcafb280..559eef4abad0 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -212,7 +212,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"; @@ -509,7 +509,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) && 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 1f52bdc2eb3a..64cc454c92ef 100644 --- a/apps/desktop/src/preview/PickedElementPayload.test.ts +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -35,76 +35,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( @@ -131,11 +61,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 { @@ -180,28 +105,11 @@ 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); - }); }); describe("isPreviewAnnotationDraft", () => { 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 8048454a0120..ad42d9a907ab 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,126 +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, - sshRunner: "windows", - 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, - sshRunner: "windows", - 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( @@ -299,46 +178,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, - sshRunner: "windows", - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -448,34 +287,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, - sshRunner: "windows", - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists wsl backend toggle and normalizes invalid distro names", () => withSettings( Effect.gen(function* () { @@ -550,19 +361,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/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 bad73cea4c0c..45224317989d 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"; @@ -156,17 +154,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* () { @@ -218,30 +205,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* () { @@ -256,114 +219,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 3adf74bf76f8..9d12ed5a07fe 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -5,15 +5,11 @@ import * as Effect from "effect/Effect"; 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 NodeOS from "node:os"; -import * as NodePath from "node:path"; import { vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -23,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]) => [ @@ -126,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" }; @@ -220,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 = { @@ -295,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 = { @@ -403,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 = { @@ -631,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/snapShot/ActiveWindow.test.ts b/apps/desktop/src/snapShot/ActiveWindow.test.ts deleted file mode 100644 index ddc760cbb9d0..000000000000 --- a/apps/desktop/src/snapShot/ActiveWindow.test.ts +++ /dev/null @@ -1,129 +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), - ); -} - -it("parses the frontmost macOS window from the osascript lookup", async () => { - stubMacLookup( - JSON.stringify({ - 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", - }, - }) + "\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({ - id: 7, - title: "", - bounds: { x: 0, y: 0, width: 1, height: 1 }, - owner: { name: "cli", processId: 9, path: "", bundleId: "" }, - }), - ); - - const window = await activeWindow("darwin"); - - assert.deepEqual(window?.owner, { name: "cli", processId: 9, path: "" }); -}); - -it("resolves undefined when macOS has no frontmost window", async () => { - stubMacLookup("\n"); - - 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/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 31eae474f87c..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]; 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 index 7e8e5136a74c..f32236201999 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -83,21 +83,6 @@ 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( diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 1a3616203cdf..fdac16e297e5 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -92,7 +92,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 94bfbe44c368..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(); @@ -306,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", () => @@ -408,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(); @@ -507,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({ @@ -721,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" }), @@ -921,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(); @@ -999,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(); @@ -1064,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/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/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 a07a24a66c21..df7e1fd2e85a 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -129,95 +129,6 @@ const configureMenu = ( ); describe("DesktopApplicationMenu", () => { - it.effect("keeps display branding in the macOS application menu", () => - Effect.gen(function* () { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate, { - platform: "darwin", - appVersion: "0.0.43-nightly.20260929.2428", - }); - - const template = yield* Deferred.await(applicationMenuTemplate); - const applicationMenu = template[0]; - assert.isDefined(applicationMenu); - assert.equal(applicationMenu.label, "T3 Code (Nightly)"); - if (!Array.isArray(applicationMenu.submenu)) { - throw new Error("Expected application menu submenu to be an array."); - } - assert.equal( - applicationMenu.submenu.find((item) => item.role === "about")?.label, - "About T3 Code (Nightly)", - ); - assert.equal( - applicationMenu.submenu.find((item) => item.role === "hide")?.label, - "Hide T3 Code (Nightly)", - ); - assert.equal( - applicationMenu.submenu.find((item) => item.role === "quit")?.label, - "Quit T3 Code (Nightly)", - ); - }), - ); - - it.effect("installs the native menu and routes Settings through DesktopWindow", () => - Effect.gen(function* () { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate); - - const template = yield* Deferred.await(applicationMenuTemplate); - const fileMenu = template.find((item) => item.label === "File"); - assert.isDefined(fileMenu); - if (!Array.isArray(fileMenu.submenu)) { - throw new Error("Expected File menu submenu to be an array."); - } - const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings..."); - assert.isDefined(settingsItem); - const settingsClick = settingsItem.click; - if (typeof settingsClick !== "function") { - throw new Error("Expected Settings menu item to have a click handler."); - } - - settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - assert.equal(yield* Deferred.await(selectedAction), "open-settings"); - }), - ); - - it.effect("owns Paste as Text and routes it through the renderer", () => - Effect.gen(function* () { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate); - - const template = yield* Deferred.await(applicationMenuTemplate); - const editMenu = template.find((item) => item.label === "Edit"); - assert.isDefined(editMenu); - if (!Array.isArray(editMenu.submenu)) { - throw new Error("Expected Edit menu submenu to be an array."); - } - const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); - assert.isDefined(pasteAsTextItem); - assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); - if (typeof pasteAsTextItem.click !== "function") { - throw new Error("Expected Paste as Text menu item to have a click handler."); - } - - pasteAsTextItem.click( - {} as Electron.MenuItem, - {} as Electron.BrowserWindow, - {} as KeyboardEvent, - ); - assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); - }), - ); - it.effect("leaves the accelerator to Chromium instead of injecting a paste", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); @@ -288,37 +199,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 35a74af4eed6..47489eea0411 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) => ({ @@ -48,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"; @@ -464,143 +456,6 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111" const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); describe("DesktopWindow", () => { - 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 = [ @@ -618,60 +473,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.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", () => @@ -748,30 +549,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. @@ -874,67 +651,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: 1320, height: 880 }, - }, - }); - - 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, 1320); - assert.equal(createdWindowOptions[0]?.height, 880); - 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(); @@ -1069,102 +785,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: 1360, height: 900 }); - 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: 1360, height: 900 }]); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("persists normal bounds and state for a maximized window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1202,74 +822,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.4, y: 80.2, width: 839.4, height: 619.4 }); - 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(); @@ -1310,42 +862,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(); @@ -1382,54 +898,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(); @@ -1487,145 +955,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(); @@ -1694,68 +1023,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 = []; @@ -1796,51 +1063,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(); @@ -1862,34 +1084,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 5d14fdfdc53f..297c67ddace0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -185,7 +185,7 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { 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" 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 c08e51e407f3..f4882a55d6f7 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -13,30 +13,24 @@ 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 ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, buildWslRuntimeProbeScript, + parseResolvedPath, + parseWslRuntimeRoot, DesktopWslDistroListError, DesktopWslEnvironment, - formatMissingToolsReason, layer as wslEnvironmentLayer, - parseNodePath, - parseNodeVersion, - parseResolvedPath, - parseToolchainReport, - parseWslRuntimeRoot, - probeWslDistros, resolveWslRunningUser, } 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 @@ -104,6 +98,8 @@ const readField = (stdout: string, field: string) => { const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; +const encoder = new TextEncoder(); + const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => Effect.succeed( @@ -126,6 +122,60 @@ const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exit ), ); +function makeWslEnvironment(spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) { + return 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), + ]), + ), + ), + ); +} + +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* makeWslEnvironment(spawner); + return { environment, started, exited, released }; +}); + describe("WSL account resolution", () => { it.effect("uses a non-login shell and rejects malformed or failed identity output", () => Effect.gen(function* () { @@ -155,96 +205,36 @@ describe("WSL account resolution", () => { 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, + const environment = yield* makeWslEnvironment( makeDistroListSpawner({ stdout: "", exitCode: 0 }), - ), - ), + ); + expect(yield* environment.probeDistros).toEqual([]); + }), ); it.effect("fails when the distro-list command exits unsuccessfully", () => Effect.gen(function* () { - const error = yield* probeWslDistros.pipe(Effect.flip); + const environment = yield* makeWslEnvironment(makeDistroListSpawner({ exitCode: 1 })); + const error = yield* environment.probeDistros.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; + it.effect("fails when the distro-list command times out", () => + Effect.gen(function* () { + const { environment, started } = yield* makeControlledEnvironment; + const fiber = yield* environment.probeDistros.pipe(Effect.flip, Effect.forkScoped); + yield* Deferred.await(started); 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; @@ -594,16 +584,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); @@ -795,53 +775,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( [ @@ -881,78 +814,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"; @@ -966,52 +827,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 eb3610b1c938..0907d1f87f2f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -515,7 +515,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()) @@ -530,7 +530,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()) @@ -540,7 +540,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()) @@ -560,7 +560,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 => { @@ -914,7 +914,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/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/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/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/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/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/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 d4f8312b72b3..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,62 +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) - } - "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..aac4983d75e2 --- /dev/null +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -0,0 +1,93 @@ +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", + "film", + "git-merge", + "git-pull-request-arrow", + "git-pull-request-closed", + "git-pull-request-draft", + "image", + "message-circle", + "messages-square", + "mouse-pointer-click", + "terminal", +]; + +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 16abe358e911..13240c54a975 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"; @@ -558,6 +559,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "SettingsLegal", "SettingsSheet", "ThreadAgents", + "ThreadBackgroundWork", "ThreadPullRequests", "ThreadQueue", "ThreadReviewComment", @@ -729,12 +731,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({ @@ -756,19 +759,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({ @@ -869,6 +871,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: { @@ -900,6 +911,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 74c842be540f..8886f0bf2720 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,64 +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("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 }; @@ -355,46 +287,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(); @@ -513,15 +405,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), @@ -529,120 +430,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) => { @@ -712,18 +499,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); @@ -782,60 +557,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: { @@ -877,100 +598,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: { @@ -995,50 +622,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", () => { @@ -1077,55 +660,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) => { @@ -1224,88 +758,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/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 1293e7893235..eed95771b1a9 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; -import { relayManagedEnvironmentIds, splitEnvironmentSections } from "./environmentSections"; +import { splitEnvironmentSections } from "./environmentSections"; function connectedEnvironment( input: Omit, "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 71221d969927..c5ac7523f898 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 { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -74,12 +75,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, @@ -182,12 +179,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; } @@ -221,6 +223,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" @@ -354,6 +361,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, @@ -490,14 +498,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, @@ -515,13 +555,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; @@ -554,19 +595,22 @@ 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 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; @@ -574,7 +618,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. @@ -620,6 +677,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)), @@ -677,12 +736,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") ? ( @@ -753,10 +877,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps + {isExpanded && !usesCard ? editingAttachments : null} - - ( - - )} - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth="100%" - onPress={openSettings} - /> - + {modelControl} )} @@ -1088,6 +1190,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 d32c35f6c7d5..9741e4644441 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"; @@ -29,7 +39,6 @@ import type { EnvironmentId, MessageId, ModelSelection, - ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, RuntimeRequestId, @@ -42,7 +51,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, @@ -189,6 +202,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; @@ -226,16 +240,16 @@ 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; readonly canSwitchThreadProvider: boolean; 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, @@ -347,7 +361,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(() => { @@ -374,7 +406,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; } @@ -389,6 +428,7 @@ function ThreadVisitWatermark(props: { selectedThreadUpdatedAt, showContent, visitThread, + wakeIsLatestUpdate, ]); return null; } @@ -516,6 +556,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, ); @@ -576,6 +706,46 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai .map((item) => item.label) .join(", ")}`, waiting: pendingBackgroundWork.waiting, + 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: () => 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") { @@ -986,12 +1156,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; } @@ -1402,7 +1597,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} @@ -1426,7 +1625,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 03e9066c9c2d..77729684a792 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, @@ -25,8 +26,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, @@ -56,7 +60,11 @@ import { import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; 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, @@ -147,6 +155,8 @@ import { type MediaVideoPreviewSource, } 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"; @@ -169,7 +179,6 @@ import { resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { markdownFileIconSource } from "../../lib/markdownFileIcons"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { markdownLinkIconSource } from "../../lib/markdownLinkIcons"; import { @@ -218,7 +227,13 @@ import { 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 } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; @@ -230,7 +245,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"; @@ -866,8 +882,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; @@ -884,7 +898,6 @@ function useMarkdownStyles(): MarkdownStyleSets { codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, fileTextColor: markdownUserBodyColor, - skillTextColor: userBubbleSkillForeground, quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, contextChipBorderColor, @@ -907,7 +920,6 @@ function useMarkdownStyles(): MarkdownStyleSets { codeBackgroundColor: markdownCodeBg, codeBlockBackgroundColor: markdownCodeBg, fileTextColor: markdownCodeText, - skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, dividerColor: markdownHrColor, contextChipBorderColor, @@ -923,7 +935,6 @@ function useMarkdownStyles(): MarkdownStyleSets { }, [ boldFontFamily, contextChipBorderColor, - inlineSkillForeground, markdownBlockquoteBorder, markdownBodyColor, markdownCodeBg, @@ -939,7 +950,6 @@ function useMarkdownStyles(): MarkdownStyleSets { markdownUserInlineCodeText, nativeMarkdownTypography, regularFontFamily, - userBubbleSkillForeground, ]); } @@ -996,6 +1006,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; @@ -1022,29 +1033,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} + ); } @@ -1096,6 +1116,14 @@ function renderFeedEntry( hasFailure={entry.hasFailure} shimmer={entry.shimmer} thought={entry.thought} + trailing={ + entry.checkpointSummary ? ( + + ) : null + } onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); @@ -1419,6 +1447,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; @@ -1566,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 ? ( @@ -1683,6 +1741,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); @@ -1706,9 +1777,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 @@ -1745,6 +1819,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, @@ -1811,6 +1898,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); @@ -1833,41 +1923,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" }, @@ -1976,7 +2136,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} /> ); } @@ -1994,7 +2154,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} /> ); }; @@ -2023,7 +2183,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? { ...actionsSource, resource: viewedImage.resource } : undefined } - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ) : null; }; @@ -2358,9 +2518,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), @@ -2374,7 +2536,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) { @@ -2412,6 +2574,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 }) => ( @@ -2443,6 +2613,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 62e98edc0076..acdbaea83efa 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 = useMemo>(() => { @@ -100,6 +101,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 e454e6ec91cb..9813f0d46333 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"; @@ -598,6 +599,7 @@ function ThreadNavigationSidebarPane( hasQueuedMessages={item.hasQueuedMessages} snoozed={item.item.snoozed} pinned={item.item.pinned} + wokeAt={item.item.wokeAt} snoozePresetMinute={item.snoozePresetMinute ?? ""} snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} @@ -797,6 +799,7 @@ function ThreadNavigationSidebarPane( /> + ); @@ -931,6 +934,7 @@ function ThreadNavigationSidebarPane( )} + ); } diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0abb16df6455..e134283ae541 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -932,6 +932,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 f36d45997f1e..85f45baee8e5 100644 --- a/apps/mobile/src/features/threads/composerSendPresentation.test.ts +++ b/apps/mobile/src/features/threads/composerSendPresentation.test.ts @@ -11,46 +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.offersFollowUpChoice).toBe(false); - 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"); - expect(steering.offersFollowUpChoice).toBe(true); - }); - it("never promises steering the provider cannot do", () => { const presentation = resolveComposerSendPresentation({ ...idle, @@ -64,18 +24,4 @@ describe("resolveComposerSendPresentation", () => { expect(presentation.alternate).toBeNull(); expect(presentation.offersFollowUpChoice).toBe(false); }); - - 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"); - expect(presentation.offersFollowUpChoice).toBe(false); - }); }); 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 0c740e5060bb..a012e6253f4f 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), @@ -156,8 +162,11 @@ export function FloatingWorkingControl(props: { hasQueue || (props.devicePreview !== null && props.browserPreview !== null); const statusInteractive = - props.status?.kind === "connection" || props.status?.kind === "child-input"; - const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; + props.status?.kind === "connection" || + props.status?.kind === "child-input" || + 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 ac099ee1d786..264d110f75f7 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 } @@ -15,6 +22,14 @@ export type FloatingWorkingStatus = readonly label: string; readonly accessibilityLabel: string; readonly waiting: boolean; + 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 f49e96381d78..d072c17aa719 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -12,12 +12,19 @@ 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, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { + formatWorkingDurationLabel, + resolveThreadWorkingStartedAt, +} from "@t3tools/client-runtime/state/models"; import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; @@ -112,6 +119,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 } }, @@ -477,6 +491,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly hasQueuedMessages?: boolean; readonly snoozed?: boolean; readonly pinned?: boolean; + readonly wokeAt: string | null; readonly snoozeWakeLabelText?: string; readonly timeLabel: string; readonly snoozePresetMinute: string; @@ -568,12 +583,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleOpenPullRequests = useCallback(() => { if (pr) onOpenThreadPullRequests(thread, pr); }, [onOpenThreadPullRequests, pr, thread]); - // VoiceOver cannot focus the badge inside the accessible row, so the row - // exposes the same action. - const rowAccessibilityActions = useMemo( - () => (prBadgeLabel ? [{ name: "openPullRequests", label: prBadgeLabel }] : undefined), - [prBadgeLabel], - ); const theme = useUniwindTheme(); const sidebarPane = props.pane === "sidebar"; @@ -583,6 +592,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" } @@ -590,6 +600,23 @@ 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 = useCallback(() => { + if (props.wokeAt === null) return; + void visitThread({ + environmentId: thread.environmentId, + input: { threadId: thread.id, visitedAt: props.wokeAt }, + }); + }, [props.wokeAt, thread.environmentId, thread.id, visitThread]); + // VoiceOver cannot focus buttons inside the accessible row. + const rowAccessibilityActions = useMemo( + () => [ + ...(prBadgeLabel ? [{ name: "openPullRequests", label: prBadgeLabel }] : []), + ...(showsWoke ? [{ name: "dismissWoke", label: "Dismiss Woke notification" }] : []), + ], + [prBadgeLabel, showsWoke], + ); + const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); @@ -748,6 +775,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...arrangementMenuItems, ...titleMenuItems, ...autoSettleMenuItems, + LEGACY_MENU_ACTIONS[0]!, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems], @@ -758,6 +786,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...arrangementMenuItems, ...titleMenuItems, ...autoSettleMenuItems, + LEGACY_MENU_ACTIONS[0]!, ...CARD_MENU_ACTIONS.slice(1), ], [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], @@ -770,6 +799,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ), ...titleMenuItems, ...autoSettleMenuItems, + LEGACY_MENU_ACTIONS[0]!, SLIM_MENU_ACTIONS[1]!, ], [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], @@ -779,6 +809,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, ...autoSettleMenuItems, + LEGACY_MENU_ACTIONS[0]!, SNOOZED_MENU_ACTIONS[1]!, ], [autoSettleMenuItems, titleMenuItems], @@ -944,7 +975,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} ) : ( @@ -1152,6 +1216,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 b347227caecd..b7ceb8c7b906 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,6 +1,7 @@ import { Button } from "../../components/ui/Button"; import { SubagentStatusDot } from "./SubagentStatusDot"; import { ThreadSubagentGroup } from "./thread-subagent-group"; +import { ProposedPlanCard } from "./ProposedPlanCard"; import { WorkLogLabel, WorkLogBlock, @@ -483,6 +484,7 @@ interface ThreadWorkLogProps { readonly activities: ReadonlyArray; readonly anchorKey: string; readonly environmentId: EnvironmentId; + readonly workspaceRoot?: string | null | undefined; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; readonly rowSizing: ReturnType; @@ -521,6 +523,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 ; } @@ -1289,6 +1303,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; @@ -1321,49 +1336,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 73c4faa7b712..a67e3a6fc8a9 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"; @@ -33,11 +32,9 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, - isThreadListV2ListItem, resolveThreadListV2SnoozeMenuSelection, - resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, - resolveThreadListV2SwipeActions, + resolveThreadListV2WokeAt, sortThreadsForListV2, threadListV2ListItemsAreEqual, threadHasUnseenCompletion, @@ -58,43 +55,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); @@ -138,72 +99,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" }, @@ -225,12 +120,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", () => { @@ -260,118 +149,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", () => { @@ -383,15 +160,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", () => { @@ -444,94 +212,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("hides snoozed threads and counts them — visibility parity with web", () => { - const layout = buildThreadListV2Items({ - threads: [ - makeThread({ id: ThreadId.make("active"), title: "Active" }), - makeThread({ - id: ThreadId.make("snoozed"), - title: "Snoozed", - snoozedUntil: "2026-06-03T09:00:00.000Z", - snoozedAt: "2026-06-01T12:00:00.000Z", - }), - makeThread({ - id: ThreadId.make("woken"), - title: "Woken", - snoozedUntil: "2026-06-01T18:00:00.000Z", - snoozedAt: "2026-06-01T12:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); - 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: [ @@ -606,32 +286,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: [ @@ -672,26 +326,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: [ @@ -771,105 +405,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: [ @@ -893,48 +428,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"); @@ -1033,33 +526,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`, @@ -1081,37 +551,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, @@ -1230,36 +669,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: [ @@ -1391,12 +800,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(); @@ -1515,20 +918,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", () => { @@ -1547,18 +936,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) => { @@ -1719,15 +1096,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", () => { @@ -1762,46 +1130,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, @@ -1877,24 +1205,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: [ @@ -1933,27 +1243,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" }); @@ -1993,198 +1282,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, [], { + 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(), }); - vi.setSystemTime(BASE_MS + MINUTE_MS); - const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], { - snoozeEnvironmentIds: new Set(), + const list = (now: string) => + buildThreadListV2ListItems({ + items: layout(now).items, + pendingTasks: [], + snoozeLabelNow: isoAt(BASE_MS), }); - 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 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); }); }); @@ -2248,46 +1436,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", () => { @@ -2359,14 +1507,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) => diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 360fbe18f090..78a65df9062e 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"; @@ -151,6 +152,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, @@ -246,6 +261,7 @@ export interface ThreadListV2Item { readonly variant: "card" | "slim"; readonly snoozed: boolean; readonly pinned: boolean; + readonly wokeAt: string | null; readonly isLast: boolean; } @@ -357,6 +373,7 @@ export function threadListV2ListItemsAreEqual( previous.item.variant === item.item.variant && previous.item.snoozed === item.item.snoozed && previous.item.pinned === item.item.pinned && + previous.item.wokeAt === item.item.wokeAt && previous.snoozeWakeLabelText === item.snoozeWakeLabelText && previous.timeLabel === item.timeLabel && previous.snoozePresetMinute === item.snoozePresetMinute && @@ -405,7 +422,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 = @@ -694,6 +713,7 @@ export function buildThreadListV2Items(input: { variant: "card", snoozed: false, pinned: true, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -703,6 +723,7 @@ export function buildThreadListV2Items(input: { variant: "card", snoozed: false, pinned: false, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -713,6 +734,7 @@ export function buildThreadListV2Items(input: { variant: "card", snoozed: false, pinned: false, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -723,6 +745,7 @@ export function buildThreadListV2Items(input: { variant: "slim", snoozed: true, pinned: false, + wokeAt: null, isLast: false, }); } @@ -733,6 +756,7 @@ export function buildThreadListV2Items(input: { variant: "slim", snoozed: false, pinned: 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 093b077aab2b..14751cedd402 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts @@ -2,13 +2,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", () => { @@ -37,50 +33,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("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, @@ -98,22 +50,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", () => { @@ -129,24 +65,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 9a2c8b74bdd8..f038fe8f9c25 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 @@ -1,12 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, -} from "@t3tools/contracts"; -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { ProviderDriverKind } from "@t3tools/contracts"; const refreshProviders = vi.hoisted(() => vi.fn()); vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); @@ -31,7 +24,6 @@ vi.mock("../../state/use-atom-command", () => ({ import { buildComposerSlashCommandItems, resolveComposerCommandSelection, - useComposerCommandMenu, } from "./use-composer-command-menu"; describe("mobile slash commands", () => { @@ -67,18 +59,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", @@ -111,213 +91,3 @@ describe("mobile slash commands", () => { ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); }); }); - -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); - }); -}); 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 d1c864174e3e..000000000000 --- a/apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx +++ /dev/null @@ -1,193 +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, - 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 9013a3dfcc9f..010c902364c4 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -41,6 +41,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, @@ -155,22 +202,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 deleted file mode 100644 index 94aa1a0f8b25..000000000000 --- a/apps/mobile/src/lib/composerChipPresentation.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -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"); - }); -}); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 90e2d5d15fa2..257234fd02b4 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -1,22 +1,47 @@ 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" }, -} 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" }, + 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; @@ -27,11 +52,11 @@ 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; export function contextChipPresentation( kind: string, @@ -45,7 +70,7 @@ export function contextChipPresentation( readonly isDraft?: boolean; }; }, -) { +): ContextChipPresentation { const presentationKind = kind === "file" && videoMimeType({ @@ -71,5 +96,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..10a8b6af70ad --- /dev/null +++ b/apps/mobile/src/lib/contextChipIcons.generated.ts @@ -0,0 +1,16 @@ +import type { ImageSourcePropType } from "react-native"; + +export const CONTEXT_CHIP_ICON_SOURCES = { + "circle-dashed": require("../../assets/context-chip-icons/circle-dashed.png"), + film: require("../../assets/context-chip-icons/film.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"), + image: require("../../assets/context-chip-icons/image.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"), + terminal: require("../../assets/context-chip-icons/terminal.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 99289ed247bc..db16d0181591 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -1,31 +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", -}; - 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 640a512dd4e5..42f737f16b34 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -140,6 +140,37 @@ export function markdownWithHtml(markdown: string): string { return 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; +} + // A delimiter row such as `|---|:--:|`, possibly inside a blockquote or list item. function isTableDelimiterRow(line: string): boolean { if (!line.includes("-")) return false; 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 453f06f6004e..4f1b92befaa3 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; @@ -235,10 +245,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" || @@ -248,6 +309,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; @@ -268,6 +330,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + providerBadge, ...(provider.supportedRuntimeModes === undefined ? {} : { supportedRuntimeModes: provider.supportedRuntimeModes }), @@ -332,6 +395,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 e9973a8362ef..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,76 +149,6 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); - it("persists thread list shelf preferences", async () => { - await expect( - savePreferencesPatch({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, - threadListWorkingShelfExpanded: true, - workingShelfEnabled: true, - }), - ).resolves.toEqual({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, - threadListWorkingShelfExpanded: true, - workingShelfEnabled: true, - }); - - await expect(loadPreferences()).resolves.toEqual({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, - threadListWorkingShelfExpanded: true, - workingShelfEnabled: true, - }); - expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, - 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, - threadListSettledShelfExpanded: "false", - threadListSnoozedShelfExpanded: 1, - }), - 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( @@ -337,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 b443c0e3f7a1..1163df73f4a0 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,38 +1,31 @@ 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,98 +53,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"); -}); - -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) { @@ -245,17 +166,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"; @@ -292,73 +370,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"), @@ -413,287 +424,6 @@ 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("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 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), @@ -794,69 +524,19 @@ 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", () => { - const opening = { - ...assistantMessage("2026-06-20T00:00:01.500Z"), - id: TurnItemId.make("item-opening"), - messageId: MessageId.make("message-opening"), - text: "I will check the deployment configuration.", - }; - const middle = { - ...assistantMessage("2026-06-20T00:00:02.500Z"), - id: TurnItemId.make("item-middle"), - messageId: MessageId.make("message-middle"), - text: "The configuration is valid; checking the build next.", - }; + 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"), + messageId: MessageId.make("message-opening"), + text: "I will check the deployment configuration.", + }; + const middle = { + ...assistantMessage("2026-06-20T00:00:02.500Z"), + id: TurnItemId.make("item-middle"), + messageId: MessageId.make("message-middle"), + text: "The configuration is valid; checking the build next.", + }; const feed = buildThreadFeed([ projected(userMessage(), 0), projected(opening, 1), @@ -874,30 +554,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( @@ -915,7 +606,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", ]); }); @@ -1063,54 +754,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, @@ -1230,7 +873,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( @@ -1239,7 +882,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), @@ -1248,7 +891,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", () => { @@ -1281,42 +924,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 }; @@ -1330,374 +937,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", () => { @@ -1709,263 +948,40 @@ describe("retained v2 feed presentation", () => { ]; const latestRun = { runId, - status: "running" as const, - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: null, - }; - const before = buildThreadFeed(rows); - const beforePresentation = deriveThreadFeedPresentation( - before, - latestRun, - new Set(), - new Set(), - latestRun.startedAt, - ); - const after = buildThreadFeed([ - rows[0]!, - rows[1]!, - projected( - { ...assistantMessage("2026-06-20T00:00:04.000Z"), text: "Still working", streaming: true }, - 2, - ), - ]); - const afterPresentation = deriveThreadFeedPresentation( - after, - latestRun, - new Set(), - new Set(), - latestRun.startedAt, - ); - expect(after[0]).toBe(before[0]); - expect(after[1]).toBe(before[1]); - expect(after[2]).not.toBe(before[2]); - expect(afterPresentation[0]).toBe(beforePresentation[0]); - 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, + status: "running" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: null, }; - const rows = [0, 1].map((index) => + const before = buildThreadFeed(rows); + const beforePresentation = deriveThreadFeedPresentation( + before, + latestRun, + new Set(), + new Set(), + latestRun.startedAt, + ); + const after = buildThreadFeed([ + rows[0]!, + rows[1]!, 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, + { ...assistantMessage("2026-06-20T00:00:04.000Z"), text: "Still working", streaming: true }, + 2, ), - ); - 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, + ]); + const afterPresentation = deriveThreadFeedPresentation( + after, latestRun, new Set(), - new Set([toggle.groupId]), + new Set(), + latestRun.startedAt, ); - 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 } }, - ], - }); + expect(after[0]).toBe(before[0]); + expect(after[1]).toBe(before[1]); + expect(after[2]).not.toBe(before[2]); + expect(afterPresentation[0]).toBe(beforePresentation[0]); + expect(afterPresentation[1]).toBe(beforePresentation[1]); }); - 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" } } }, @@ -2051,32 +1067,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( @@ -2124,35 +1114,6 @@ describe("retained v2 feed presentation", () => { { 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" }] }, - ]); - }); }); const singleSelectQuestion = { @@ -2166,17 +1127,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`; @@ -2247,50 +1197,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", () => { @@ -2316,79 +1225,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", () => { @@ -2455,193 +1291,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 => ({ @@ -2699,44 +1348,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) => { @@ -2796,118 +1407,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`; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ff68fa56f2e8..1e942fc8ca76 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 { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; @@ -145,6 +146,7 @@ export interface ThreadFeedMessage { readonly createdAt: string; readonly updatedAt: string; readonly projectedItem?: OrchestrationV2ProjectedTurnItem; + readonly checkpointSummary?: ThreadCheckpointSummary; } export interface LocalThreadMessage { @@ -191,7 +193,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 @@ -467,6 +472,7 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean { item.type === "fork" || item.type === "thread_created" || item.type === "system_notice" || + item.type === "proposed_plan" || item.type === "secret_request" ); } @@ -823,19 +829,23 @@ function toFeedActivity( ? collectToolFilePaths(item) : null; const getFullDetail = memoizeValue(() => - readPaths - ? readPaths.join("\n") || null - : item.type === "notification" - ? item.detail?.trim() || null - : formatItemFullDetail(row, item), + item.type === "proposed_plan" + ? item.markdown + : readPaths + ? readPaths.join("\n") || null + : item.type === "notification" + ? item.detail?.trim() || null + : formatItemFullDetail(row, item), ); const getCopyText = memoizeValue(() => - [summary, detail, getFullDetail()] - .filter( - (value, index, values): value is string => - Boolean(value) && values.indexOf(value) === index, - ) - .join("\n"), + item.type === "proposed_plan" + ? `${item.markdown.trimEnd()}\n` + : [summary, detail, getFullDetail()] + .filter( + (value, index, values): value is string => + Boolean(value) && values.indexOf(value) === index, + ) + .join("\n"), ); return { id: `${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, @@ -955,6 +965,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 === "notification"; @@ -1059,7 +1070,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); @@ -1114,9 +1124,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; @@ -1146,13 +1153,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" && @@ -1161,6 +1166,7 @@ function deriveThreadFeedRunFolds( entry.activities.some( (activity) => activity.prominent || + activity.workEntry.questionAnswer !== undefined || activity.projectedItem.item.type === "notification" || activity.projectedItem.item.type === "handoff" || // A child still working outlives its settled launching run; @@ -1470,6 +1476,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]!; @@ -1898,6 +1937,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; @@ -1927,6 +1967,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>, @@ -1953,6 +2017,7 @@ export function buildThreadFeed( readonly anchoredMessages?: ReadonlyArray; readonly attempts?: ReadonlyArray; readonly nodes?: ReadonlyArray; + readonly checkpointSummaries?: ReadonlyArray; readonly cache?: ThreadFeedCache; }, ): ThreadFeedEntry[] { @@ -1999,6 +2064,7 @@ export function buildThreadFeed( anchoredMessages.length === 0 && previous.attempts === options?.attempts && previous.nodes === options?.nodes && + checkpointSummariesEqual(previous.checkpointSummaries, options?.checkpointSummaries) && setsEqual(previous.foldedAnswerMessageIds, foldedAnswerMessageIds) && setMapsEqual(previous.delegatedChildrenByRun, delegatedChildrenByRun) ) { @@ -2153,8 +2219,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 @@ -2174,6 +2250,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 7e7ecf39674c..5d965dad4dc3 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -4,7 +4,11 @@ import { formatComposerContextHref, formatComposerContextReference, } from "@t3tools/shared/composerContextReferences"; -import { markdownWithHtml, markdownWithTableBreaks } from "../lib/markdownDocument"; +import { + markdownWithHtml, + markdownWithInlineFileLinks, + markdownWithTableBreaks, +} from "../lib/markdownDocument"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import { Platform, Share, StyleSheet, View } from "react-native"; import { @@ -151,7 +155,13 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { const { renderImage, resolveImageSource } = props; const { themeAppearance, mathRenderingEnabled } = useAppearancePreferences(); const markdown = useMemo( - () => markdownWithTableBreaks(markdownWithHtml(props.markdown)), + () => + markdownWithTableBreaks( + markdownWithInlineFileLinks( + markdownWithHtml(props.markdown), + ENRICHED_INLINE_FILE_LINK_REGEX, + ), + ), [props.markdown], ); const [documentAssets, setDocumentAssets] = useState([]); @@ -178,7 +188,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 8240fbc74df4..698a345035cc 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 f2d2699f7a89..519de1993194 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -15,9 +15,13 @@ import { 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"; @@ -99,6 +103,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); @@ -108,10 +115,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, @@ -125,6 +128,7 @@ export function ComposerEditor({ onFocus, onBlur, onSubmit, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -164,12 +168,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) @@ -177,12 +189,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", }; }), ); @@ -349,6 +357,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 dce1589439ad..89fd665ab1df 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -16,9 +16,13 @@ import { import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { Image, 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"; @@ -98,6 +102,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); @@ -107,10 +114,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, @@ -123,6 +126,7 @@ export function ComposerEditor({ onPasteText, onFocus, onBlur, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -162,12 +166,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) @@ -175,12 +187,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", }; }), ); @@ -354,6 +362,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 8c99457a9646..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: {}, -} 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 1b1a2550ccd6..82ac4dfff5b4 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -108,7 +108,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, @@ -229,17 +232,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 ac58170cd0d4..98bec9753325 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -371,6 +371,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 c979ac6d4b66..ebdf9769d46f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -4,6 +4,7 @@ import { readEnvironmentScope } from "./session"; import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { deriveProviderSubagentStatus, @@ -117,6 +118,7 @@ import { useAtomCommand } from "./use-atom-command"; 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; @@ -240,6 +242,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 @@ -256,6 +263,7 @@ export function useThreadComposerState() { anchoredMessages: pendingCreation, attempts: selectedThreadAttempts, nodes: selectedThreadNodes, + checkpointSummaries, cache: feedCache, }); const pendingAcknowledgments = acknowledgedMessages.filter( @@ -520,7 +528,10 @@ export function useThreadComposerState() { scheduleUnusedComposerAttachmentCleanup(draft.attachments); }; - const onSendMessage = async (followUpOverride?: ActiveTurnComposerAction) => { + const onSendMessage = async ( + followUpOverride?: ActiveTurnComposerAction, + beforeDispatch?: () => Promise, + ) => { if ( selectedThreadShell && selectedEnvironmentRuntime?.connectionState === "connected" && @@ -637,15 +648,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 d4fd80d827e0..d0c8969c5f86 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.89", + "version": "0.0.90", "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 f51a8580f3f7..950c6909d013 100644 --- a/apps/server/scripts/acp-replay-agent.ts +++ b/apps/server/scripts/acp-replay-agent.ts @@ -56,10 +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 temporaryPath = `${replayStatusPath}.${process.pid}.tmp`; + const temporaryStatusPath = `${replayStatusPath}.${process.pid}.tmp`; NodeFS.writeFileSync( - temporaryPath, + temporaryStatusPath, JSON.stringify({ scenario: transcript.scenario, cursor, @@ -68,7 +70,7 @@ function writeStatus(failure?: unknown): void { }), "utf8", ); - NodeFS.renameSync(temporaryPath, replayStatusPath); + 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/cli.ts b/apps/server/scripts/cli.ts index 9acff58ab2a1..e21037d60da9 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -20,6 +20,7 @@ import { resolveCatalogDependencies, resolveServerPublishDependencies, } from "../../../scripts/lib/resolve-catalog.ts"; +import { createServerPublishConfig } from "../../../scripts/lib/server-publish-config.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -38,7 +39,7 @@ import { interface PackageJson { name: string; license: string; - publishConfig: { registry: string }; + publishConfig: { registry: string; executableFiles: string[] }; repository: { type: string; url: string; @@ -325,10 +326,18 @@ const publishCmd = Command.make( const workspaceConfig = yield* readWorkspaceConfig(); const workspaceCatalog = workspaceConfig.catalog ?? {}; const workspaceOverrides = workspaceConfig.overrides ?? {}; + const resourceMonitorDir = path.join(serverDir, "dist/resource-monitor"); + const resourceMonitorFiles = (yield* fs.exists(resourceMonitorDir)) + ? yield* fs.readDirectory(resourceMonitorDir, { recursive: true }) + : []; const pkg: PackageJson = { name: T3_NPM_PACKAGE, license: serverPackageJson.license, - publishConfig: { registry: T3_NPM_REGISTRY }, + publishConfig: createServerPublishConfig( + T3_NPM_REGISTRY, + resourceMonitorFiles, + (serverPackageJson as Partial).publishConfig, + ), repository: { ...serverPackageJson.repository, url: "https://github.com/RTVision/t3code", diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index d8bf059852b1..27c52f3fb4e9 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -194,33 +194,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/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 2c86da4bd287..a5600ecb5b8f 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,3 +1,4 @@ +import * as TestClock from "effect/testing/TestClock"; import * as NodeOS from "node:os"; // @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -8,12 +9,10 @@ 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 GiteaCli from "../sourceControl/GiteaCli.ts"; import * as Option from "effect/Option"; import * as DateTime from "effect/DateTime"; @@ -22,9 +21,7 @@ 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"; @@ -37,7 +34,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"; @@ -53,6 +49,8 @@ vi.mock("node:os", async (importOriginal) => { return { ...actual, homedir: vi.fn(actual.homedir) }; }); +const encodeAssetAccessError = Schema.encodeEffect(Schema.fromJsonString(AssetAccessError)); + const layerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); @@ -177,6 +175,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; @@ -315,30 +314,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; @@ -411,41 +386,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", () => @@ -591,40 +531,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", () => @@ -806,204 +712,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; @@ -1089,188 +797,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; @@ -1356,27 +882,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; @@ -1436,136 +941,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({ - invalidate: () => Effect.void, - 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) => { @@ -1612,7 +987,7 @@ describe("AssetAccess", () => { ]) { const error = yield* issue(url).pipe(Effect.flip); expect(error._tag).toBe("AssetGitHubMediaUrlValidationError"); - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error); + const encoded = yield* encodeAssetAccessError(error); expect(encoded).not.toContain(url); } }).pipe(Effect.provide(layerTest)), 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 a0189cdf84d4..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.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 91bc07035217..569ae75907d1 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -600,7 +600,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)) { 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 e13417bb0535..581517de719d 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -6,15 +6,10 @@ import { AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, ProviderInstanceId, - ProjectId, ThreadId, AuthOrchestrationOperateScope, - ORCHESTRATION_V2_WS_METHODS, AuthOrchestrationReadScope, AuthSourceControlWriteScope, - AuthPreviewOperateScope, - AuthRelayReadScope, - AuthRelayWriteScope, AuthTerminalReadScope, AuthTerminalOperateScope, WS_METHODS, @@ -34,42 +29,10 @@ import { import * as RpcAuthorization from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { - it("lets readers inspect CI but requires source control writes to rerun it", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsCiRuns)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsCiJobs)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsRerunCi)).toBe( - AuthSourceControlWriteScope, - ); - }); it("declares exactly one scope for every RPC in the server group", () => { 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, @@ -80,115 +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, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( - AuthOrchestrationOperateScope, - ); - }); - - 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("reads dependency context under the same scope as pull request detail", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsDependencyContext)).toBe( - requiredScopeForRpcMethod(WS_METHODS.pullRequestsDetail), - ); - }); - - 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("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, @@ -248,10 +102,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 = []; @@ -277,27 +127,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", () => { @@ -416,7 +245,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* () { @@ -502,64 +331,6 @@ it.effect("separates host file URLs from readable attachment URLs", () => }).pipe(Effect.scoped), ); -describe("fork pull request mutation authorization", () => { - const tested = [WS_METHODS.pullRequestsRerunCi, WS_METHODS.pullRequestsSetFileViewed] as const; - const group = WsRpcGroup.omit( - ...[...WsRpcGroup.requests.keys()].filter( - (tag): tag is Exclude => - !(tested as ReadonlyArray).includes(tag), - ), - ); - const reference = { - projectId: ProjectId.make("project"), - repository: "acme/repo", - number: 1, - headSha: "head", - }; - - it.effect.each([ - { scopes: [AuthOrchestrationReadScope], allowed: false }, - { scopes: [AuthOrchestrationOperateScope], allowed: false }, - { scopes: [AuthSourceControlWriteScope], allowed: true }, - ])("checks source control permission before dispatch, allowed=$allowed", ({ scopes, allowed }) => - Effect.gen(function* () { - let handled = 0; - const handle = () => - Effect.sync(() => { - handled++; - }); - const client = yield* RpcTest.makeClient(group).pipe( - Effect.provide( - Layer.mergeAll( - group.toLayerHandler(WS_METHODS.pullRequestsRerunCi, handle), - group.toLayerHandler(WS_METHODS.pullRequestsSetFileViewed, handle), - RpcAuthorization.layer(scopes), - ), - ), - ); - const rerun = client[WS_METHODS.pullRequestsRerunCi]({ - ...reference, - runId: "run", - attempt: 1, - target: { kind: "all" }, - }); - const viewed = client[WS_METHODS.pullRequestsSetFileViewed]({ - ...reference, - path: "file.ts", - viewed: true, - }); - for (const call of [rerun, viewed]) { - if (allowed) yield* call; - else - expect(yield* call.pipe(Effect.flip)).toMatchObject({ - _tag: "EnvironmentAuthorizationError", - requiredPermission: AuthSourceControlWriteScope, - }); - } - expect(handled).toBe(allowed ? 2 : 0); - }).pipe(Effect.scoped), - ); -}); it.effect("denies manual cleanup before the handler without settings permission", () => Effect.gen(function* () { const group = WsRpcGroup.omit( 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 2ab8099f1a96..325f0f991e9c 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"; @@ -161,78 +155,3 @@ it.effect("sets the selected browser session cookies through the HTTP route", () ); }).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/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 04c1aa82c746..7a09b7269e91 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 02a8b2b594af..000000000000 --- a/apps/server/src/cli/browser.test.ts +++ /dev/null @@ -1,242 +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 Path from "effect/Path"; -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.LinuxLibc, "gnu"), - 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), - Effect.provideService(HostProcess.LinuxLibc, "gnu"), - // 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."); - }), -); - -const windowsHost = HostProcess.Platform.defaultValue() === "win32"; - -it.layer(NodeServices.layer)("t3 browser setup", (it) => { - for (const platform of ["linux", "win32"] as const) { - for (const installed of [false, true]) { - it.effect.skipIf(windowsHost && platform === "linux" && installed)( - `honors the configured executable on ${platform}: ${installed}`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-browser-setup-" }); - const executable = path.join(root, "custom-chromium"); - if (installed) { - yield* fs.writeFileString(executable, "browser", { - mode: platform === "win32" ? 0o644 : 0o755, - }); - } - const output = yield* Effect.gen(function* () { - const previous = yield* TestConsole.logLines; - yield* Command.runWith(browserCommand, { version: "0.0.0" })([ - "setup", - "--base-dir", - root, - ]); - return (yield* TestConsole.logLines).slice(previous.length); - }).pipe( - Effect.provide(TestConsole.layer), - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService(HostProcess.LinuxLibc, "gnu"), - Effect.provideService(HostProcess.Environment, { - T3CODE_PREVIEW_BROWSER_PATH: executable, - }), - ); - - expect(output).toHaveLength(1); - expect(output[0]).toContain( - installed ? `T3 uses ${executable}` : "T3CODE_PREVIEW_BROWSER_PATH points to", - ); - expect(output[0]).not.toContain("runs as is"); - expect(yield* fs.exists(path.join(root, "tools"))).toBe(false); - }), - ); - } - } - - for (const installed of [false, true]) { - it.effect.skipIf(installed && windowsHost)( - `reports native Chromium installation state: ${installed}`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-browser-setup-" }); - if (installed) { - const executable = path.join(root, "usr/lib/chromium/chromium"); - yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); - yield* fs.writeFileString(executable, "#!/bin/sh\n", { mode: 0o755 }); - } - - const output = yield* Effect.gen(function* () { - const previous = yield* TestConsole.logLines; - yield* Command.runWith(browserCommand, { version: "0.0.0" })([ - "setup", - "--base-dir", - root, - ]); - return (yield* TestConsole.logLines).slice(previous.length); - }).pipe( - Effect.provide(TestConsole.layer), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HostProcess.Environment, {}), - Effect.provideService(HostProcess.LinuxLibc, "musl"), - Effect.provideService(FileSystem.FileSystem, { - ...fs, - stat: (file) => - fs.stat(file.startsWith("/usr/") ? path.join(root, file.slice(1)) : file), - }), - ); - - expect(output).toHaveLength(1); - expect(output[0]).toContain( - installed ? "T3 uses /usr/lib/chromium/chromium" : "apk add chromium", - ); - expect(output[0]).not.toContain("installs on first use"); - expect(yield* fs.exists(path.join(root, "tools"))).toBe(false); - }), - ); - } -}); 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 60b6f00f5dc9..4d79b8fccb02 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,119 +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 --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - [ - "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - [ - "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", - "pnpm dlx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - [ - "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", - "pnpm dlx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - [ - "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "pnpm dlx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - [ - "/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - [ - "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - [ - "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/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 --registry=https://npm-registry.rtvision.com/ @rtvision/t3@nightly serve", - ], - [ - "0.0.31-preview.20260729.1", - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3@preview serve", - ], - [ - "0.0.31-foo-preview.20260729.1", - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ], - ["0.0.31", "npx --registry=https://npm-registry.rtvision.com/ @rtvision/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 --registry=https://npm-registry.rtvision.com/ @rtvision/t3@nightly serve", - ); - assert.equal( - formatCliCommand({ - subcommand: "serve", - entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", - version: "0.0.31", - }), - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/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; @@ -286,39 +178,35 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { }), ); - it.effect.each(["t3", "@rtvision/t3"] as const)( - "requires the %s npm prefix's bin to point to the running package", - (packageName) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped(); - const prefix = path.join(root, "bunx-tools"); - const packageRoot = path.join(prefix, "lib/node_modules", packageName); - const entry = path.join(packageRoot, "dist/bin.mjs"); - const globalBin = path.join(prefix, "bin/t3"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); - yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true }); - yield* fs.writeFileString(entry, ""); - yield* fs.writeFileString( - path.join(packageRoot, "package.json"), - `{"name":"${packageName}","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}`, - ); - const resolve = resolveServerInstallation.pipe( - Effect.provideService(HostProcess.Arguments, ["node", entry]), - Effect.provideService(HostProcess.IsExecutable, false), - Effect.provideService(HostProcess.Platform, "linux"), - ); - expect(yield* resolve).toBeNull(); - yield* fs.symlink(entry, globalBin); - expect(yield* resolve).toEqual({ kind: "npm-global", prefix }); - yield* fs.remove(globalBin); - yield* fs.writeFileString(globalBin, "an unrelated t3 command"); - expect(yield* resolve).toBeNull(); - expect( - yield* resolve.pipe(Effect.provideService(HostProcess.Platform, "win32")), - ).toBeNull(); - }), + it.effect("requires the npm prefix's bin to point to the running package", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + const prefix = path.join(root, "bunx-tools"); + const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const entry = path.join(packageRoot, "dist/bin.mjs"); + const globalBin = path.join(prefix, "bin/t3"); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true }); + yield* fs.writeFileString(entry, ""); + yield* fs.writeFileString( + path.join(packageRoot, "package.json"), + '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}', + ); + const resolve = resolveServerInstallation.pipe( + Effect.provideService(HostProcess.Arguments, ["node", entry]), + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.Platform, "linux"), + ); + expect(yield* resolve).toBeNull(); + yield* fs.symlink(entry, globalBin); + expect(yield* resolve).toEqual({ kind: "npm-global", prefix }); + yield* fs.remove(globalBin); + yield* fs.writeFileString(globalBin, "an unrelated t3 command"); + expect(yield* resolve).toBeNull(); + expect(yield* resolve.pipe(Effect.provideService(HostProcess.Platform, "win32"))).toBeNull(); + }), ); it.effect("proves the downloaded executable was started by a global npm launcher", () => diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 070ca425fb92..08f1db81730c 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -114,7 +114,7 @@ function suggestedPackageSpec(version: string): string { return channel === undefined ? T3_NPM_PACKAGE : `${T3_NPM_PACKAGE}@${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 b4baaa48e870..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,29 +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 --registry=https://npm-registry.rtvision.com/ @rtvision/t3 serve", - ); - assert.include( - rendered, - "npx --registry=https://npm-registry.rtvision.com/ @rtvision/t3 connect", - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("ignores runtime state whose recorded pid is no longer alive", () => withDescriptorServer((origin) => Effect.gen(function* () { @@ -377,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 feab787af359..8c12eea0e8f1 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 c65b291c5b64..d03991b8a91e 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -32,7 +32,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; }) { @@ -68,10 +68,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 7576197df552..000000000000 --- a/apps/server/src/cli/sshHelper.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Exercises the CLI's Node process boundary. -import * as NodeNet from "node:net"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as FileSystem from "effect/FileSystem"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeVM from "node:vm"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import { buildRemoteLaunchScript } from "@t3tools/ssh/tunnel"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { Command } from "effect/cli"; -import { vi } from "vite-plus/test"; - -import { sshHelperCommand } from "./sshHelper.ts"; - -const cli = Command.make("t3").pipe(Command.withSubcommands([sshHelperCommand])); -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const launchScript = buildRemoteLaunchScript({ nodeScriptPath: "/unused/t3.mjs" }); -const inlineScript = launchScript.match( - /node - "\$\{1:-\$DEFAULT_RUNTIME_FILE\}" <<'NODE'\n([\s\S]*?)\nNODE/, -)?.[1]; -if (!inlineScript) throw new Error("SSH runtime discovery script was not found"); - -const childPid = 12345; -const launcherPid = 23456; -const runtime = { pid: childPid, port: 3773, origin: "http://127.0.0.1:3773" }; -const cases = [ - { name: "reuses a foreground server", runtime, live: [childPid], found: true }, - { name: "rejects a stopped foreground server", runtime, live: [], found: false }, - { - name: "reserves the daemon port while its child is replaced", - runtime: { ...runtime, launcherPid }, - live: [launcherPid], - found: true, - }, - { - name: "rejects a dead launcher even if the child PID is alive", - runtime: { ...runtime, launcherPid }, - live: [childPid], - found: false, - }, - { - name: "discovers a daemon with a wildcard origin", - runtime: { ...runtime, launcherPid, origin: "http://0.0.0.0:3773" }, - live: [launcherPid], - found: true, - }, - { - name: "requires loopback for foreground discovery", - runtime: { ...runtime, origin: "http://192.0.2.1:3773" }, - live: [childPid], - found: false, - }, - ...[0, -1, null, "invalid", 1.5].map((pid) => ({ - name: `rejects invalid launcher PID ${String(pid)}`, - runtime: { ...runtime, launcherPid: pid }, - live: [childPid], - found: false, - })), -]; - -describe.each(["packaged", "inline"] as const)(`%s SSH runtime discovery`, (mode) => { - it.effect.each(cases)("$name", (testCase) => - Effect.gen(function* () { - const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-ssh-helper-")); - const runtimeFile = NodePath.join(dir, "server-runtime.json"); - NodeFS.writeFileSync(runtimeFile, encodeJson(testCase.runtime)); - const previousExitCode = process.exitCode; - const probes: number[] = []; - let output = ""; - let exitCode = 0; - const kill = (pid: number, signal?: string | number) => { - assert.equal(signal, 0); - probes.push(pid); - if (!testCase.live.includes(pid)) throw new Error("ESRCH"); - return true as const; - }; - try { - if (mode === "packaged") { - process.exitCode = 0; - vi.spyOn(process, "kill").mockImplementation(kill); - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - output += String(chunk); - return true; - }); - yield* Command.runWith(cli, { version: "0.0.0" })([ - "__ssh-helper", - "runtime-port", - runtimeFile, - ]).pipe(Effect.provide(NodeServices.layer)); - exitCode = Number(process.exitCode); - } else { - const exited = new Error("process.exit"); - try { - NodeVM.runInNewContext(inlineScript, { - require: () => NodeFS, - URL, - process: { - argv: ["node", "-", runtimeFile], - kill, - stdout: { - write: (chunk: string) => { - output += chunk; - }, - }, - exit: (code: number) => { - exitCode = code; - throw exited; - }, - }, - }); - } catch (error) { - if (error !== exited) throw error; - } - } - assert.equal(exitCode, testCase.found ? 0 : 1); - assert.equal(output, testCase.found ? `${childPid} 3773` : ""); - assert.isTrue(probes.every((pid) => Number.isInteger(pid) && pid > 0)); - } finally { - vi.restoreAllMocks(); - process.exitCode = previousExitCode; - NodeFS.rmSync(dir, { recursive: true, force: true }); - } - }), - ); -}); - -// 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/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 a930517a9c1d..9f9f598b5644 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -1,15 +1,15 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, 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 Layer from "effect/Layer"; +import { Command } from "effect/cli"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, 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 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"; @@ -26,30 +26,6 @@ import { 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[] = []; diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 36aa2a509630..9f910b950bf4 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() @@ -526,6 +520,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 75074afedd37..1fcca4fa3e75 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -22,22 +22,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"], @@ -65,67 +49,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", @@ -453,6 +395,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(); @@ -508,6 +451,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(); @@ -698,27 +642,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(); @@ -733,27 +656,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(); @@ -936,14 +838,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"); @@ -980,25 +874,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", ""); @@ -1086,35 +962,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 3aa156e58dbe..ab73d8f46f49 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -54,21 +54,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 4e45c53eb402..e099f5377071 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -1,3 +1,4 @@ +import * as Stream from "effect/Stream"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -9,8 +10,11 @@ import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import * as PlatformError from "effect/PlatformError"; 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, @@ -19,7 +23,9 @@ import { PinnedRuntimeInstallError, PinnedRuntimePreflightBlockedError, 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`; @@ -927,33 +933,262 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.equal(yield* fs.readFileString(finalPaths.entryPath), "installed\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( + "keeps npm service launchers across successive updates for launcher-owned cleanup", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, versionsDir } = yield* setup(["0.9.0", "1.0.0", "1.1.0", "1.2.0"]); + const launcher = path.join( + versionsDir, + "1.0.0", + "node_modules/t3/dist/service-launcher.mjs", + ); + for (const version of ["1.0.0", "1.1.0", "1.2.0"]) { + const dist = path.join(versionsDir, version, "node_modules/t3/dist"); + yield* fs.makeDirectory(dist, { recursive: true }); + yield* fs.writeFileString(path.join(dist, "bin.mjs"), "// npm entry\n"); + yield* fs.writeFileString(path.join(dist, "service-launcher.mjs"), "// launcher\n"); + } + yield* fs.writeFileString(path.join(baseDir, "runtime", ".boot-version"), "1.0.0\n"); + for (const [fromVersion, targetVersion] of [ + ["1.0.0", "1.1.0"], + ["1.1.0", "1.2.0"], + ] as const) { + yield* fs.writeFileString( + path.join(baseDir, "runtime", "service-state.json"), + JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: targetVersion, + update: { id: targetVersion, fromVersion, targetVersion, status: "committed" }, + }), + ); + yield* prunePinnedRuntimes({ baseDir, keep: [targetVersion] }).pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + lsofRunner("p1\nftxt\nn/usr/bin/node\n"), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: baseDir }), + ); + assert.equal(yield* fs.readFileString(launcher), "// launcher\n"); + } + assert.isFalse(yield* fs.exists(path.join(versionsDir, "0.9.0"))); + for (const version of ["1.0.0", "1.1.0", "1.2.0"]) { + assert.isTrue( + yield* fs.exists(path.join(versionsDir, version, "node_modules/t3/dist/bin.mjs")), + ); + } + }), + ); + + 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", - linuxLibc: "gnu", - 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"); + // Use Node, not a multicall sleep binary whose behavior depends on argv[0]. + yield* fs.copyFile(yield* HostProcess.ExecutablePath, 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; + const child = yield* spawner.spawn( + ChildProcess.make(link, ["-e", 'process.stdout.write("ready"); process.stdin.resume()'], { + stdin: { stream: Stream.never }, + }), + ); + yield* child.stdout.pipe(Stream.take(1), Stream.runDrain); + 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 a15f99fa572d..8051c4a7d16b 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"; @@ -21,7 +22,10 @@ import { import type { HostLinuxLibc } from "@t3tools/shared/HostProcess"; import { T3_NPM_PACKAGE, T3_NPM_REGISTRY } from "@t3tools/shared/releasePackage"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ProcessRunner from "../processRunner.ts"; +import { isExactServiceVersion, parseServiceState, SERVICE_STATE_FILE } from "./serviceProtocol.ts"; /** * Standalone executables pin release archives. Node installations keep the npm @@ -485,3 +489,269 @@ 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); + // Node's process executable does not identify its pinned launcher script. The npm service + // launcher owns cleanup and preserves its boot/restart markers and script across updates. + if (yield* fs.exists(path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"))) continue; + 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 5bcb0be1e0d7..3d2d8d78e714 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -230,29 +230,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[] = []; @@ -286,41 +263,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[] = []; @@ -426,15 +368,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 35a6da478b62..02f48d3c1083 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"; @@ -190,6 +192,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 d7fc2b6cf518..b480b021375f 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/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts deleted file mode 100644 index 1a1943ecf469..000000000000 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, 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 Option from "effect/Option"; -import * as Stream from "effect/Stream"; - -import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts"; -import type { ResourceTelemetryHistoryWithLegacyBuckets } from "../resourceTelemetry/ResourceTelemetryHistory.ts"; -import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; - -describe("ProcessResourceMonitor", () => { - it.effect("projects resource telemetry history into the legacy diagnostics contract", () => - Effect.gen(function* () { - const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const history: ResourceTelemetryHistoryWithLegacyBuckets = { - readAt, - windowMs: 60_000, - bucketMs: 10_000, - sampleIntervalMs: 1_000, - retainedSampleCount: 2, - buckets: [ - { - startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), - endedAt: readAt, - avgCpuPercent: 15, - maxCpuPercent: 25, - maxRssBytes: 4_096, - ioReadBytes: 1_024, - ioWriteBytes: 2_048, - maxProcessCount: 2, - }, - ], - legacyBackendBuckets: [ - { - startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"), - endedAt: readAt, - avgCpuPercent: 5, - maxCpuPercent: 8, - maxRssBytes: 4_096, - ioReadBytes: 1_024, - ioWriteBytes: 2_048, - maxProcessCount: 1, - }, - ], - topProcesses: [ - { - identity: { pid: process.pid, startTimeMs: 100 }, - ppid: 1, - depth: 0, - name: "node", - command: "t3 server", - category: "server", - firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), - lastSeenAt: readAt, - currentCpuPercent: 5, - avgCpuPercent: 4, - maxCpuPercent: 8, - cpuTimeMs: 1_500, - currentRssBytes: 2_048, - peakRssBytes: 4_096, - ioReadBytes: 1_024, - ioWriteBytes: 2_048, - ioSemantics: "storage", - sampleCount: 2, - }, - { - identity: { pid: 5_000, startTimeMs: 200 }, - ppid: 1, - depth: 0, - name: "electron", - command: "electron", - category: "electron-main", - firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), - lastSeenAt: readAt, - currentCpuPercent: 50, - avgCpuPercent: 40, - maxCpuPercent: 80, - cpuTimeMs: 15_000, - currentRssBytes: 20_480, - peakRssBytes: 40_960, - ioReadBytes: 10_240, - ioWriteBytes: 20_480, - ioSemantics: "storage", - sampleCount: 2, - }, - ], - health: { - native: { - status: "degraded", - lastSampleAt: Option.some(readAt), - lastError: Option.some("collector stalled"), - }, - desktop: { - status: "healthy", - lastSampleAt: Option.some(readAt), - lastError: Option.none(), - }, - sidecarVersion: Option.some("0.1.0"), - sidecarPid: Option.some(9_000), - restartCount: 1, - collectionDurationMicros: 250, - scannedProcessCount: 80, - retainedProcessCount: 2, - inaccessibleProcessCount: 0, - }, - }; - const telemetry: ResourceTelemetry.ResourceTelemetry["Service"] = { - latest: Effect.die("unused"), - changes: Stream.empty, - subscribe: Effect.die("unused"), - readHistory: () => Effect.succeed(history), - refresh: Effect.die("unused"), - validateProcessIdentity: () => Effect.die("unused"), - retry: Effect.die("unused"), - }; - const layer = ProcessResourceMonitor.layer.pipe( - Layer.provide( - Layer.succeed( - ResourceTelemetry.ResourceTelemetry, - ResourceTelemetry.ResourceTelemetry.of(telemetry), - ), - ), - ); - - const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe( - Effect.flatMap((monitor) => - monitor.readHistory({ - windowMs: 60_000, - bucketMs: 10_000, - }), - ), - Effect.provide(layer), - ); - - expect(result.totalCpuSecondsApprox).toBe(1.5); - expect(result.topProcesses).toEqual([ - { - processKey: `${process.pid}:100`, - pid: process.pid, - ppid: 1, - command: "t3 server", - depth: 0, - isServerRoot: true, - firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"), - lastSeenAt: readAt, - currentCpuPercent: 5, - avgCpuPercent: 4, - maxCpuPercent: 8, - cpuSecondsApprox: 1.5, - currentRssBytes: 2_048, - maxRssBytes: 4_096, - sampleCount: 2, - }, - ]); - expect(result.buckets[0]).toMatchObject({ - avgCpuPercent: 5, - maxCpuPercent: 8, - maxRssBytes: 4_096, - maxProcessCount: 1, - }); - expect(result.error).toEqual( - Option.some({ - failureTag: "ProcessDiagnosticsQueryFailedError", - message: "collector stalled", - }), - ); - }), - ); -}); 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 a746867e3348..8a6d4f94fb5d 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 23.11 is inside `engines.node` but leaves 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 b17668002e8e..7cebccb2a1f7 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), @@ -272,106 +266,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 96f16f574fcb..d7b39efde3c0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -6,29 +6,22 @@ import * as NodeChildProcess from "node:child_process"; 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"; @@ -63,8 +56,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"; @@ -741,13 +732,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, @@ -908,206 +892,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-"); @@ -1210,33 +994,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-"); @@ -1267,160 +1024,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-"); @@ -1473,159 +1076,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); @@ -2173,231 +1675,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("exhaustive branch PR lookup searches closed history the capped lookup missed", () => - 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/old-merge"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/old-merge"]); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - "[]", - encodeCliJson([ - { - number: 222, - title: "Old merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/222", - baseRefName: "main", - headRefName: "feature/old-merge", - state: "MERGED", - updatedAt: "2026-01-07T15:00:00Z", - }, - ]), - encodeCliJson([ - { - number: 223, - title: "Newer PR closed without merging", - url: "https://github.com/pingdotgg/codething-mvp/pull/223", - baseRefName: "release", - headRefName: "feature/old-merge", - state: "CLOSED", - updatedAt: "2026-02-07T15:00:00Z", - }, - ]), - ], - }, - }); - - const capped = yield* manager.branchPullRequest({ - cwd: repoDir, - branch: "feature/old-merge", - }); - const exhaustive = yield* manager.branchPullRequest( - { cwd: repoDir, branch: "feature/old-merge" }, - { refresh: true, exhaustive: true }, - ); - - expect(capped).toBeNull(); - // Cleanup must not treat the older merge as the branch's answer. - expect(exhaustive).toMatchObject({ number: 223, state: "closed" }); - const listCalls = ghCalls.filter((call) => call.startsWith("pr list ")); - expect(listCalls).toHaveLength(4); - expect(listCalls[2]).toContain("--state merged"); - expect(listCalls[3]).toContain("--state closed"); - }), - ); - - 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-"); @@ -2497,48 +1775,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", @@ -2768,141 +2004,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( @@ -2989,46 +2219,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", () => @@ -3189,107 +2379,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-"); @@ -3338,106 +2427,37 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status is resilient to gh lookup failures and returns pr null", () => + it.effect("status keeps the last known PR when a later lookup fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-sticky", + }; 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 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-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); - - const existingPr = { - number: 214, - title: "Sticky PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/214", - baseRefName: "main", - headRefName: "feature/pr-sticky", - }; - 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(214); - - yield* manager.invalidateStatus(repoDir); - const second = yield* manager.status({ cwd: repoDir }); - expect(second.pr?.number).toBe(214); + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(214); }), ); @@ -3487,116 +2507,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status keeps the last known PR when the branch gains its first upstream", () => - 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]); - - 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-"); @@ -3647,39 +2557,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-"); @@ -3813,51 +2690,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) => @@ -3905,56 +2737,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-"); @@ -4083,125 +2865,16 @@ 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", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); + it.effect( + "pushes and creates PR from a no-upstream branch when local commits are ahead of base", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -4243,55 +2916,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("skips push when branch is already up to date", () => - 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-"); @@ -4331,107 +2955,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-"); @@ -4496,689 +3019,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", () => @@ -5320,83 +3188,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-"); @@ -5569,73 +3360,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-"); @@ -5748,49 +3472,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-"); @@ -6156,195 +3837,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"); }), ); @@ -6762,51 +4319,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-"); @@ -6904,118 +4416,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-"); @@ -7078,59 +4478,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-"); @@ -7148,96 +4495,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"); }), ); @@ -7296,126 +4590,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-"); @@ -7889,126 +5063,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-"); @@ -8164,124 +5218,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 6b631d5c2262..bc0418024b5f 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -198,7 +198,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 89f0e645de8e..776431fee7b3 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"; @@ -16,216 +12,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(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( - 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 c3cbdb51aecb..4804c728825b 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..7338190ca56a 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -14,7 +14,7 @@ import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpRespon * unevenly implemented. */ -export class AcpMcpBridgeError extends Error { +class AcpMcpBridgeError extends Error { readonly _tag = "AcpMcpBridgeError"; } @@ -66,9 +66,7 @@ export interface AcpMcpToolCallOptions { * `session/new` but fail to expose those tools to their model. Compliant ACP * agents continue to use the stdio bridge above. */ -export function callAcpMcpTool( - options: AcpMcpToolCallOptions, -): Effect.Effect { +function callAcpMcpTool(options: AcpMcpToolCallOptions): Effect.Effect { const fetchImplementation = options.fetchImplementation ?? fetch; return Effect.gen(function* () { let sessionId: string | null = null; 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 90c4af4d6e0d..6e54be84ef86 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -12,19 +12,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"; @@ -41,7 +33,6 @@ import { ProjectToolkit } from "./toolkits/project/tools.ts"; const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); -const tabId = PreviewTabId.make("tab-mcp-test"); const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const invocation = { @@ -177,18 +168,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) => @@ -241,56 +220,6 @@ it.effect.each([{}, { includeImage: false }])( ).pipe(Effect.provide(layerTest)), ); -it.effect.each([ - { args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." }, - { - args: { tabId: alternateTabId }, - advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`, - }, -])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const connected = yield* Deferred.make(); - const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId }); - yield* Stream.runForEach(events, (event) => - event.type === "connected" - ? Deferred.succeed(connected, undefined) - : broker.respond({ - clientId: "mcp-no-tab-client", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: false, - error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" }, - }), - ).pipe(Effect.forkScoped); - yield* Deferred.await(connected); - - const snapshot = yield* callSnapshot(args); - - expect(snapshot.isError).toBe(true); - expect(snapshot.content).toEqual([ - { type: "text", text: `Preview snapshot failed: ${advice}` }, - ]); - }), - ).pipe(Effect.provide(layerTest)), -); - -it.effect("tells the agent how to recover when no preview host can run the snapshot", () => - Effect.gen(function* () { - const snapshot = yield* callSnapshot({}); - - expect(snapshot.isError).toBe(true); - const [text] = snapshot.content; - expect(text?.type === "text" ? text.text : "").toContain( - "Retry preview_status, then call preview_open", - ); - expect(snapshot.structuredContent).toMatchObject({ - error: { _tag: "PreviewAutomationNoAvailableHostError" }, - }); - }).pipe(Effect.provide(layerTest)), -); - it.effect.each([ { mode: "default", input: {}, images: false }, { mode: "explicit image", input: { includeImage: true }, images: true }, @@ -428,80 +357,6 @@ it.effect("refuses preview tools to a client outside a thread before they run", }).pipe(Effect.provide(layerTest)), ); -it.effect("rejects non-boolean snapshot image options before selecting a browser host", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - for (const includeImage of ["false", 0]) { - const result = yield* server - .callTool({ - name: "preview_snapshot", - arguments: { includeImage }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(result.isError).toBe(true); - expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed: AiError." }]); - expect(result.structuredContent).toEqual({ - error: { _tag: "AiError", operation: "snapshot", failureCount: 1 }, - }); - } - }).pipe(Effect.provide(layerTest)), -); - -it.effect("saves the snapshot PNG on request and reports its path", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const inputs = yield* serveSnapshots("mcp-save-client", snapshotResult); - - const snapshot = yield* callSnapshot({ save: true, includeImage: true }); - - expect(snapshot.isError).toBe(false); - expect(inputs).toEqual([{}]); - expect(snapshot.content.map((content) => content.type)).toContain("image"); - const structured = snapshot.structuredContent as { readonly screenshotPath?: string }; - const screenshotPath = structured.screenshotPath; - expect(typeof screenshotPath).toBe("string"); - expect(path.dirname(screenshotPath!)).toBe(config.browserArtifactsDir); - expect(path.basename(screenshotPath!)).toMatch( - /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, - ); - expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png"); - const [, text] = snapshot.content; - expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); - - const unsaved = yield* callSnapshot({}); - expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath"); - - const pathOnly = yield* callSnapshot({ save: true }); - const saved = pathOnly.structuredContent as { readonly screenshotPath: string }; - expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) }); - expect(Buffer.from(yield* fileSystem.readFile(saved.screenshotPath)).toString()).toBe("png"); - const [only, ...others] = pathOnly.content; - expect(others).toEqual([]); - expect(only?.type === "text" ? decodeJsonText(only.text) : null).toEqual(saved); - }), - ).pipe(Effect.provide(layerTest)), -); - -it.effect("treats null snapshot options like omitted ones", () => - Effect.scoped( - Effect.gen(function* () { - const inputs = yield* serveSnapshots("mcp-null-options-client", snapshotResult); - - const omitted = yield* callSnapshot({}); - const nulls = yield* callSnapshot({ tabId: null, includeImage: null, save: null }); - - expect(nulls).toEqual(omitted); - expect(inputs).toEqual([{}, {}]); - }), - ).pipe(Effect.provide(layerTest)), -); - it.effect("decodes null optional arguments the way it decodes omitted ones", () => { const calls: Array = []; return Effect.gen(function* () { @@ -520,47 +375,6 @@ it.effect("decodes null optional arguments the way it decodes omitted ones", () }).pipe(Effect.provide(layerRecordingTools(calls))); }); -it.effect("accepts finite wait budgets and refuses non-finite ones", () => { - const calls: Array = []; - return Effect.gen(function* () { - yield* callTool("delegate_task", { task: "Inspect", timeoutMs: 1500.5 }); - yield* callTool("t3_thread_wait", { threadId: "thread-wait", timeoutMs: 5000 }); - expect(calls).toMatchObject([{ timeoutMs: 1500.5 }, { timeoutMs: 5000 }]); - - for (const [name, args] of [ - ["delegate_task", { task: "Inspect", timeoutMs: "Infinity" }], - ["t3_thread_wait", { threadId: "thread-wait", timeoutMs: "NaN" }], - ["t3_thread_wait", { threadId: "thread-wait", timeoutMs: "-Infinity" }], - ["t3_thread_read", { threadId: "thread-wait", afterPosition: "2" }], - ] as const) { - const refused = yield* callTool(name, args).pipe(Effect.flip); - expect(refused._tag, `${name} ${JSON.stringify(args)}`).toBe("InvalidParams"); - } - expect(calls).toHaveLength(2); - }).pipe(Effect.provide(layerRecordingTools(calls))); -}); - -it.effect("reports a tagged error when the screenshot cannot be saved", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.writeFileString(config.browserArtifactsDir, ""); - yield* serveSnapshots("mcp-save-failure-client", snapshotResult); - - const snapshot = yield* callSnapshot({ save: true }); - - expect(snapshot.isError).toBe(true); - expect(snapshot.content).toEqual([ - { type: "text", text: "Preview snapshot failed: PreviewScreenshotSaveError." }, - ]); - expect(snapshot.structuredContent).toEqual({ - error: { _tag: "PreviewScreenshotSaveError", operation: "snapshot", failureCount: 1 }, - }); - }), - ).pipe(Effect.provide(layerTest)), -); - it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => @@ -592,24 +406,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* () { @@ -699,6 +495,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* () { @@ -731,6 +528,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* () { @@ -769,71 +567,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* () { @@ -909,155 +642,6 @@ it.effect("terminates HTTP MCP sessions with DELETE", () => ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); -it.effect("registers annotated tools and preserves authenticated request context", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const toolIcon = { - _tag: "website" as const, - pageUrl: "http://example.test/", - }; - const routedRequests: Array<{ - readonly operation: string; - readonly tabId?: string | undefined; - }> = []; - const events = yield* broker.connect({ - clientId: "mcp-test-client", - environmentId, - }); - yield* Stream.runForEach(events, (event) => { - if (event.type === "connected") return Effect.void; - routedRequests.push(event.request); - return broker.respond({ - clientId: "mcp-test-client", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: true, - result: - event.request.operation === "snapshot" - ? snapshotResult - : event.request.operation === "evaluate" - ? ["Connect", "Continue"] - : event.request.operation === "press" - ? undefined - : { - available: true, - visible: true, - tabId, - url: "http://example.test/", - title: "Example", - loading: false, - }, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); - expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); - expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); - - const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); - expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(false); - expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(false); - expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); - - const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); - expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); - expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); - expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); - expect(clickTool?.tool.outputSchema).toMatchObject({ - type: "object", - additionalProperties: true, - description: "The preview action completed successfully.", - }); - - const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); - expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); - expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); - - const status = yield* server - .callTool({ name: "preview_status", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(status.isError).toBe(false); - expect(status.structuredContent).toMatchObject({ - available: true, - tabId, - }); - - const malformed = yield* server - .callTool({ name: "preview_click", arguments: { selector: "" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - Effect.flip, - ); - expect(malformed._tag).toBe("InvalidParams"); - - const snapshot = yield* server - .callTool({ - name: "preview_snapshot", - arguments: { tabId: alternateTabId, includeImage: true }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(snapshot.isError).toBe(false); - expect(snapshot.content.some((content) => content.type === "image")).toBe(true); - expect(snapshot.structuredContent).toMatchObject({ - screenshot: { mimeType: "image/png", width: 10, height: 5 }, - }); - expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe( - alternateTabId, - ); - - // Arrays and primitives are wrapped so structuredContent stays a JSON object. - // Claude Code rejects the whole result otherwise. - const evaluateTool = server.tools.find(({ tool }) => tool.name === "preview_evaluate"); - expect(evaluateTool?.tool.outputSchema).toMatchObject({ type: "object" }); - const evaluated = yield* server - .callTool({ name: "preview_evaluate", arguments: { expression: "buttons()" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(evaluated.isError).toBe(false); - expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon }); - const evaluatedText = evaluated.content[0]; - expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({ - toolIcon, - value: ["Connect", "Continue"], - }); - - const actionRequests = [ - { name: "preview_click", arguments: { x: 10, y: 10 } }, - { name: "preview_type", arguments: { text: "Hello" } }, - { name: "preview_press", arguments: { key: "Enter" } }, - { name: "preview_scroll", arguments: { deltaY: 100 } }, - { name: "preview_wait_for", arguments: { text: "Example" } }, - ]; - for (const request of actionRequests) { - const result = yield* server - .callTool(request) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(result.isError).toBe(false); - expect(result.structuredContent).toEqual({ toolIcon }); - expect(routedRequests.at(-1)?.operation).toBe("status"); - const text = result.content[0]; - expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon }); - } - }), - ).pipe(Effect.provide(layerTest)), -); - it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 2f19cc8cecf4..5c6c4fece44b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -138,7 +138,7 @@ type McpAuthMiddleware = ( HttpServerRequest.HttpServerRequest >; -export const normalizeMcpHttpResponse = ( +const normalizeMcpHttpResponse = ( response: HttpServerResponse.HttpServerResponse, ): HttpServerResponse.HttpServerResponse => { const bodyIsEmpty = @@ -1025,7 +1025,7 @@ const layerPreviewControlsRegistration = toolkitRegistration( PreviewControlsHandlers.layer, ); -export const layerEnvironmentToolkit = toolkitRegistration( +const layerEnvironmentToolkit = toolkitRegistration( EnvironmentToolkit, EnvironmentHandlers.layer, ).pipe(Layer.provide(ThreadCommandExecutor.layer)); 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 ae05529cd250..04c33a1509e9 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -212,15 +212,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 82c49485270f..9d2f10bc6c02 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -24,11 +24,10 @@ import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.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)({})), @@ -182,71 +181,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(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({ @@ -604,143 +538,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 b132db8fc7ae..015cf1f57696 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -14,6 +14,7 @@ import { ProviderInstanceId, RunId, ThreadId, + type OrchestrationV2ServerCommand, type OrchestrationV2ThreadProjection, type ServerProvider, } from "@t3tools/contracts"; @@ -366,6 +367,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"); @@ -1045,325 +1156,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(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)({ - 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(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)({ - 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(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"); @@ -1423,50 +1215,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)({ - 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(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"); @@ -1851,110 +1599,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)({ - 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(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - ); - - 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 259f0e7cf10d..29237721d842 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -60,6 +60,7 @@ import { type ScheduledTaskUpsertInput, type ServerProvider, ThreadId, + isOrchestrationV2WorkActive, } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { @@ -371,7 +372,7 @@ function taskStatusForRun( } } -export function delegatedTaskRun( +function delegatedTaskRun( childProjection: Pick, task: OrchestrationV2Subagent, ): OrchestrationV2Run | undefined { @@ -391,7 +392,7 @@ export function delegatedTaskRun( : childProjection.runs.find((run) => run.id === spawnTransfer.targetRunId); } -export function hasPendingChildRuns( +function hasPendingChildRuns( childProjection: Pick, delegatedRun: OrchestrationV2Run | undefined, ): boolean { @@ -2394,12 +2395,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); @@ -2412,6 +2438,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 cef285a54c2c..1c43e1ee93a0 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"; @@ -85,8 +46,6 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.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)({})), @@ -95,48 +54,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, @@ -152,17 +92,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; @@ -194,262 +123,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", @@ -463,45 +136,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({ @@ -539,3856 +173,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 defaultBaseRef = yield* Ref.make("develop"); - 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 }; - }), - defaultWorkspaceStrategy: () => - Ref.get(defaultBaseRef).pipe( - Effect.map((baseRef) => ({ - type: "worktree" as const, - baseRef, - startFromOrigin: true, - })), - ), - 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(ThreadSearch.ThreadSearch)({})), - 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(); - - 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}', - bindToCurrentThread: false, - clientRequestId: "schedule-opencode-compat-1", - }); - expect(serializedScheduleCall.isError).toBe(false); - expect(serializedScheduleCall.structuredContent).toMatchObject({ - schedule: { type: "interval", everyMs: 3_600_000 }, - boundThreadId: null, - }); - // A fresh-thread task gets a new worktree from the project's default branch. - expect(yield* Ref.get(scheduledStore)).toMatchObject([ - { - threadId: null, - workspaceStrategy: { type: "worktree", baseRef: "develop", startFromOrigin: true }, - }, - ]); - // A retry keeps the branch the first attempt resolved after the checkout moves. - yield* Ref.set(defaultBaseRef, "feature/after"); - const replayedScheduleCall = yield* invoke("schedule_task", { - prompt: "check for new pull requests", - schedule: '{"type":"interval","everyMs":3600000}', - bindToCurrentThread: false, - clientRequestId: "schedule-opencode-compat-1", - }); - expect(replayedScheduleCall.isError).toBe(false); - expect(yield* Ref.get(scheduledStore)).toMatchObject([ - { workspaceStrategy: { type: "worktree", baseRef: "develop" } }, - ]); - 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 }, @@ -4752,7 +536,6 @@ describe("orchestrator MCP toolkit", () => { const finalStatusCall = yield* invoke("task_status", { taskId: delegated.taskId, }); - expect(finalStatusCall.isError).toBe(false); const finalStatus = yield* decodeDelegateTaskResult( finalStatusCall.structuredContent, ).pipe(Effect.orDie); @@ -4766,40 +549,6 @@ describe("orchestrator MCP toolkit", () => { latestTerminalStatus: "completed", latestTerminalSummary: latestSummary, }); - - // The caller stays live through the status checks. Only this explicit - // interrupt lets the replay close its shared app-server connection. - const parentStopSequence = yield* orchestrator.getThreadEventSequence(parentThreadId); - const parentInterruptCall = yield* invoke("t3_thread_interrupt", { - threadId: parentThreadId, - clientRequestId: "interrupt-replay-parent-after-status", - }); - expect(parentInterruptCall.isError).toBe(false); - const parentInterrupt = yield* decodeThreadInterruptResult( - parentInterruptCall.structuredContent, - ).pipe(Effect.orDie); - expect(parentInterrupt.status).toBe("interrupt_requested"); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: parentThreadId, - afterSequence: parentStopSequence, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "run.updated" && - stored.event.payload.id === parentInterrupt.runId && - stored.event.payload.status === "interrupted", - ), - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.die("Replay parent did not finish its interrupt."), - onSome: () => Effect.void, - }), - ), - ); - expect(finalStatus.latestTerminalResultContextTransferId).not.toBeNull(); const followupTransfer = finishedParent.contextTransfers.find( (transfer) => transfer.type === "subagent_result" && @@ -4816,11 +565,10 @@ describe("orchestrator MCP toolkit", () => { }); expect(read.isError).not.toBe(true); const afterRead = yield* orchestrator.getThreadProjection(parentThreadId); - // Stopping the parent disposes delivery; reading stored results must not reactivate it. expect( afterRead.subagents.find((task) => task.id === queuedFollowup.taskId) ?.completionDelivery?.state, - ).toBe("disposed"); + ).toBe("acknowledged"); }).pipe(Effect.provide(layerTest)); }), ), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index d3ba6911ad9f..7ae8bfbefead 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -3,10 +3,8 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, PreviewAutomationClientDisconnectedError, - PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, PreviewAutomationNoAvailableHostError, - PreviewAutomationTargetNotEditableError, PreviewTabId, ProviderInstanceId, ThreadId, @@ -16,13 +14,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"; @@ -201,40 +196,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* () { @@ -326,192 +287,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 }, @@ -576,24 +351,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; @@ -889,63 +646,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* () { @@ -1142,30 +842,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* () { @@ -1317,190 +993,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("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", - }); - }), - ), -); - it.effect("keeps each thread's current tab when threads share a provider session", () => Effect.scoped( 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 7cc31272c01e..3902068894d6 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* () { @@ -543,15 +456,6 @@ describe("t3_worktree_handoff", () => { ), ); - it.effect("uses the server setting for startFromOrigin when unspecified", () => { - const harness = makeHarness({ newWorktreesStartFromOrigin: true }); - return Effect.gen(function* () { - const result = yield* runHandoff(harness, { branch: "feature/settings-origin" }); - expect(result.startedFromOrigin).toBe(true); - expect(harness.fetchWorktreeBaseCommit).toHaveBeenCalled(); - }); - }); - it.effect("fails when the thread is already attached to a worktree", () => { const harness = makeHarness({ thread: { branch: "feature/existing", worktreePath: "/worktrees/project/existing" }, @@ -566,60 +470,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* () { @@ -639,15 +489,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* () { @@ -657,17 +498,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* () { @@ -680,27 +510,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* () { @@ -777,15 +586,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(); @@ -810,32 +610,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* () { @@ -867,14 +641,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(); @@ -899,37 +665,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* () { @@ -941,20 +676,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: { @@ -972,46 +693,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 b3bd302303f4..806f3360e9fd 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, @@ -19,13 +17,12 @@ 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 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 { @@ -51,18 +48,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)({})), @@ -75,13 +62,6 @@ const declaredFailure = (result: McpSchema.CallToolResult) => { const text = result.content[0]; return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; }; -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); @@ -92,43 +72,6 @@ const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), ); -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, - 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"), @@ -182,57 +125,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( @@ -315,28 +207,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; @@ -366,132 +236,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 deleted file mode 100644 index b8f735069d31..000000000000 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Tool } from "effect/ai"; - -import { - CreateThreadsTool, - DelegateTaskTool, - OrchestratorToolkit, - ScheduleTaskTool, - ThreadUpdateTool, -} from "./tools.ts"; - -describe("orchestrator MCP tool guidance", () => { - it("directs subagent requests to delegation instead of ordinary threads", () => { - assert.include(DelegateTaskTool.description ?? "", "child agent/subagent"); - assert.include(DelegateTaskTool.description ?? "", "cross-provider"); - assert.include(CreateThreadsTool.description ?? "", "not delegation"); - assert.include(CreateThreadsTool.description ?? "", "call delegate_task"); - assert.include(DelegateTaskTool.description ?? "", "waitTimedOut"); - assert.include(DelegateTaskTool.description ?? "", "does not cancel the child"); - assert.include(DelegateTaskTool.description ?? "", "keep that taskId"); - assert.include(DelegateTaskTool.description ?? "", "call delegate_task again"); - assert.include(DelegateTaskTool.description ?? "", "childThreadId is backing storage"); - assert.include( - OrchestratorToolkit.tools.t3_thread_send.description ?? "", - "a requested follow-up creates a fresh task", - ); - assert.include( - OrchestratorToolkit.tools.task_cancel.description ?? "", - "This includes later child-thread runs, even after the task is terminal", - ); - }); - - it("documents wait timeout as a parent budget, not a child failure", () => { - const schema = Tool.getJsonSchema(DelegateTaskTool) as { - readonly properties?: Readonly< - Record< - string, - { - readonly description?: unknown; - readonly anyOf?: ReadonlyArray<{ readonly description?: unknown }>; - } - > - >; - }; - const mode = schema.properties?.mode; - const timeoutMs = schema.properties?.timeoutMs; - const modeText = [mode?.description, ...(mode?.anyOf ?? []).map((entry) => entry.description)] - .filter((value) => typeof value === "string") - .join(" "); - const timeoutText = [ - timeoutMs?.description, - ...(timeoutMs?.anyOf ?? []).map((entry) => entry.description), - ] - .filter((value) => typeof value === "string") - .join(" "); - assert.include(modeText, "Defaults to async"); - assert.include(timeoutText, "does not cancel the child"); - }); - - it("publishes an actionable schedule schema and compatibility string branch", () => { - const schema = Tool.getJsonSchema(ScheduleTaskTool) as { - readonly type?: unknown; - readonly properties?: Readonly< - Record }> - >; - }; - - assert.equal(schema.type, "object"); - assert.isString(schema.properties?.schedule?.description); - assert.isAtLeast(schema.properties?.schedule?.anyOf?.length ?? 0, 2); - assert.include(ScheduleTaskTool.description ?? "", "STRUCTURED OBJECT"); - assert.include(ScheduleTaskTool.description ?? "", "nextRunAt"); - }); - - it("publishes thread metadata actions from an object-root schema", () => { - const schema = Tool.getJsonSchema(ThreadUpdateTool) as { - readonly type?: unknown; - readonly properties?: Readonly>; - }; - - assert.equal(schema.type, "object"); - assert.hasAllKeys(schema.properties ?? {}, [ - "threadId", - "action", - "title", - "pullRequest", - "clientRequestId", - ]); - assert.include(ThreadUpdateTool.description ?? "", "Workspace and branch changes"); - }); -}); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index db291aac7b74..9c24fb62d8e9 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -63,7 +63,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. 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. 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, @@ -114,9 +114,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, @@ -143,7 +143,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, @@ -177,7 +177,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 instead of asking the agent to create a worktree in its prompt.", parameters: OrchestratorMcpCreateThreadsInput, @@ -219,7 +219,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 cf9423ed5a78..37b4e0470783 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 29a830535801..aecc99f7dda2 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -3,8 +3,6 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, - ThreadId, - type OrchestrationV2ThreadShell, type Project as ProjectRecord, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; @@ -29,276 +27,6 @@ import * as McpToolAccess from "../../McpToolAccess.ts"; import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; -it.effect("attributes a launched thread's first message to the calling thread", () => - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("source-thread"); - const projectId = ProjectId.make("project"); - const providerInstanceId = ProviderInstanceId.make("codex"); - const modelSelection = { instanceId: providerInstanceId, model: "gpt-5" }; - const caller = { - id: sourceThreadId, - projectId, - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - activeRunId: "active-run", - archivedAt: null, - deletedAt: null, - } as OrchestrationV2ThreadShell; - let launchedSender: ThreadId | undefined; - const layerDependencies = Layer.mergeAll( - ServerSettings.layerTest(), - NodeCrypto.layer, - Layer.succeed(McpInvocationContext.McpInvocationContext, { - environmentId: EnvironmentId.make("environment"), - requestNamespace: "session", - thread: { - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, - }, - client: undefined, - issuedAt: 0, - capabilities: new Set(["orchestration" as const]), - }), - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(caller), - }), - Layer.mock(ThreadLaunch.ThreadLaunchService)({ - launch: (input) => { - launchedSender = input.initialMessage?.senderThreadId; - return Effect.succeed({ - threadId: input.threadId, - projection: { - thread: { id: input.threadId, projectId, modelSelection }, - runs: [], - }, - resumed: false, - } as unknown as ThreadLaunch.ThreadLaunchResult); - }, - }), - Layer.mock(Project.ProjectService)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - NodeServices.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe( - Layer.provide(NodeServices.layer), - ), - ); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), - ), - ); - const result = yield* toolkit - .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); - expect(launchedSender).toBe(sourceThreadId); - }), -); - -it.effect("launches a scratch thread into the Scratch project", () => - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("source-thread"); - const scratchProjectId = ProjectId.make("project:scratch"); - const providerInstanceId = ProviderInstanceId.make("codex"); - const modelSelection = { instanceId: providerInstanceId, model: "gpt-5" }; - const caller = { - id: sourceThreadId, - projectId: ProjectId.make("project:caller"), - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - activeRunId: "active-run", - archivedAt: null, - deletedAt: null, - } as OrchestrationV2ThreadShell; - const launched: Array = []; - const layerDependencies = Layer.mergeAll( - ServerSettings.layerTest(), - NodeCrypto.layer, - Layer.succeed(McpInvocationContext.McpInvocationContext, { - environmentId: EnvironmentId.make("environment"), - requestNamespace: "session", - thread: { - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, - }, - client: undefined, - issuedAt: 0, - capabilities: new Set(["orchestration" as const]), - }), - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(caller), - }), - Layer.mock(ThreadLaunch.ThreadLaunchService)({ - launch: (input) => { - launched.push(input); - return Effect.succeed({ - threadId: input.threadId, - projection: { - thread: { id: input.threadId, projectId: input.projectId, modelSelection }, - runs: [], - }, - resumed: false, - } as unknown as ThreadLaunch.ThreadLaunchResult); - }, - }), - Layer.mock(Project.ProjectService)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }), - }), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - NodeServices.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe( - Layer.provide(NodeServices.layer), - ), - ); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), - ), - ); - const handle = (params: Parameters>[1]) => - toolkit - .handle("t3_thread_launch", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - - const result = yield* handle({ title: "Notes", scratch: true, message: "Draft a list" }); - expect(result.at(-1)?.result).toMatchObject({ projectId: scratchProjectId }); - expect(launched.map((input) => [input.projectId, input.workspaceStrategy])).toEqual([ - [scratchProjectId, { type: "root" }], - ]); - - const rejected = yield* handle({ - title: "Notes", - scratch: true, - projectId: caller.projectId, - }); - expect(rejected.at(-1)?.result).toMatchObject({ code: "invalid_request" }); - expect(launched).toHaveLength(1); - }), -); - -it.effect("starts a project from just a title when workspaceRoot is omitted", () => - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("source-thread"); - const createdProjectId = ProjectId.make("project:named"); - const providerInstanceId = ProviderInstanceId.make("codex"); - const caller = { - id: sourceThreadId, - projectId: ProjectId.make("project:caller"), - providerInstanceId, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, - runtimeMode: "full-access", - interactionMode: "default", - activeRunId: "active-run", - archivedAt: null, - deletedAt: null, - } as OrchestrationV2ThreadShell; - const named: Array = []; - const registered: Array = []; - const createdProject = { - id: createdProjectId, - title: "Pinball Stats", - workspaceRoot: "/projects/pinball-stats", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-10-01T00:00:00.000Z", - updatedAt: "2026-10-01T00:00:00.000Z", - deletedAt: null, - }; - const layerDependencies = Layer.mergeAll( - ServerSettings.layerTest(), - NodeCrypto.layer, - Layer.succeed(McpInvocationContext.McpInvocationContext, { - environmentId: EnvironmentId.make("environment"), - requestNamespace: "session", - thread: { - threadId: sourceThreadId, - providerSessionId: "session", - providerInstanceId, - }, - client: undefined, - issuedAt: 0, - capabilities: new Set(["orchestration" as const]), - }), - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(caller), - }), - Layer.mock(ThreadLaunch.ThreadLaunchService)({}), - Layer.mock(Project.ProjectService)({ - create: (input) => - Effect.sync(() => { - registered.push(input.workspaceRoot); - return { ...createdProject, id: input.projectId, workspaceRoot: input.workspaceRoot }; - }), - getById: (projectId) => - Effect.succeed( - projectId === createdProjectId ? Option.some(createdProject) : Option.none(), - ), - }), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - createNamedProject: (input) => - Effect.sync(() => { - named.push(input.name); - return { - projectId: createdProjectId, - workspaceRoot: createdProject.workspaceRoot, - commitError: "Git has no name or email on this machine.", - }; - }), - }), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - NodeServices.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe( - Layer.provide(NodeServices.layer), - ), - ); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), - ), - ); - const handle = (params: Parameters>[1]) => - toolkit - .handle("t3_project_create", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - - const result = yield* handle({ title: "Pinball Stats" }); - expect(result.at(-1)?.result).toMatchObject({ - id: createdProjectId, - workspaceRoot: "/projects/pinball-stats", - commitError: "Git has no name or email on this machine.", - }); - expect(named).toEqual(["Pinball Stats"]); - - yield* handle({ title: "Existing", workspaceRoot: "/work/existing" }); - expect(registered).toEqual(["/work/existing"]); - - for (const extra of [ - { scripts: [] }, - { defaultModelSelection: { instanceId: providerInstanceId, model: "gpt-5" } }, - ]) { - const rejected = yield* handle({ title: "Configured", ...extra }); - expect(rejected.at(-1)?.result).toMatchObject({ code: "invalid_request" }); - } - expect(named).toEqual(["Pinball Stats"]); - }), -); - const clientLaunchHarness = (input: { readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; readonly launched: Array; 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 deleted file mode 100644 index 48c48f619966..000000000000 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -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 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 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 ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.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 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(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(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), - Layer.mock(PreviewManager.PreviewManager)({}), - Layer.mock(ServerSecretStore.ServerSecretStore)({}), - Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ThreadSearch.ThreadSearch)({}), - 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, - ), - ), - ), -); 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 2ccf2591fe7e..767e10bce8fb 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -1,13 +1,4 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import * as Context from "effect/Context"; -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"; @@ -31,161 +22,8 @@ const stalled: EventLoopMonitor.EventLoopReadings = { 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("reports a long stall when a client connects, before the next timed sample", () => - Effect.gen(function* () { - const long = { ...stalled, delayMaxNs: ms(13_000), activeMs: 13_000 }; - const context = yield* monitor([quiet, stalled, long]); - const eventLoop = Context.get(context, EventLoopMonitor.EventLoopMonitor); - // The startup sample is skipped, so a connect before it reads nothing. - assert.isUndefined(yield* eventLoop.latestNoticeableStall); - - yield* TestClock.adjust("30 seconds"); - // Under 5 s: logged, but not worth telling a client about. - assert.isUndefined(yield* eventLoop.latestNoticeableStall); - assert.deepStrictEqual(yield* eventLoop.latestNoticeableStall, { - detectedAt: "1970-01-01T00:00:30.000Z", - durationMs: 12_000, - loadAverage: 219.4, - cpuCount: 18, - }); - - // Too old to explain a drop now. - yield* TestClock.adjust("301 seconds"); - assert.isUndefined(yield* eventLoop.latestNoticeableStall); - }).pipe(Effect.scoped), - ); - - 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 3e03190a6edb..5e4d55ff59bd 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -141,7 +141,7 @@ export interface StallLogOptions { readonly maxBytes: number; } -export const layerWith = ( +const layerWith = ( makeSampler: Effect.Effect, never, Scope.Scope>, stallLog: StallLogOptions, ) => 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 82cd3d7a3a12..1a0c6caf1f92 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* () { @@ -2423,144 +2096,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", () => @@ -2743,45 +2278,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; @@ -2857,240 +2353,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"); @@ -3475,61 +2771,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; @@ -3840,170 +3081,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, @@ -4011,74 +3200,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, @@ -4086,69 +3236,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; @@ -4158,151 +3278,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, { @@ -4785,88 +3801,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; @@ -5029,6791 +3963,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; @@ -11825,49 +4505,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, @@ -11875,12 +4551,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); - }), }), }), }, @@ -11892,7 +4562,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", @@ -11902,7 +4572,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, @@ -11924,479 +4594,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", @@ -12405,10 +4817,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)), @@ -12420,532 +4840,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; @@ -12962,15 +5194,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", @@ -12979,7 +5215,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, }); @@ -12989,9 +5227,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) => @@ -13001,226 +5241,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: { @@ -13229,55 +5281,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, @@ -13290,127 +5332,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; @@ -13419,25 +5522,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", @@ -13446,554 +5578,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, }); @@ -14007,71 +6360,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; @@ -14081,37 +6666,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", @@ -14120,9 +6692,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, }); @@ -14137,72 +6707,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(); @@ -14210,261 +6749,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), ); @@ -14529,482 +6907,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, @@ -15032,74 +7026,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 340bdd08898e..58871bfdd3a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -74,24 +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", - }), - ); - 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", [ @@ -114,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( @@ -408,111 +377,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 138528263fba..c7f23fb3c89b 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,9 +60,9 @@ 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 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"; @@ -118,9 +115,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)({ @@ -237,47 +231,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"], @@ -296,26 +249,6 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.isUndefined(options.extraArgs?.["dangerously-skip-permissions"]); }); - 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( @@ -359,52 +292,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({ @@ -428,29 +315,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({ @@ -517,21 +381,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({ @@ -549,60 +398,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", () => { @@ -630,54 +425,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); @@ -697,50 +444,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); @@ -844,191 +547,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", @@ -1310,6 +849,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", @@ -1376,28 +916,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* ( @@ -1470,40 +988,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* () { @@ -2010,95 +1494,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", () => { @@ -2381,18 +1776,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", () => { @@ -2470,15 +1853,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", () => @@ -2675,15 +2049,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?: { @@ -2723,6 +2108,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 = []; @@ -2764,7 +2150,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: @@ -2800,7 +2189,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) => @@ -2821,7 +2210,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 }); @@ -2877,6 +2266,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, @@ -2919,6 +2309,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(); @@ -3245,6 +2639,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", () => @@ -3293,6 +2688,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", () => @@ -3601,6 +2997,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" }, @@ -3926,85 +3323,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) => @@ -4253,57 +3572,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; @@ -4798,93 +4066,35 @@ 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", - limited: true, - compaction: "failed", - compact: true, - status: "failed", - }, - { - name: "completes an unlimited failed /compact", - limited: false, - compaction: "failed", - compact: true, - status: "completed", - }, - { - name: "completes a successful limited /compact", - limited: true, - compaction: "success", - compact: true, - status: "completed", - }, - { - name: "completes a failed automatic compaction", - limited: true, - compaction: "failed", - compact: false, - status: "completed", - }, - ] as const)("$name", ({ name, limited, compaction, compact, status }) => + { + name: "fails a /compact the usage limit stopped", + limited: true, + compaction: "failed", + compact: true, + status: "failed", + }, + { + name: "completes an unlimited failed /compact", + limited: false, + compaction: "failed", + compact: true, + status: "completed", + }, + { + name: "completes a successful limited /compact", + limited: true, + compaction: "success", + compact: true, + status: "completed", + }, + { + name: "completes a failed automatic compaction", + limited: true, + compaction: "failed", + compact: false, + status: "completed", + }, + ] as const)("$name", ({ name, limited, compaction, compact, status }) => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -5131,54 +4341,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; @@ -5344,53 +4506,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({ @@ -5533,70 +4648,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; @@ -6047,122 +5098,19 @@ 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({ + it.effect.each(["claude-haiku-4-5-20251001", "claude-sonnet-4-6"])( + "renegotiates auto only when switching API models after fallback to default: %s", + (nextModel) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); + const modelSelection = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-haiku-4-5-20251001", + }; + const turnInput = { 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.each(["claude-haiku-4-5-20251001", "claude-sonnet-4-6"])( - "renegotiates auto only when switching API models after fallback to default: %s", - (nextModel) => - Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); - const modelSelection = { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - model: "claude-haiku-4-5-20251001", - }; - const turnInput = { - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, + now: yield* DateTime.now, attachments: [], modelSelection, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ @@ -6842,33 +5790,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; @@ -6988,6 +5909,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; @@ -7041,7 +5963,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"); @@ -7058,7 +5980,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); @@ -7072,65 +5994,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Opened GitHub.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - 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"); + yield* harness.awaitTerminal(1); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -7139,67 +6003,56 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - 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 @@ -7305,7 +6158,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, @@ -7334,24 +6187,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( @@ -7483,7 +6332,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; @@ -7522,7 +6371,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); @@ -7534,57 +6383,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); @@ -7599,6 +6401,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({ @@ -7622,7 +6425,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), @@ -7666,7 +6469,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -7689,7 +6492,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); @@ -7728,18 +6531,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( @@ -7754,7 +6555,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({ @@ -7770,69 +6571,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), @@ -7846,6 +6585,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({ @@ -7930,7 +6670,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -7983,15 +6723,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, @@ -8014,15 +6752,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)); @@ -8041,14 +6777,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); @@ -8080,15 +6814,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( @@ -8187,7 +6919,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({ @@ -8202,14 +6934,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(() => @@ -8230,10 +6956,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, @@ -8255,7 +6978,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({ @@ -8271,7 +6994,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) => @@ -8387,7 +7110,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) => @@ -8452,7 +7175,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({ @@ -8466,8 +7189,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { runtimePolicy: approvalPolicy, }), ); - yield* Queue.offer( - harness.sdkMessages, + yield* harness.offerAndWait( claudeSdkFrame({ type: "assistant", message: { @@ -8489,12 +7211,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", @@ -8507,9 +7223,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", @@ -8537,102 +7252,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("drops a zero-turn task-notification result while awaiting a prompt echo", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1"); - const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2"); - 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: "First.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }), - 0, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", 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-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, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("releases output held for a prompt echo when the stream ends", () => + it.effect("releases output held for a prompt echo when the stream ends", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarnessWithOptions({ @@ -8677,7 +7297,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({ @@ -8690,16 +7310,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 2, }), ); - yield* Queue.offer( - harness.sdkMessages, + yield* harness.offerAndWait( 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"); + yield* harness.awaitTerminal(2); assert.deepEqual(assistantTexts(), ["One.", "Two."]); assert.equal(harness.terminalEvents()[1]?.status, "failed"); }).pipe( @@ -8710,33 +7327,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - 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* () { @@ -8764,7 +7354,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; @@ -8778,6 +7368,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* () { @@ -8821,7 +7412,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( @@ -8849,27 +7440,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); @@ -8899,7 +7486,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); @@ -8924,7 +7511,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( @@ -8940,61 +7527,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) => @@ -9125,10 +7657,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({ @@ -9142,7 +7674,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); @@ -9153,7 +7685,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( @@ -9174,7 +7706,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( @@ -9198,114 +7730,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* () { @@ -9329,7 +7753,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 @@ -9357,10 +7781,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)); @@ -9379,7 +7800,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)); @@ -9391,60 +7812,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", () => @@ -9491,12 +7858,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"); @@ -9509,108 +7871,6 @@ 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", () => Effect.scoped( Effect.gen(function* () { @@ -9630,7 +7890,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( @@ -9793,7 +8053,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 @@ -9847,452 +8107,39 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: wakeAttempt, - text: "Background task completed.", - 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), + providerThread: harness.providerThread, + now, + attemptId: wakeAttempt, + text: "Background task completed.", + 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* 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", () => @@ -10338,7 +8185,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({ @@ -10363,18 +8210,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."]); @@ -10403,7 +8248,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({ @@ -10424,7 +8269,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), [ @@ -10456,210 +8301,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* () { @@ -10692,7 +8333,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({ @@ -10746,12 +8387,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 [ @@ -10785,15 +8424,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, @@ -10815,7 +8452,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( @@ -10917,13 +8554,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({ @@ -10945,13 +8582,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( @@ -11039,86 +8674,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { } let ended = yield* Queue.take(harness.subagentReceipts); while (ended.subagent.status === "running") { - ended = yield* Queue.take(harness.subagentReceipts); - } - assert.equal(ended.subagent.nativeTaskRef?.nativeId, "task-idle-exit"); - assert.equal(ended.subagent.status, reason === "Stop" ? "cancelled" : "failed"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.deepEqual(bashStatuses(), [ - "command_execution:running", - reason === "Stop" ? "command_execution:interrupted" : "command_execution:failed", - ]); - }).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(), [ - "command_execution:running", - "command_execution:interrupted", - ]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ended = yield* Queue.take(harness.subagentReceipts); + } + assert.equal(ended.subagent.nativeTaskRef?.nativeId, "task-idle-exit"); + assert.equal(ended.subagent.status, reason === "Stop" ? "cancelled" : "failed"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.deepEqual(bashStatuses(), [ + "command_execution:running", + reason === "Stop" ? "command_execution:interrupted" : "command_execution:failed", + ]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), ), - ), ); it.effect.each(["before", "after"] as const)( @@ -11164,7 +8734,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, @@ -11173,7 +8743,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, @@ -11242,14 +8812,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, @@ -11271,7 +8837,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(), [ @@ -11341,7 +8907,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, @@ -11359,7 +8925,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({ @@ -11393,15 +8959,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, @@ -11423,7 +8985,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}`]); @@ -11439,82 +9001,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"], @@ -11565,7 +9051,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", @@ -11619,10 +9105,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({ @@ -11643,7 +9126,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); @@ -11685,71 +9168,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* () { @@ -11819,7 +9237,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); @@ -12003,98 +9421,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* () { @@ -12131,65 +9457,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each(["completed", "failed", "stopped"] as const)( - "applies idle %s once, preserves a genuine resume, and only confirms native outcomes", - (status) => - Effect.scoped( - Effect.gen(function* () { - let stops = 0; - const fixture = yield* makeStopFixture({ - stopTask: () => Effect.sync(() => void stops++), - }); - yield* fixture.finishParent(); - yield* TestClock.adjust("1 second"); - yield* fixture.notify(status); - const completed = yield* Queue.take(fixture.subagentReceipts); - const expected = status === "stopped" ? "cancelled" : status; - assert.equal(completed.subagent.status, expected); - assert.lengthOf(fixture.offeredMessages, 1); - assert.isFalse(yield* fixture.hasPendingBackgroundWork); - assert.deepEqual(yield* fixture.stop(), { - status: expected, - completedAt: completed.subagent.completedAt!, - result: "Native outcome.", - }); - assert.equal(stops, 0); - yield* TestClock.adjust("1 second"); - yield* fixture.start("toolu-resume"); - assert.isUndefined(yield* fixture.stop(completed.subagent.completedAt!)); - assert.equal(stops, 0); - const stopping = yield* fixture.stop().pipe(Effect.exit, Effect.forkScoped); - yield* Queue.take(fixture.requests); - const beforeReplay = fixture.events.length; - yield* fixture.replay(); - const reopened = yield* Queue.take(fixture.subagentReceipts); - assert.equal(reopened.subagent.status, "running"); - assert.isNull(reopened.subagent.completedAt); - assert.isFalse( - fixture.events - .slice(beforeReplay) - .some( - (event) => event.type === "subagent.updated" && event.subagent.status !== "running", - ), - ); - yield* TestClock.adjust("2 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(stopping))); - assert.equal(stops, 1); - yield* fixture.notify("completed", "Resumed outcome.", "toolu-resume"); - const resumed = yield* Queue.take(fixture.subagentReceipts); - assert.equal(resumed.subagent.result, "Resumed outcome."); - assert.isAbove( - DateTime.toEpochMillis(resumed.subagent.completedAt!), - DateTime.toEpochMillis(completed.subagent.completedAt!), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect.each([ [false, "stopped"], [true, "stopped"], @@ -12386,150 +9653,40 @@ describe("ClaudeAdapterV2 background wake turns", () => { 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", + description: "Haiku puzzle", 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", + 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), @@ -12801,7 +9958,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, @@ -12825,10 +9982,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."); @@ -12839,7 +9993,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), @@ -12899,7 +10053,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({ @@ -12907,11 +10061,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, @@ -12933,7 +10087,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); @@ -13022,7 +10176,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({ @@ -13030,13 +10184,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({ @@ -13057,7 +10208,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); @@ -13140,10 +10291,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); @@ -13167,10 +10315,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"); @@ -13217,6 +10362,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({ @@ -13239,7 +10385,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), @@ -13280,7 +10426,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -13314,10 +10460,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; @@ -13338,297 +10481,61 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); 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), + + 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 = []; @@ -13647,7 +10554,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), @@ -13672,12 +10579,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, }); @@ -13690,7 +10595,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -13700,15 +10605,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( @@ -13716,33 +10612,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( @@ -13750,8 +10634,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, @@ -13762,31 +10646,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, @@ -13795,24 +10673,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( @@ -13825,6 +10708,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; @@ -13885,7 +10769,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -13925,7 +10809,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; @@ -13966,7 +10850,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, @@ -13987,6 +10871,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* () { @@ -14069,200 +10954,68 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* runtime.startTurn( makeClaudeTestTurnInput({ 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; + 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* 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, @@ -14273,10 +11026,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), @@ -14311,12 +11061,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, }); @@ -14329,7 +11077,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -14346,20 +11094,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; @@ -14369,7 +11112,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, @@ -14378,73 +11121,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), @@ -14453,84 +11131,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 = ( @@ -14715,10 +11315,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)( @@ -14848,16 +11445,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" && @@ -14865,105 +11452,42 @@ 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; const turnItem = harness.events.findLast( (event) => event.type === "turn_item.updated" && - 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)), - [], + 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, + }; + }; + // 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), @@ -15208,10 +11732,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); @@ -15237,9 +11758,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, @@ -15379,553 +11903,50 @@ describe("ClaudeAdapterV2 background wake turns", () => { 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), + 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([ @@ -16357,324 +12378,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { pendingStop ? [WORKFLOW_TASK_ID, WORKFLOW_TASK_ID] : [WORKFLOW_TASK_ID], ); if (stopping !== undefined) { - yield* TestClock.adjust("2 seconds"); - 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.", - ); + yield* TestClock.adjust("2 seconds"); + 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]) { - 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( @@ -16684,74 +12403,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* () { @@ -16814,10 +12465,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( @@ -16847,14 +12495,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); @@ -16866,10 +12512,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, @@ -16895,9 +12538,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; @@ -16938,11 +12580,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")); @@ -16954,11 +12595,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( @@ -17287,153 +12927,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* () { @@ -17483,283 +12976,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({ @@ -17845,45 +13061,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 53c71b9eed71..8ea356c31b5b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -97,6 +97,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"; @@ -189,7 +190,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 ( @@ -304,6 +305,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 @@ -770,7 +774,7 @@ export function loggedClaudeQueryOptions( }; } -export function makeClaudeAgentSdkProtocolLogger(input: { +function makeClaudeAgentSdkProtocolLogger(input: { readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; @@ -2215,7 +2219,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 @@ -3611,7 +3615,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") @@ -3779,7 +3783,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") @@ -9684,6 +9688,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; @@ -9739,11 +9744,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({ @@ -9769,7 +9780,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 e63ccacd620e..bc69f75d37e5 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"; @@ -67,100 +60,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( @@ -197,64 +104,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>([]); @@ -282,119 +131,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", () => { @@ -437,31 +173,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; @@ -492,4103 +203,1193 @@ 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", () => + 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], + ) => 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"), + 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 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)), + }, }); - - assert.deepEqual(boundary, { - lastTurnId: "native-turn-first", - rollbackTurnCount: 0, + 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, }); - }), - ); - - 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"), + 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(changes.notify), + 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), + until: changes.until, + awaitTerminal: (count: number) => changes.until(() => terminalEvents().length >= count), + }; + }); - assert.deepEqual(boundary, { - lastTurnId: "native-turn-second", - rollbackTurnCount: 0, - }); - }), + 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("keeps the rollback-count fallback when the boundary turn lacks a native id", () => + it.effect("adds the session MCP server without replacing launch-argument servers", () => 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"), + 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, }); - - assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); - }), + 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("keeps the rollback-count fallback when the boundary turn has no native ref", () => + // 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 now = yield* DateTime.now; - const firstTurn: OrchestrationV2ProviderTurn = { - ...makeProviderTurn("provider-turn-first", 1, "native-turn-first", now), - nativeTurnRef: null, + const recordedServer = { + url: "http://127.0.0.1:41000/mcp", + http_headers: { Authorization: "Bearer recorded-token" }, }; - 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 }); - }), + 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("forks at head without a boundary when no provider turn is selected", () => + it.effect("waits for native start before interrupting an acknowledged queued turn", () => Effect.gen(function* () { - const now = yield* DateTime.now; - - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - targetThreadId: ThreadId.make("thread-codex-fork-target"), - }); - - 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"), + 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, }), ); - - assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); - assert.include(String(error.cause), "provider-turn-missing"); - }), + 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), + ), + ), ); -}); - -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", + const assistantMessages = (events: ReadonlyArray) => + events.filter( + ( + event, + ): event is Extract => + event.type === "message.updated" && event.message.role === "assistant", ); - }); - - 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], - ) => - 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); - }), - }, - }); - 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", () => + it.effect("keeps an asynchronous Codex question actionable after the turn completes", () => 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 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, + scenario: "async-question-and-billed-usage", entries: [ ...codexReplayPreamble({ nativeThreadId, nativeTurnId, - prompt: "Reply with the requested recovery marker.", + prompt: "Continue while I decide.", }), - ...["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, + label: "question", frame: { method: "item/completed", params: { - item: answerItem("answer-overlap-original", "CODEX_RECOVERY_OK"), threadId: nativeThreadId, turnId: nativeTurnId, - completedAtMs: 1782622441000, + item: { + type: "agentMessage", + id: "async-question-item", + text: "Which branch?", + delivery: "async", + questions: [{ title: "Which branch?", options: ["main", "dev"] }], + }, }, }, }, { type: "emit_inbound", - label: "item/completed/answer-overlap-duplicate", + label: "usage", frame: { - method: "item/completed", + method: "thread/tokenUsage/updated", params: { - item: answerItem("answer-overlap-duplicate", "CODEX_RECOVERY_OK"), threadId: nativeThreadId, turnId: nativeTurnId, - completedAtMs: 1782622441001, + tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, }, }, }, { type: "emit_inbound", - label: "turn/completed", + label: "complete", frame: { method: "turn/completed", params: { @@ -4600,1203 +1401,1212 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); 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.", + now: yield* DateTime.now, + attemptId: RunAttemptId.make("async-question-attempt"), + text: "Continue while I decide.", }), ); - 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* harness.firstTerminal; + const requests = harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], + 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", ); - }).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.", - }), + 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, ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], + assert.equal( + questionNode?.type === "node.updated" && questionNode.node.countsForRun, + false, ); - }).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.", - }), + const contextReport = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["", "CODEX_RECOVERY_OK"], + 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), - ), - ), - ), - ); - - 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, - }); + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); - 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, + 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" }, }, - }, - }, - { - 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, + "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", + }, }, - 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: `turn ${index + 1} turn/start`, + frame: { + id: 3 + index, + result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) }, + }, }, - }, - }, - { - 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, + 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}` } : {}), + }, + }, }, - }, - }, - ], - }); - - 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, + { + 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", }), - ); - 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"); + 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 }); + }); - 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, + 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]! }, + }); }); - 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), - ), + 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.each([false, true])( - "keeps commands interrupted after native thread closure (persistent tool: %s)", - (retainContext) => + it.effect("preserves T3 context on the wire and restores it after compaction", () => + Effect.scoped( Effect.gen(function* () { - const notification = (method: string, params: unknown) => ({ - type: "emit_inbound" as const, - label: method, - frame: { method, params }, + 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, }); - 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 }); + assert.include( + params.additionalContext?.t3_code_orchestration_01?.value ?? "", + "delegate_task", + ); + const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); const transcript = makeCodexReplayTranscript({ - scenario: `codex-background-thread-closed-${retainContext}`, + scenario: "restore-context", 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")] : []), - ]), + ...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 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; + 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("attempt-codex-thread-closed"), - text: BG_PROMPT, + attemptId: RunAttemptId.make("context-attempt"), + text: "work", }), ); 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.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", + }), ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.isFalse( - yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), + 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.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 }, - }, - }, - { + 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, - frame: { id: 7, result: { data: [{ processId: "4242" }], nextCursor: null } }, - }, - { - type: "expect_outbound" as const, + label: `${method}/${id}`, frame: { - id: 8, - method: "thread/backgroundTerminals/terminate", - params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, + method, + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { type: "contextCompaction", id }, + ...timestamps, + }, }, - }, - { - 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(); + })), + ...(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.type === "command_execution" && - event.turnItem.status === "interrupted" - ? Deferred.succeed(stopped, undefined) + event.turnItem.nativeItemRef?.nativeId === "unfinished-item" && + event.turnItem.status === "running" && + event.turnItem.providerTurnId !== null + ? Deferred.succeed(compactionStarted, event.turnItem.providerTurnId) : Effect.void, ); - const now = yield* DateTime.now; + yield* TestClock.setTime(1782622445000); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-stop"), - text: BG_PROMPT, + 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 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"), - }), + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], ); - 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); + 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); } - 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( + + it.effect("resumes a provider thread without requesting or decoding its history", () => + 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", + 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), + ), + ), + ), + ); + + // 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 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.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], + } + 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" }], + }, + }, + }, + }, ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ - transcript: localTranscript, - driver: replayDriver, - }), - { runEffectWorker: false }, - ), - ), + } + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: name, entries }), ); - }).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 resume = harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }); - 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, - [], + 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", ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ transcript: localTranscript }), - { runEffectWorker: false }, - ), - ), + 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, ); - }).pipe(Effect.provide(NodeServices.layer)), + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, 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, + 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, + }, + }, + }, }, - 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); + { + 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-bg-pre-settle"), - text: BG_PROMPT, + attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), + text: "Open github.com.", }), ); - 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, - ), - "pre-settle command projection", + yield* harness.awaitTerminal(1); + + 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"); - 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 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.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), + ), + ), + ), ); - 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, + 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("contains commands that start before and after the interrupt response", () => + it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); + 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( @@ -5804,117 +2614,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-mid-command"), - text: INTERRUPT_PROMPT, + attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), + text: "Reply with the requested recovery marker.", }), ); + yield* harness.awaitTerminal(1); - 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", - ); - - 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", + assistantMessages(harness.events).map((event) => event.message.text), + ["CODEX_RECOVERY_OK"], ); - 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), @@ -5923,689 +2632,785 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const interruptSubagentCommandTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-subagent-command", + 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: INTERRUPT_NATIVE_THREAD, - nativeTurnId: INTERRUPT_NATIVE_TURN, - prompt: INTERRUPT_PROMPT, + nativeThreadId: BG_NATIVE_THREAD, + nativeTurnId: BG_NATIVE_TURN, + prompt: BG_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", - }, - 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", + label: "item/started/command", frame: { method: "item/started", params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_CHILD_COMMAND_ITEM, - processId: "57682", - }, - 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" }, + item: backgroundCommandItem("inProgress"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + startedAtMs: 1782622440500, + }, }, }, { type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/child", - frame: { id: 6, result: { terminated: true } }, + 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/root", + label: "turn/completed", frame: { method: "turn/completed", params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), + threadId: BG_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: BG_NATIVE_TURN, status: "completed" }), }, }, }, { type: "emit_inbound", - label: "turn/completed/child-completed-race", + label: "item/completed/command-late", + afterMs: 30_000, frame: { - method: "turn/completed", + method: "item/completed", params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_CHILD_NATIVE_TURN, - status: "completed", - }), + item: backgroundCommandItem("completed"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622465500, }, }, }, - { 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.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("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, - }), - ); - 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({ - 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" } - > => + 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.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), + 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), + ), ), - ), - ), - ); - - 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" }, - ], - }); - - it.effect("terminalizes descendants before the root when an interrupt request fails", () => - Effect.scoped( + 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 harness = yield* makeCodexReplayHarness(interruptSubagentRequestFailureTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-request-failure"), - text: INTERRUPT_PROMPT, - }), + 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* awaitUntil( - () => - harness.events.some( + 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.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", + event.type === "turn-item.updated" && + event.payload.type === "command_execution" && + event.payload.status === "interrupted", ), - "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 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); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - 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, - }, - }, - }, - }, - }, - { - 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: "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", + Stream.runHead, + Effect.forkChild({ startImmediately: true }), ); - - 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* 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 }, + ), + ), ); - 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, - }, - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_CHILD_NATIVE_TURN, - startedAtMs: 1782622441600, - }, - }, - }, - ], + }).pipe(Effect.provide(NodeServices.layer)), + ); }); - it.effect("terminalizes timed-out descendants before the root", () => + it.effect("Stop ends a background command no Codex process tracks any more", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentTimeoutTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-timeout"), - text: INTERRUPT_PROMPT, - }), + 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), + ), ); - yield* awaitUntil( - () => - harness.events.some( + 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 === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", + event.type === "run.updated" && + (event.payload.status === "waiting" || event.payload.status === "completed"), ), - "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", + 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 }, ), - "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); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), + }).pipe(Effect.provide(NodeServices.layer)), ), ); - const rootCompletionIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "turn/completed/root", - ); - const interruptLateSubagentTurnTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-late-subagent-turn", + 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, + 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: [ - ...interruptSubagentCommandTranscript.entries.slice(0, rootCompletionIndex), + ...codexReplayPreamble({ + nativeThreadId: INTERRUPT_NATIVE_THREAD, + nativeTurnId: INTERRUPT_NATIVE_TURN, + prompt: INTERRUPT_PROMPT, + }), { type: "emit_inbound", - label: "turn/started/late-child", + label: "item/started/command", frame: { - method: "turn/started", + method: "item/started", params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_NATIVE_TURN, - status: "inProgress", - }), + item: interruptCommandItem("inProgress"), + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + startedAtMs: 1782622440500, }, }, }, - ...interruptSubagentCommandTranscript.entries.slice(rootCompletionIndex, -1), { type: "expect_outbound", - label: "turn/interrupt/late-child", + label: "turn/interrupt", frame: { - id: 7, + id: 4, method: "turn/interrupt", params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, }, }, }, { type: "emit_inbound", - label: "turn/interrupt/late-child", - frame: { id: 7, result: {} }, + label: "turn/interrupt", + frame: { id: 4, result: {} }, }, - { type: "runtime_exit", status: "success" }, - ], - }); - - it.effect("interrupts descendants that start after the initial Stop snapshot", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptLateSubagentTurnTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - 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, - ); - assert.equal(lateChildUpdates.at(-1)?.providerTurn.status, "interrupted"); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - 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", + label: "item/started/command-two-after-interrupt-response", frame: { - method: "turn/started", + method: "item/started", params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, + 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_LATE_CHILD_NATIVE_TURN, - status: "inProgress", + id: INTERRUPT_NATIVE_TURN, + status: "interrupted", }), }, }, }, { type: "expect_outbound", - label: "turn/interrupt/late-child-1", + label: "thread/backgroundTerminals/terminate/one", frame: { - id: 7, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, - }, + id: 5, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, }, }, { type: "emit_inbound", - label: "turn/started/late-child-2", + label: "thread/backgroundTerminals/terminate/one", + frame: { id: 5, result: { terminated: false } }, + }, + { + type: "expect_outbound", + label: "thread/backgroundTerminals/list/after-false", frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, - status: "inProgress", - }), - }, + id: 6, + method: "thread/backgroundTerminals/list", + params: { threadId: INTERRUPT_NATIVE_THREAD }, }, }, { type: "emit_inbound", - label: "turn/interrupt/late-child-1", - frame: { id: 7, result: {} }, + label: "thread/backgroundTerminals/list/after-false", + frame: { id: 6, result: { data: [], nextCursor: null } }, }, { type: "expect_outbound", - label: "turn/interrupt/late-child-2", + label: "thread/backgroundTerminals/terminate/two", frame: { - id: 8, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, - }, + id: 7, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, }, }, { type: "emit_inbound", - label: "turn/interrupt/late-child-2", - frame: { id: 8, result: {} }, + 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, + }, + }, }, - { type: "runtime_exit", status: "success" }, ], }); - it.effect("interrupts descendants discovered only by the final interrupt rescan", () => + it.effect("contains commands that start before and after the interrupt response", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptRescanLateSubagentTurnTranscript); + const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -6613,86 +3418,115 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-rescan-late-subagent-turn"), + attemptId: RunAttemptId.make("attempt-codex-interrupt-mid-command"), 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", + + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "running", + ), ); + const providerTurnId = harness.events.find( ( event, ): event is Extract< ProviderAdapter.ProviderAdapterV2Event, { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, + > => 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.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* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); + yield* harness.awaitTerminal(1); assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - const rootTerminalIndex = harness.events.findIndex( - (event) => event.type === "turn.terminal", + 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 lateChild1InterruptedIndex = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN && - event.providerTurn.status === "interrupted", + + const interruptedCommandIndex = harness.events.findIndex( + (event, index) => + index < terminalIndex && + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.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.isAbove( + terminalIndex, + interruptedCommandIndex, + "command terminalization must precede turn.terminal", ); - assert.isAtLeast( - lateChild1InterruptedIndex, - 0, - "late child 1 must terminalize interrupted", + + 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.isAtLeast( - lateChild2InterruptedIndex, + assert.lengthOf( + postTerminalCommandUpdates, 0, - "late child 2 must terminalize interrupted", - ); - assert.isAbove( - rootTerminalIndex, - lateChild1InterruptedIndex, - "late child 1 must terminalize before the root run", + "late item/completed after interrupt must not project", ); - assert.isAbove( - rootTerminalIndex, - lateChild2InterruptedIndex, - "late child 2 must terminalize before the root run", + + 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", ); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + 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), @@ -6701,8 +3535,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const interruptTimeoutTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-timeout", + const interruptSubagentCommandTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-subagent-command", entries: [ ...codexReplayPreamble({ nativeThreadId: INTERRUPT_NATIVE_THREAD, @@ -6711,145 +3545,124 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), { type: "emit_inbound", - label: "item/started/command", + label: "item/completed/subAgentActivity-started", frame: { - method: "item/started", + method: "item/completed", params: { - item: interruptCommandItem("inProgress"), + item: { + type: "subAgentActivity", + id: "call-codex-interrupt-subagent", + kind: "started", + agentThreadId: INTERRUPT_CHILD_NATIVE_THREAD, + agentPath: "/root/stop_hold", + }, threadId: INTERRUPT_NATIVE_THREAD, turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622440500, + completedAtMs: 1782622441000, }, }, }, { - type: "expect_outbound", - label: "turn/interrupt", + type: "emit_inbound", + label: "turn/started/child", frame: { - id: 4, - method: "turn/interrupt", + method: "turn/started", params: { - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_CHILD_NATIVE_TURN, + status: "inProgress", + }), }, }, }, { type: "emit_inbound", - label: "turn/interrupt", - frame: { id: 4, result: {} }, - }, - { - type: "emit_inbound", - label: "item/started/command-two-after-interrupt-response", + label: "item/started/child-command", 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 }], + id: INTERRUPT_CHILD_COMMAND_ITEM, + processId: "57682", }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622440600, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_CHILD_NATIVE_TURN, + startedAtMs: 1782622441500, }, }, }, { type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/one", + label: "turn/interrupt/root", frame: { - id: 5, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, + id: 4, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + }, }, }, { type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/one", - frame: { id: 5, result: { terminated: true } }, + label: "turn/interrupt/root", + frame: { id: 4, result: {} }, }, { - type: "emit_inbound", - label: "item/started/command-at-timeout-boundary", - afterMs: 9_999, + type: "expect_outbound", + label: "turn/interrupt/child", frame: { - method: "item/started", + id: 5, + method: "turn/interrupt", 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, + 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/two", + label: "thread/backgroundTerminals/terminate/child", frame: { id: 6, method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, + params: { threadId: INTERRUPT_CHILD_NATIVE_THREAD, processId: "57682" }, }, }, { type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/two", + label: "thread/backgroundTerminals/terminate/child", frame: { id: 6, result: { terminated: true } }, }, { type: "emit_inbound", - label: "turn/completed/late", - afterMs: 20_000, + 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: "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, + turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), }, }, }, { type: "emit_inbound", - label: "item/completed/command-late", + label: "turn/completed/child-completed-race", frame: { - method: "item/completed", + method: "turn/completed", params: { - item: interruptCommandItem("completed"), - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - completedAtMs: 1782622465500, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_CHILD_NATIVE_TURN, + status: "completed", + }), }, }, }, @@ -6857,10 +3670,41 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); - it.effect("bounds interrupt settlement and drops late completion events", () => + 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(interruptTimeoutTranscript); + const harness = yield* makeCodexReplayHarness(interruptSubagentCommandTranscript); const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -6868,19 +3712,18 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-timeout"), + attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-command"), 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", + yield* harness.until(() => + 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", + ), ); const providerTurnId = harness.events.find( ( @@ -6888,80 +3731,32 @@ describe("CodexAdapterV2 post-settle continuation", () => { ): event is Extract< ProviderAdapter.ProviderAdapterV2Event, { type: "provider_turn.updated" } - > => event.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.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", - ).length === 2, - "post-interrupt running command item", - ); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "timeout terminal"); + yield* harness.awaitTerminal(1); 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( + 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", - ); - 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, + > => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM, ); - const lastBoundaryUpdate = boundaryUpdates.at(-1); - assert.isDefined(lastBoundaryUpdate); - assert.equal(lastBoundaryUpdate.turnItem.status, "interrupted"); + 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); - - yield* TestClock.adjust("20 seconds"); - for (let attempt = 0; attempt < 100; attempt++) { - yield* Effect.yieldNow; - } - 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", - ); - 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", - ); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -6970,67 +3765,53 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const interruptTerminationFailureTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-termination-failure", + 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: [ - ...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, - ), + ...interruptSubagentCommandTranscript.entries.slice(0, rootCompletionIndex), { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/one-retry", + type: "emit_inbound", + label: "turn/started/late-child", frame: { - id: 8, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, + method: "turn/started", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_NATIVE_TURN, + status: "inProgress", + }), + }, }, }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/one-retry", - frame: { id: 8, result: { terminated: false } }, - }, + ...interruptSubagentCommandTranscript.entries.slice(rootCompletionIndex, -1), { type: "expect_outbound", - label: "thread/backgroundTerminals/list/after-false-retry", + label: "turn/interrupt/late-child", frame: { - id: 9, - method: "thread/backgroundTerminals/list", - params: { threadId: INTERRUPT_NATIVE_THREAD }, + id: 7, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, + }, }, }, { type: "emit_inbound", - label: "thread/backgroundTerminals/list/after-false-retry", - frame: { - id: 9, - result: { data: [{ processId: "57680" }], nextCursor: null }, - }, + label: "turn/interrupt/late-child", + frame: { id: 7, result: {} }, }, { type: "runtime_exit", status: "success" }, ], }); - it.effect("attempts every terminal and cleans up tracking when termination fails", () => + it.effect("interrupts descendants that start after the initial Stop snapshot", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptTerminationFailureTranscript); + const harness = yield* makeCodexReplayHarness(interruptLateSubagentTurnTranscript); const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -7038,19 +3819,18 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-termination-failure"), + 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.status === "running", - ), - "running command item", + yield* harness.until(() => + 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", + ), ); const providerTurnId = harness.events.find( ( @@ -7058,20 +3838,35 @@ describe("CodexAdapterV2 post-settle continuation", () => { ): event is Extract< ProviderAdapter.ProviderAdapterV2Event, { type: "provider_turn.updated" } - > => event.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); + const interruptFiber = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.forkScoped); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + ), + ); + yield* Fiber.join(interruptFiber); - assert.equal(interruptExit._tag, "Failure"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + yield* harness.awaitTerminal(1); + 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, + ); + assert.equal(lateChildUpdates.at(-1)?.providerTurn.status, "interrupted"); + assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( @@ -7088,31 +3883,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { 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", @@ -7171,9 +3947,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { scenario: `codex-limit-${scenario.name}`, entries: [ ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" - ? [snapshot] - : []), + ...(scenario.name === "deferred-reset" ? [snapshot] : []), ...(scenario.name === "deferred-reset" ? [ { @@ -7313,8 +4087,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { 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 === "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") { @@ -7336,6 +4109,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + // regression: #17501 Codex 429 retry exhaustion classifies as a usage limit it.effect.each([ { notificationStatus: 500, @@ -7521,7 +4295,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { text: FAILED_PROMPT, }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); + yield* harness.awaitTerminal(1); assert.equal(harness.terminalEvents()[0]?.status, "failed"); const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); @@ -7711,92 +4485,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const failedMcpAppTranscript = makeCodexReplayTranscript({ - scenario: "codex-mcp-app-capture-failed-turn", - entries: [ - ...codexReplayPreamble({ - nativeThreadId: MCP_APP_NATIVE_THREAD, - nativeTurnId: MCP_APP_NATIVE_TURN, - prompt: MCP_APP_PROMPT, - }), - { - 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, - }, - }, - }, - { - 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: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: MCP_APP_NATIVE_THREAD, - turn: { - ...makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "failed" }), - error: { message: "provider failed mid-capture" }, - }, - }, - }, - }, - ], - }); - - it.effect("settles a pending MCP app capture before a failed turn's terminal event", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(failedMcpAppTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-mcp-app-failed"), - text: MCP_APP_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 rows = harness.events.flatMap((event, index) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed" - ? [{ index, turnItem: event.turnItem }] - : [], - ); - assert.lengthOf(rows, 1); - assert.isBelow(rows[0]!.index, terminalIndex); - const output = rows[0]!.turnItem.type === "dynamic_tool" ? rows[0]!.turnItem.output : null; - assert.isUndefined((output as Record | null)?.[MCP_APP_OUTPUT_KEY]); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; @@ -8204,145 +4892,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); - it.effect.each([ - { name: "current Codex Sol", model: "gpt-6-sol" }, - { name: "current Codex wrong child", model: null }, - { name: "Sol", model: "gpt-5.6-sol" }, - { name: "Fable", model: "gpt-5.6-fable" }, - { name: "Astra", model: "gpt-6-astra" }, - { name: "missing", model: null }, - { name: "invalid", model: null }, - { name: "wrong child", model: null }, - ])("reads $name child metadata without using the parent model", ({ name, model }) => - Effect.scoped( - Effect.gen(function* () { - const metadataRead = yield* Deferred.make(); - const modelReported = yield* Deferred.make(); - let metadataRequests = 0; - const harness = yield* makeCodexReplayHarness( - resumeSubagentTranscript, - (event) => - event.type === "subagent.updated" && event.subagent.model === model - ? Deferred.succeed(modelReported, undefined) - : Effect.void, - undefined, - (threadId) => { - metadataRequests++; - assert.equal(threadId, RESUME_CHILD_THREAD); - return Deferred.succeed(metadataRead, undefined).pipe( - Effect.as( - name === "invalid" - ? {} - : { - thread: { - id: name.includes("wrong child") ? "other-child" : threadId, - ...(name.startsWith("current Codex") - ? { model: "gpt-6-sol", reasoningEffort: "high" } - : {}), - }, - model: name.startsWith("current Codex") - ? null - : name === "wrong child" - ? "gpt-5.6-sol" - : model, - reasoningEffort: "high", - serviceTier: "priority", - }, - ), - ); - }, - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-child-model"), - text: RESUME_PROMPT, - }), - ); - yield* Deferred.await(metadataRead); - yield* Deferred.await(modelReported); - yield* TestClock.adjust("100 millis"); - yield* harness.firstTerminal; - assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); - if (model) { - assert.deepEqual(harness.subagentUpdates().at(-1)?.subagent.modelSelection?.options, [ - { id: "reasoningEffort", value: "high" }, - ...(name.startsWith("current Codex") ? [] : [{ id: "serviceTier", value: "priority" }]), - ]); - } - assert.equal(metadataRequests, name === "current Codex Sol" ? 1 : 2); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each(["failed", "malformed", "wrong child", "blank model"] as const)( - "resumes child metadata after a %s read", - (readResult) => - Effect.scoped( - Effect.gen(function* () { - const modelReported = yield* Deferred.make(); - const model = "gpt-6-sol"; - const metadataRequests: Array = []; - const harness = yield* makeCodexReplayHarness( - resumeSubagentTranscript, - (event) => - event.type === "subagent.updated" && event.subagent.model === model - ? Deferred.succeed(modelReported, undefined) - : Effect.void, - undefined, - (threadId, method) => { - assert.equal(threadId, RESUME_CHILD_THREAD); - metadataRequests.push(method); - if (method === "thread/resume") { - return Effect.succeed({ thread: { id: threadId }, model }); - } - switch (readResult) { - case "failed": - return Effect.fail( - new CodexError.CodexAppServerRequestError({ - code: -32000, - errorMessage: "Child metadata unavailable", - method, - }), - ); - case "malformed": - return Effect.succeed({}); - case "wrong child": - return Effect.succeed({ thread: { id: "other-child", model: "gpt-6-astra" } }); - case "blank model": - return Effect.succeed({ thread: { id: threadId, model: " \t " } }); - } - }, - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-child-model-fallback"), - text: RESUME_PROMPT, - }), - ); - yield* Deferred.await(modelReported); - yield* TestClock.adjust("100 millis"); - yield* harness.firstTerminal; - assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); - assert.deepEqual(metadataRequests, ["thread/read", "thread/resume"]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each(["thread/settings/updated", "model/rerouted"] as const)( + it.effect.each(["model/rerouted"] as const)( "keeps %s child metadata when an older lookup finishes later", (method) => Effect.scoped( @@ -8354,29 +4904,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { type: "emit_inbound", frame: { method, - params: - method === "model/rerouted" - ? { - threadId: RESUME_CHILD_THREAD, - turnId: RESUME_CHILD_TURN_1, - fromModel: "gpt-6-astra", - toModel: model, - reason: "highRiskCyberActivity", - } - : { - threadId: RESUME_CHILD_THREAD, - threadSettings: { - model, - effort: "low", - serviceTier: "ultrafast", - modelProvider: "openai", - cwd: "/workspace", - approvalPolicy: "never", - approvalsReviewer: "auto_review", - collaborationMode: { mode: "default", settings: { model } }, - sandboxPolicy: { type: "dangerFullAccess" }, - }, - }, + params: { + threadId: RESUME_CHILD_THREAD, + turnId: RESUME_CHILD_TURN_1, + fromModel: "gpt-6-astra", + toModel: model, + reason: "highRiskCyberActivity", + }, }, }; const initialSettings: CodexReplay.CodexAppServerReplayEntry = { @@ -8402,356 +4936,41 @@ describe("CodexAdapterV2 post-settle continuation", () => { const harness = yield* makeCodexReplayHarness( { ...resumeSubagentTranscript, - entries: resumeSubagentTranscript.entries.flatMap((entry) => - entry.type === "emit_inbound" && entry.label === "turn/completed/root" - ? [entry, initialSettings, notification] - : [entry], - ), - }, - (event) => - event.type === "subagent.updated" && event.subagent.model === model - ? Deferred.succeed(observed, undefined) - : Effect.void, - undefined, - (threadId) => - Deferred.await(releaseMetadata).pipe( - Effect.as({ thread: { id: threadId }, model: "gpt-6-astra" }), - ), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-child-model-update"), - text: RESUME_PROMPT, - }), - ); - yield* TestClock.adjust("100 millis"); - yield* Deferred.await(observed); - assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "completed"); - yield* Deferred.succeed(releaseMetadata, undefined); - yield* TestClock.adjust("30 seconds"); - assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); - assert.deepEqual(harness.subagentUpdates().at(-1)?.subagent.modelSelection?.options, [ - { id: "reasoningEffort", value: "low" }, - { id: "serviceTier", value: "ultrafast" }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("preserves a subagent result across a trailing empty final and resume", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(resumeSubagentTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-resume"), - text: RESUME_PROMPT, - }), - ); - yield* awaitUntil( - () => - harness.subagentUpdates().some((event) => event.subagent.result === "CODEX_FIRST_DONE"), - "first subagent result", - ); - assert.lengthOf( - harness.subagentUpdates().filter((event) => event.subagent.result === "CODEX_FIRST_DONE"), - 1, - ); - yield* TestClock.adjust("100 millis"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - const settledUpdates = harness.subagentUpdates(); - const firstCompletion = settledUpdates[settledUpdates.length - 1]; - assert.equal(firstCompletion?.subagent.status, "completed"); - assert.equal(firstCompletion?.subagent.result, "CODEX_FIRST_DONE"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - const settledUpdateCount = settledUpdates.length; - - yield* TestClock.adjust("30 seconds"); - yield* awaitUntil( - () => harness.subagentUpdates().length > settledUpdateCount, - "subagent re-open", - ); - const reopened = harness.subagentUpdates()[settledUpdateCount]; - assert.equal(reopened?.subagent.status, "running"); - assert.equal(DateTime.toEpochMillis(reopened!.subagent.startedAt!), 1782622470000); - assert.isNull(reopened!.subagent.completedAt); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - yield* TestClock.adjust("30 seconds"); - yield* awaitUntil(() => { - const updates = harness.subagentUpdates(); - const latest = updates[updates.length - 1]; - return ( - latest !== undefined && - latest.subagent.status === "completed" && - latest.subagent.result === "CODEX_RESUME_DONE" - ); - }, "resumed subagent completion"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.terminalEvents(), 1); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("rejects duplicate child starts across parent runs", () => - Effect.scoped( - Effect.gen(function* () { - const firstDone = yield* Deferred.make(); - const resumed = yield* Deferred.make(); - const secondTurn = "native-parent-resume-turn"; - const secondPrompt = "Resume the child."; - const entries = [...resumeSubagentTranscript.entries]; - const resumeIndex = entries.findIndex( - (e) => e.type === "emit_inbound" && e.label === `turn/started/${RESUME_CHILD_TURN_2}`, - ); - const suffix = entries.splice(resumeIndex); - for (const entry of codexReplayPreamble({ - nativeThreadId: RESUME_NATIVE_THREAD, - nativeTurnId: secondTurn, - prompt: secondPrompt, - }).slice(-3)) { - entries.push( - entry.type === "expect_outbound" || entry.type === "emit_inbound" - ? { - ...entry, - frame: - Predicate.isObject(entry.frame) && "id" in entry.frame - ? { ...entry.frame, id: 4 } - : entry.frame, - } - : entry, - ); - } - entries.push(childTurnStarted(RESUME_CHILD_TURN_1)); - entries.push(...suffix); - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "codex-cross-run-resume", - entries, - }), - (event) => - event.type === "turn.terminal" - ? Deferred.succeed(firstDone, undefined) - : event.type === "subagent.updated" && event.subagent.runId === "run-cross-run-second" - ? Deferred.succeed(resumed, undefined) - : Effect.void, - ); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("cross-run-first"), - text: RESUME_PROMPT, - }), - ); - yield* TestClock.adjust("100 millis"); - yield* Deferred.await(firstDone); - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("cross-run-second"), - text: secondPrompt, - }), - runOrdinal: 2, - providerTurnOrdinal: 2, - }); - yield* TestClock.adjust("30 seconds"); - yield* Deferred.await(resumed); - const row = harness - .subagentUpdates() - .find((e) => e.subagent.runId === "run-cross-run-second")?.subagent; - assert.equal(row?.status, "running"); - assert.equal(row?.parentNodeId, "node-cross-run-second"); - assert.isNull(row?.completedAt); - assert.isNotNull(row?.startedAt); - assert.equal(DateTime.toEpochMillis(row!.startedAt!), 1782622470000); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([ - ["pendingInit", "pending"], - ["running", "running"], - ["interrupted", "interrupted"], - ["shutdown", "cancelled"], - ["notFound", "failed"], - ["errored", "failed"], - ["completed", "completed"], - ["activity-completed", "completed"], - ["late-activity-completed", "completed"], - ["stale-running", "completed"], - ["duplicate-completed", "completed"], - ] as const)( - "normalizes subagent %s without losing its lifecycle", - ([nativeStatus, expectedStatus]) => - Effect.scoped( - Effect.gen(function* () { - const marker = yield* Deferred.make(); - const firstCompletion = yield* Deferred.make(); - const stateEntry = ( - status: string, - id: string, - ): Extract => ({ - type: "emit_inbound", - label: id, - frame: { - method: "item/completed", - params: { - threadId: RESUME_NATIVE_THREAD, - turnId: RESUME_NATIVE_TURN, - item: { - type: "collabAgentToolCall", - id, - tool: "listAgents", - status: "completed", - senderThreadId: RESUME_NATIVE_THREAD, - receiverThreadIds: [RESUME_CHILD_THREAD], - agentsStates: { [RESUME_CHILD_THREAD]: { status, message: null } }, - }, - }, - }, - }); - const entries: Array = [ - ...codexReplayPreamble({ - nativeThreadId: RESUME_NATIVE_THREAD, - nativeTurnId: RESUME_NATIVE_TURN, - prompt: RESUME_PROMPT, - }), - resumeSubagentTranscript.entries.find( - (e) => - e.type === "emit_inbound" && e.label === "item/completed/subAgentActivity-started", - )!, - ]; - if (nativeStatus === "late-activity-completed") { - entries.push( - resumeSubagentTranscript.entries.find( - (e) => e.type === "emit_inbound" && e.label === "turn/completed/root", - )!, - ); - } - if (nativeStatus === "activity-completed" || nativeStatus === "late-activity-completed") { - entries.push({ - type: "emit_inbound", - label: "activity-done", - frame: { - method: "item/completed", - params: { - threadId: RESUME_NATIVE_THREAD, - turnId: RESUME_NATIVE_TURN, - item: { - type: "subAgentActivity", - id: "activity-done", - kind: "completed", - agentThreadId: RESUME_CHILD_THREAD, - agentPath: "/root/resume_agent", - }, - }, - }, - }); - } else if (nativeStatus === "stale-running" || nativeStatus === "duplicate-completed") { - entries.push(stateEntry("completed", "child-completed"), { - ...stateEntry( - nativeStatus === "stale-running" ? "running" : "completed", - "trailing-snapshot", - ), - afterMs: 100, - }); - } else { - entries.push(stateEntry(nativeStatus, "status-update")); - } - if (nativeStatus === "late-activity-completed") { - entries.push({ - type: "emit_inbound", - label: "late-marker", - frame: { - method: "turn/started", - params: { - threadId: RESUME_CHILD_THREAD, - turn: makeCodexReplayTurn({ id: RESUME_CHILD_TURN_1, status: "inProgress" }), - }, - }, - }); - } else { - entries.push({ - type: "emit_inbound", - label: "marker", - frame: { - method: "item/completed", - params: { - threadId: RESUME_NATIVE_THREAD, - turnId: RESUME_NATIVE_TURN, - item: { - type: "agentMessage", - id: "marker", - text: "LIFECYCLE_MARKER", - phase: "final_answer", - memoryCitation: null, - }, - }, - }, - }); - } - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ scenario: `subagent-${nativeStatus}`, entries }), + entries: resumeSubagentTranscript.entries.flatMap((entry) => + entry.type === "emit_inbound" && entry.label === "turn/completed/root" + ? [entry, initialSettings, notification] + : [entry], + ), + }, (event) => - (event.type === "message.updated" && event.message.text === "LIFECYCLE_MARKER") || - (nativeStatus === "late-activity-completed" && - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === RESUME_CHILD_TURN_1) - ? Deferred.succeed(marker, undefined) - : event.type === "subagent.updated" && event.subagent.status === "completed" - ? Deferred.succeed(firstCompletion, undefined) - : Effect.void, + event.type === "subagent.updated" && event.subagent.model === model + ? Deferred.succeed(observed, undefined) + : Effect.void, + undefined, + (threadId) => + Deferred.await(releaseMetadata).pipe( + Effect.as({ thread: { id: threadId }, model: "gpt-6-astra" }), + ), ); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make(`subagent-${nativeStatus}`), + attemptId: RunAttemptId.make("attempt-child-model-update"), text: RESUME_PROMPT, }), ); - if (nativeStatus === "stale-running" || nativeStatus === "duplicate-completed") { - yield* Deferred.await(firstCompletion); - yield* TestClock.adjust("100 millis"); - } - yield* Deferred.await(marker); - const latest = harness.subagentUpdates().at(-1)!.subagent; - assert.equal(latest.status, expectedStatus); - if (nativeStatus === "duplicate-completed") { - const first = harness.subagentUpdates().find((e) => e.subagent.status === "completed")!; - assert.equal( - DateTime.toEpochMillis(latest.completedAt!), - DateTime.toEpochMillis(first.subagent.completedAt!), - ); - } + yield* TestClock.adjust("100 millis"); + yield* Deferred.await(observed); + assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "completed"); + yield* Deferred.succeed(releaseMetadata, undefined); + yield* TestClock.adjust("30 seconds"); + assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); + assert.deepEqual(harness.subagentUpdates().at(-1)?.subagent.modelSelection?.options, [ + { id: "reasoningEffort", value: "low" }, + { id: "serviceTier", value: "ultrafast" }, + ]); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -8760,1047 +4979,669 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const codexReplayThreadResult = (input: { - readonly nativeThreadId: string; - readonly forkedFromId: string | null; - }) => ({ - thread: { - id: input.nativeThreadId, - sessionId: input.nativeThreadId, - forkedFromId: input.forkedFromId, - 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", - }); - - const errorCauseChainText = (error: unknown): string => - error instanceof Error ? `${error.message} ${errorCauseChainText(error.cause)}` : String(error); - - const codexReplaySourceTurn = (input: { - readonly id: string; - readonly ordinal: number; - readonly nativeId: string | null; - readonly providerThreadId: ProviderThreadId; - readonly now: DateTime.Utc; - }): OrchestrationV2ProviderTurn => ({ - id: ProviderTurnId.make(input.id), - providerThreadId: input.providerThreadId, - nodeId: NodeId.make(`node-${input.id}`), - runAttemptId: RunAttemptId.make(`run-attempt-${input.id}`), - nativeTurnRef: - input.nativeId === null - ? { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId: null, strength: "none" } - : { - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeId: input.nativeId, - strength: "strong", - }, - ordinal: input.ordinal, - status: "completed", - startedAt: input.now, - completedAt: input.now, - }); + it.effect("preserves a subagent result across a trailing empty final and resume", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness(resumeSubagentTranscript); + const now = yield* DateTime.now; - it.effect("fails honestly when rolling back a legacy Codex thread", () => - Effect.gen(function* () { - const nativeThreadId = "legacy-rollback-thread"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "legacy-rollback-turn", - prompt: "unused", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-legacy-rollback", - entries: [ - ...preamble.slice(0, 5), - { - type: "expect_outbound", - label: "thread/read", - frame: { - id: 3, - method: "thread/read", - params: { threadId: nativeThreadId, includeTurns: false }, - }, - }, - { - type: "emit_inbound", - label: "thread/read", - frame: { - id: 3, - result: { thread: { id: nativeThreadId, historyMode: "legacy" } }, - }, - }, - ], - }); - const outbound: Array = []; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - outbound.push(method); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-resume"), + text: RESUME_PROMPT, }), - ); - const now = yield* DateTime.now; - const firstTurn = codexReplaySourceTurn({ - id: "provider-turn-first", - ordinal: 1, - nativeId: "native-turn-first", - providerThreadId: harness.providerThread.id, - now, - }); - const secondTurn = codexReplaySourceTurn({ - id: "provider-turn-second", - ordinal: 2, - nativeId: "native-turn-second", - providerThreadId: harness.providerThread.id, - now, - }); - - const error = yield* Effect.flip( - harness.runtime.rollbackThread({ - providerThread: harness.providerThread, - target: { - type: "provider_turn", - checkpointId: CheckpointId.make("checkpoint-legacy-rollback"), - appRunOrdinal: 1, - providerTurn: firstTurn, - }, - providerThreadTurns: [firstTurn, secondTurn], - }), - ); - - assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError); - assert.include( - errorCauseChainText(error), - "legacy", - "legacy rollback must surface an honest unsupported-history failure", - ); - assert.notInclude( - outbound, - "thread/rollback", - "thread/rollback must not be sent to a legacy Codex thread", - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect( - "falls back to fork-local thread/revert on paginated history when the source turn lacks a native reference", - () => - Effect.gen(function* () { - const nativeThreadId = "fallback-source-thread"; - const forkThreadId = "fallback-fork-thread"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "fallback-source-turn", - prompt: "unused", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-fork-paginated-fallback", - entries: [ - ...preamble.slice(0, 5), - { - type: "expect_outbound", - label: "thread/fork", - frame: { - id: 3, - method: "thread/fork", - params: { threadId: nativeThreadId, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, - }, - }, - { - type: "emit_inbound", - label: "thread/fork", - frame: { - id: 3, - result: codexReplayThreadResult({ - nativeThreadId: forkThreadId, - forkedFromId: nativeThreadId, - }), - }, - }, - { - type: "expect_outbound", - label: "thread/read", - frame: { - id: 4, - method: "thread/read", - params: { threadId: forkThreadId, includeTurns: false }, + ); + yield* harness.until(() => + harness.subagentUpdates().some((event) => event.subagent.result === "CODEX_FIRST_DONE"), + ); + assert.lengthOf( + harness.subagentUpdates().filter((event) => event.subagent.result === "CODEX_FIRST_DONE"), + 1, + ); + yield* TestClock.adjust("100 millis"); + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + const settledUpdates = harness.subagentUpdates(); + const firstCompletion = settledUpdates[settledUpdates.length - 1]; + assert.equal(firstCompletion?.subagent.status, "completed"); + assert.equal(firstCompletion?.subagent.result, "CODEX_FIRST_DONE"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + const settledUpdateCount = settledUpdates.length; + + yield* TestClock.adjust("30 seconds"); + yield* harness.until(() => harness.subagentUpdates().length > settledUpdateCount); + const reopened = harness.subagentUpdates()[settledUpdateCount]; + assert.equal(reopened?.subagent.status, "running"); + assert.equal(DateTime.toEpochMillis(reopened!.subagent.startedAt!), 1782622470000); + assert.isNull(reopened!.subagent.completedAt); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* TestClock.adjust("30 seconds"); + yield* harness.until(() => { + const updates = harness.subagentUpdates(); + const latest = updates[updates.length - 1]; + return ( + latest !== undefined && + latest.subagent.status === "completed" && + latest.subagent.result === "CODEX_RESUME_DONE" + ); + }); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.terminalEvents(), 1); + assert.lengthOf(harness.continuationRequests, 0); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + ["shutdown", "cancelled"], + ["activity-completed", "completed"], + ["late-activity-completed", "completed"], + ["stale-running", "completed"], + ["duplicate-completed", "completed"], + ] as const)( + "normalizes subagent %s without losing its lifecycle", + ([nativeStatus, expectedStatus]) => + Effect.scoped( + Effect.gen(function* () { + const marker = yield* Deferred.make(); + const firstCompletion = yield* Deferred.make(); + const stateEntry = ( + status: string, + id: string, + ): Extract => ({ + type: "emit_inbound", + label: id, + frame: { + method: "item/completed", + params: { + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + item: { + type: "collabAgentToolCall", + id, + tool: "listAgents", + status: "completed", + senderThreadId: RESUME_NATIVE_THREAD, + receiverThreadIds: [RESUME_CHILD_THREAD], + agentsStates: { [RESUME_CHILD_THREAD]: { status, message: null } }, + }, }, }, - { + }); + const entries: Array = [ + ...codexReplayPreamble({ + nativeThreadId: RESUME_NATIVE_THREAD, + nativeTurnId: RESUME_NATIVE_TURN, + prompt: RESUME_PROMPT, + }), + resumeSubagentTranscript.entries.find( + (e) => + e.type === "emit_inbound" && e.label === "item/completed/subAgentActivity-started", + )!, + ]; + if (nativeStatus === "late-activity-completed") { + entries.push( + resumeSubagentTranscript.entries.find( + (e) => e.type === "emit_inbound" && e.label === "turn/completed/root", + )!, + ); + } + if (nativeStatus === "activity-completed" || nativeStatus === "late-activity-completed") { + entries.push({ type: "emit_inbound", - label: "thread/read", - frame: { - id: 4, - result: { thread: { id: forkThreadId, historyMode: "paginated" } }, - }, - }, - { - type: "expect_outbound", - label: "thread/turns/list", + label: "activity-done", frame: { - id: 5, - method: "thread/turns/list", + method: "item/completed", params: { - threadId: forkThreadId, - cursor: null, - limit: 1, - sortDirection: "desc", - itemsView: "summary", + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + item: { + type: "subAgentActivity", + id: "activity-done", + kind: "completed", + agentThreadId: RESUME_CHILD_THREAD, + agentPath: "/root/resume_agent", + }, }, }, - }, - { + }); + } else if (nativeStatus === "stale-running" || nativeStatus === "duplicate-completed") { + entries.push(stateEntry("completed", "child-completed"), { + ...stateEntry( + nativeStatus === "stale-running" ? "running" : "completed", + "trailing-snapshot", + ), + afterMs: 100, + }); + } else { + entries.push(stateEntry(nativeStatus, "status-update")); + } + if (nativeStatus === "late-activity-completed") { + entries.push({ type: "emit_inbound", - label: "thread/turns/list", + label: "late-marker", frame: { - id: 5, - result: { - data: [{ id: "native-turn-second", items: [], status: "completed", error: null }], - nextCursor: null, + method: "turn/started", + params: { + threadId: RESUME_CHILD_THREAD, + turn: makeCodexReplayTurn({ id: RESUME_CHILD_TURN_1, status: "inProgress" }), }, }, - }, - { - type: "expect_outbound", - label: "thread/revert", - frame: { - id: 6, - method: "thread/revert", - params: { threadId: forkThreadId, beforeTurnId: "native-turn-second" }, - }, - }, - { + }); + } else { + entries.push({ type: "emit_inbound", - label: "thread/revert", + label: "marker", frame: { - id: 6, - result: codexReplayThreadResult({ - nativeThreadId: forkThreadId, - forkedFromId: null, - }), + method: "item/completed", + params: { + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + item: { + type: "agentMessage", + id: "marker", + text: "LIFECYCLE_MARKER", + phase: "final_answer", + memoryCitation: null, + }, + }, }, - }, - ], - }); - const outbound: Array = []; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - outbound.push(method); + }); + } + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: `subagent-${nativeStatus}`, entries }), + (event) => + (event.type === "message.updated" && event.message.text === "LIFECYCLE_MARKER") || + (nativeStatus === "late-activity-completed" && + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId === RESUME_CHILD_TURN_1) + ? Deferred.succeed(marker, undefined) + : event.type === "subagent.updated" && event.subagent.status === "completed" + ? Deferred.succeed(firstCompletion, undefined) + : Effect.void, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`subagent-${nativeStatus}`), + text: RESUME_PROMPT, }), - ); - const now = yield* DateTime.now; - const firstTurn = codexReplaySourceTurn({ - id: "provider-turn-first", - ordinal: 1, - nativeId: null, - providerThreadId: harness.providerThread.id, - now, - }); - const secondTurn = codexReplaySourceTurn({ - id: "provider-turn-second", - ordinal: 2, - nativeId: "native-turn-second", - providerThreadId: harness.providerThread.id, - now, - }); - - const forkedProviderThread = yield* harness.runtime.forkThread({ - sourceProviderThread: harness.providerThread, - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: firstTurn.id, - targetThreadId: ThreadId.make("thread-fork-paginated-fallback-target"), - }); - - assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, forkThreadId); - assert.notEqual(forkedProviderThread.id, harness.providerThread.id); - assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, firstTurn.id); - assert.deepEqual(outbound.slice(-2), ["thread/turns/list", "thread/revert"]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ); + if (nativeStatus === "stale-running" || nativeStatus === "duplicate-completed") { + yield* Deferred.await(firstCompletion); + yield* TestClock.adjust("100 millis"); + } + yield* Deferred.await(marker); + const latest = harness.subagentUpdates().at(-1)!.subagent; + assert.equal(latest.status, expectedStatus); + if (nativeStatus === "duplicate-completed") { + const first = harness.subagentUpdates().find((e) => e.subagent.status === "completed")!; + assert.equal( + DateTime.toEpochMillis(latest.completedAt!), + DateTime.toEpochMillis(first.subagent.completedAt!), + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), ), ); - it.effect( - "fails honestly when a legacy fork cannot honor a source turn without a native reference", - () => - Effect.gen(function* () { - const nativeThreadId = "legacy-fallback-source-thread"; - const forkThreadId = "legacy-fallback-fork-thread"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "legacy-fallback-source-turn", - prompt: "unused", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-fork-legacy-fallback", - entries: [ - ...preamble.slice(0, 5), - { - type: "expect_outbound", - label: "thread/fork", - frame: { - id: 3, - method: "thread/fork", - params: { threadId: nativeThreadId, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, - }, - }, - { - type: "emit_inbound", - label: "thread/fork", - frame: { - id: 3, - result: codexReplayThreadResult({ - nativeThreadId: forkThreadId, - forkedFromId: nativeThreadId, - }), - }, - }, - { - type: "expect_outbound", - label: "thread/read", - frame: { - id: 4, - method: "thread/read", - params: { threadId: forkThreadId, includeTurns: false }, - }, - }, - { - type: "emit_inbound", - label: "thread/read", - frame: { - id: 4, - result: { thread: { id: forkThreadId, historyMode: "legacy" } }, - }, - }, - ], - }); - const outbound: Array = []; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - outbound.push(method); - }), - ); - const now = yield* DateTime.now; - const firstTurn = codexReplaySourceTurn({ - id: "provider-turn-first", - ordinal: 1, - nativeId: null, - providerThreadId: harness.providerThread.id, - now, - }); - const secondTurn = codexReplaySourceTurn({ - id: "provider-turn-second", - ordinal: 2, - nativeId: "native-turn-second", - providerThreadId: harness.providerThread.id, - now, - }); + const codexReplayThreadResult = (input: { + readonly nativeThreadId: string; + readonly forkedFromId: string | null; + }) => ({ + thread: { + id: input.nativeThreadId, + sessionId: input.nativeThreadId, + forkedFromId: input.forkedFromId, + 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", + }); - const error = yield* Effect.flip( - harness.runtime.forkThread({ - sourceProviderThread: harness.providerThread, - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: firstTurn.id, - targetThreadId: ThreadId.make("thread-fork-legacy-fallback-target"), - }), - ); + const errorCauseChainText = (error: unknown): string => + error instanceof Error ? `${error.message} ${errorCauseChainText(error.cause)}` : String(error); - assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); - assert.include( - errorCauseChainText(error), - "legacy", - "the missing-native-reference fallback must name the legacy limitation", - ); - assert.notInclude( - outbound, - "thread/rollback", - "thread/rollback must not be sent to a legacy Codex fork", - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); + const codexReplaySourceTurn = (input: { + readonly id: string; + readonly ordinal: number; + readonly nativeId: string | null; + readonly providerThreadId: ProviderThreadId; + readonly now: DateTime.Utc; + }): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(input.id), + providerThreadId: input.providerThreadId, + nodeId: NodeId.make(`node-${input.id}`), + runAttemptId: RunAttemptId.make(`run-attempt-${input.id}`), + nativeTurnRef: + input.nativeId === null + ? { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId: null, strength: "none" } + : { + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeId: input.nativeId, + strength: "strong", + }, + ordinal: input.ordinal, + status: "completed", + startedAt: input.now, + completedAt: input.now, + }); - it.effect("propagates native thread/fork failures as typed fork errors", () => + it.effect("fails honestly when rolling back a legacy Codex thread", () => Effect.gen(function* () { - const nativeThreadId = "fork-failure-source-thread"; + const nativeThreadId = "legacy-rollback-thread"; const preamble = codexReplayPreamble({ nativeThreadId, - nativeTurnId: "fork-failure-source-turn", + nativeTurnId: "legacy-rollback-turn", prompt: "unused", }); const transcript = makeCodexReplayTranscript({ - scenario: "codex-fork-request-failure", + scenario: "codex-legacy-rollback", entries: [ ...preamble.slice(0, 5), { type: "expect_outbound", - label: "thread/fork", + label: "thread/read", frame: { id: 3, - method: "thread/fork", - params: { - threadId: nativeThreadId, - lastTurnId: "native-turn-first", - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, + method: "thread/read", + params: { threadId: nativeThreadId, includeTurns: false }, }, }, { type: "emit_inbound", - label: "thread/fork", - frame: { id: 3, error: { code: -32000, message: "fork exploded" } }, + label: "thread/read", + frame: { + id: 3, + result: { thread: { id: nativeThreadId, historyMode: "legacy" } }, + }, }, ], }); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - const firstTurn = codexReplaySourceTurn({ - id: "provider-turn-first", - ordinal: 1, - nativeId: "native-turn-first", - providerThreadId: harness.providerThread.id, - now, - }); - - const error = yield* Effect.flip( - harness.runtime.forkThread({ - sourceProviderThread: harness.providerThread, - sourceProviderTurns: [firstTurn], - providerTurnId: firstTurn.id, - targetThreadId: ThreadId.make("thread-fork-failure-target"), - }), - ); - - assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); - assert.include(errorCauseChainText(error), "fork exploded"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - describe("native goals", () => { - const nativeThreadId = "goal-thread"; - const objective = "Ship the feature"; - const codexGoal = (status: string, tokensUsed: number) => ({ - threadId: nativeThreadId, - objective, - status, - tokenBudget: null, - tokensUsed, - timeUsedSeconds: 3, - createdAt: 1782622440, - updatedAt: 1782622450, - }); - const notification = ( - label: string, - method: string, - params: unknown, - ): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label, - frame: { method, params }, - }); - const request = (id: number, method: string, params: unknown, result: unknown) => - [ - { type: "expect_outbound", label: method, frame: { id, method, params } }, - { type: "emit_inbound", label: method, frame: { id, result } }, - ] satisfies Array; - const withReplayRequestId = ( - entries: ReadonlyArray, - id: number, - ) => - entries.map((entry) => - "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame - ? { ...entry, frame: { ...entry.frame, id } } - : entry, - ); - const sessionStart = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused", - prompt: "unused", - }).slice(0, 5); - const goalTurnInput = ( - harness: { - readonly threadId: ThreadId; - readonly providerThread: OrchestrationV2ProviderThread; - }, - text: string, - ) => - DateTime.now.pipe( - Effect.map((now) => - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("goal-attempt"), - text, - }), - ), - ); - const providerGoals = (events: ReadonlyArray) => - events.flatMap((event) => - event.type === "provider_thread.updated" ? [event.providerThread.goal?.status ?? null] : [], + const outbound: Array = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + outbound.push(method); + }), ); - - it("parses /goal like the Codex TUI", () => { - assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal"), { type: "show" }); - assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand(" /goal Pause "), { type: "pause" }); - assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal resume"), { type: "resume" }); - assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal clear"), { type: "clear" }); - assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal fix the\nflaky tests"), { - type: "set", - objective: "fix the\nflaky tests", + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: "native-turn-first", + providerThreadId: harness.providerThread.id, + now, + }); + const secondTurn = codexReplaySourceTurn({ + id: "provider-turn-second", + ordinal: 2, + nativeId: "native-turn-second", + providerThreadId: harness.providerThread.id, + now, }); - assert.isNull(CodexAdapterV2.parseCodexGoalCommand("/goals")); - assert.isNull(CodexAdapterV2.parseCodexGoalCommand("set a /goal")); - }); - it.effect("keeps a /goal run open across the turns Codex continues on its own", () => - Effect.gen(function* () { - const transcript = makeCodexReplayTranscript({ - scenario: "goal-continuation", - entries: [ - ...sessionStart, - ...request(3, "thread/goal/get", { threadId: nativeThreadId }, { goal: null }), - ...request( - 4, - "thread/goal/set", - { threadId: nativeThreadId, objective, status: "paused" }, - { goal: codexGoal("paused", 0) }, - ), - ...withReplayRequestId( - codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "goal-turn-1", - prompt: objective, - }).slice(5), - 5, - ), - ...request( - 6, - "thread/goal/set", - { threadId: nativeThreadId, status: "active" }, - { goal: codexGoal("active", 0) }, - ), - notification("goal active", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: "goal-turn-1", - goal: codexGoal("active", 0), - }), - notification("first turn done", "turn/completed", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-1", status: "completed" }), - }), - notification("continuation", "turn/started", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "inProgress" }), - }), - notification("goal complete", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: "goal-turn-2", - goal: codexGoal("complete", 1200), - }), - notification("continuation done", "turn/completed", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "completed" }), - }), - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn(yield* goalTurnInput(harness, `/goal ${objective}`)); - yield* harness.firstTerminal; + const error = yield* Effect.flip( + harness.runtime.rollbackThread({ + providerThread: harness.providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-legacy-rollback"), + appRunOrdinal: 1, + providerTurn: firstTurn, + }, + providerThreadTurns: [firstTurn, secondTurn], + }), + ); - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const providerTurnId = (nativeTurnId: string) => - idAllocator.derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }); - assert.deepEqual( - harness.terminalEvents().map((event) => [event.providerTurnId, event.status]), - [[providerTurnId("goal-turn-2"), "completed"]], - ); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "running" - ? [ - [ - event.providerTurn.id, - event.providerTurn.runAttemptId, - event.providerTurn.ordinal, - ], - ] - : [], - ), - [ - [providerTurnId("goal-turn-1"), RunAttemptId.make("goal-attempt"), 1], - [providerTurnId("goal-turn-2"), RunAttemptId.make("goal-attempt"), 2], - ], - ); - assert.equal(providerGoals(harness.events).at(-1), "complete"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), + assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError); + assert.include( + errorCauseChainText(error), + "legacy", + "legacy rollback must surface an honest unsupported-history failure", + ); + assert.notInclude( + outbound, + "thread/rollback", + "thread/rollback must not be sent to a legacy Codex thread", + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ); + ), + ); - it.effect("settles /goal pause with a reply instead of a native turn", () => + it.effect( + "falls back to fork-local thread/revert on paginated history when the source turn lacks a native reference", + () => Effect.gen(function* () { - const transcript = makeCodexReplayTranscript({ - scenario: "goal-pause", - entries: [ - ...sessionStart, - ...request( - 3, - "thread/goal/get", - { threadId: nativeThreadId }, - { goal: codexGoal("active", 10) }, - ), - ...request( - 4, - "thread/goal/set", - { threadId: nativeThreadId, status: "paused" }, - { goal: codexGoal("paused", 10) }, - ), - ], + const nativeThreadId = "fallback-source-thread"; + const forkThreadId = "fallback-fork-thread"; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "fallback-source-turn", + prompt: "unused", }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn(yield* goalTurnInput(harness, "/goal pause")); - yield* harness.firstTerminal; - - assert.deepEqual( - harness.terminalEvents().map((event) => event.status), - ["completed"], - ); - const turns = harness.events.flatMap((event) => - event.type === "provider_turn.updated" ? [event.providerTurn] : [], - ); - assert.isTrue(turns.every((turn) => turn.nativeTurnRef === null)); - assert.include( - harness.events.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" - ? [event.message.text] - : [], - ), - "Goal paused. Send /goal resume to continue.", - ); - assert.equal(providerGoals(harness.events).at(-1), "paused"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("settles a run held for the next goal turn when Stop arrives in between", () => - Effect.gen(function* () { - const prompt = "Keep going"; const transcript = makeCodexReplayTranscript({ - scenario: "goal-stop-between-turns", + scenario: "codex-fork-paginated-fallback", entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), - notification("goal active", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: "goal-turn", - goal: codexGoal("active", 10), - }), - notification("turn done", "turn/completed", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), - }), - notification("goal accounted", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: null, - goal: codexGoal("active", 11), - }), - ...request( - 4, - "thread/goal/set", - { threadId: nativeThreadId, status: "paused" }, - { goal: codexGoal("paused", 10) }, - ), - notification("goal paused", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: null, - goal: codexGoal("paused", 10), - }), + ...preamble.slice(0, 5), + { + type: "expect_outbound", + label: "thread/fork", + frame: { + id: 3, + method: "thread/fork", + params: { threadId: nativeThreadId, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, + }, + }, + { + type: "emit_inbound", + label: "thread/fork", + frame: { + id: 3, + result: codexReplayThreadResult({ + nativeThreadId: forkThreadId, + forkedFromId: nativeThreadId, + }), + }, + }, + { + type: "expect_outbound", + label: "thread/read", + frame: { + id: 4, + method: "thread/read", + params: { threadId: forkThreadId, includeTurns: false }, + }, + }, + { + type: "emit_inbound", + label: "thread/read", + frame: { + id: 4, + result: { thread: { id: forkThreadId, historyMode: "paginated" } }, + }, + }, + { + type: "expect_outbound", + label: "thread/turns/list", + frame: { + id: 5, + method: "thread/turns/list", + params: { + threadId: forkThreadId, + cursor: null, + limit: 1, + sortDirection: "desc", + itemsView: "summary", + }, + }, + }, + { + type: "emit_inbound", + label: "thread/turns/list", + frame: { + id: 5, + result: { + data: [{ id: "native-turn-second", items: [], status: "completed", error: null }], + nextCursor: null, + }, + }, + }, + { + type: "expect_outbound", + label: "thread/revert", + frame: { + id: 6, + method: "thread/revert", + params: { threadId: forkThreadId, beforeTurnId: "native-turn-second" }, + }, + }, + { + type: "emit_inbound", + label: "thread/revert", + frame: { + id: 6, + result: codexReplayThreadResult({ + nativeThreadId: forkThreadId, + forkedFromId: null, + }), + }, + }, ], }); - const requests: Array = []; + const outbound: Array = []; const harness = yield* makeCodexReplayHarness( transcript, () => Effect.void, (method) => Effect.sync(() => { - requests.push(method); + outbound.push(method); }), ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("goal-hold-attempt"), - text: prompt, - }), - ); - const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId: "goal-turn", + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: null, + providerThreadId: harness.providerThread.id, + now, }); - const turnStatuses = () => - harness.events.flatMap((event) => - event.type === "provider_turn.updated" && event.providerTurn.id === providerTurnId - ? [event.providerTurn.status] - : [], - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.goal?.tokensUsed === 11, - ), - "the completed turn to be held", - ); - assert.deepEqual(turnStatuses(), ["running"]); - assert.lengthOf(harness.terminalEvents(), 0); - - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, + const secondTurn = codexReplaySourceTurn({ + id: "provider-turn-second", + ordinal: 2, + nativeId: "native-turn-second", + providerThreadId: harness.providerThread.id, + now, }); - yield* harness.firstTerminal; - - assert.notInclude(requests, "turn/interrupt"); - assert.deepEqual(turnStatuses(), ["running", "completed"]); - assert.deepEqual( - harness.terminalEvents().map((event) => event.status), - ["interrupted"], - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect("moves a Stop that names an earlier goal turn to the turn Codex continued with", () => - Effect.gen(function* () { - const prompt = "Keep going"; - const transcript = makeCodexReplayTranscript({ - scenario: "goal-stop-after-continuation", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }), - notification("goal active", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: "goal-turn-a", - goal: codexGoal("active", 10), - }), - notification("first turn done", "turn/completed", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }), - }), - notification("continuation", "turn/started", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }), - }), - notification("goal accounted", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: "goal-turn-b", - goal: codexGoal("active", 11), - }), - ...request( - 4, - "thread/goal/set", - { threadId: nativeThreadId, status: "paused" }, - { goal: codexGoal("paused", 11) }, - ), - ...request( - 5, - "turn/interrupt", - { threadId: nativeThreadId, turnId: "goal-turn-b" }, - {}, - ), - notification("continuation interrupted", "turn/completed", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "interrupted" }), - }), - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("goal-successor-attempt"), - text: prompt, - }), - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.goal?.tokensUsed === 11, - ), - "the continuation to be adopted", - ); - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId: "goal-turn-a", - }), - requestRuntimeRestart: true, + const forkedProviderThread = yield* harness.runtime.forkThread({ + sourceProviderThread: harness.providerThread, + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-fork-paginated-fallback-target"), }); - yield* harness.firstTerminal; - assert.deepEqual( - harness.terminalEvents().map((event) => event.status), - ["interrupted"], - ); + + assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, forkThreadId); + assert.notEqual(forkedProviderThread.id, harness.providerThread.id); + assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, firstTurn.id); + assert.deepEqual(outbound.slice(-2), ["thread/turns/list", "thread/revert"]); }).pipe( Effect.scoped, Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), - ); + ); - it.effect("stops a held goal run's background command when Stop arrives between turns", () => - Effect.gen(function* () { - const prompt = "Start the server"; - const command = "node server.js"; - const commandItem = (status: "inProgress" | "completed") => ({ - type: "commandExecution", - id: "goal-bg-command", - command, - cwd: "/workspace", - processId: "4242", - source: "unifiedExecStartup", - status, - commandActions: [{ type: "unknown", command }], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }); - const transcript = makeCodexReplayTranscript({ - scenario: "goal-stop-held-background", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), - notification("command started", "item/started", { - item: commandItem("inProgress"), - threadId: nativeThreadId, - turnId: "goal-turn", - startedAtMs: 1782622440500, - }), - notification("goal active", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: "goal-turn", - goal: codexGoal("active", 10), - }), - notification("turn done", "turn/completed", { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), - }), - notification("goal accounted", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: null, - goal: codexGoal("active", 11), - }), - ...request( - 4, - "thread/goal/set", - { threadId: nativeThreadId, status: "paused" }, - { goal: codexGoal("paused", 11) }, - ), - ...request( - 5, - "thread/backgroundTerminals/terminate", - { threadId: nativeThreadId, processId: "4242" }, - { terminated: true }, - ), - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( + describe("native goals", () => { + const nativeThreadId = "goal-thread"; + const objective = "Ship the feature"; + const codexGoal = (status: string, tokensUsed: number) => ({ + threadId: nativeThreadId, + objective, + status, + tokenBudget: null, + tokensUsed, + timeUsedSeconds: 3, + createdAt: 1782622440, + updatedAt: 1782622450, + }); + const notification = ( + label: string, + method: string, + params: unknown, + ): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label, + frame: { method, params }, + }); + const request = (id: number, method: string, params: unknown, result: unknown) => + [ + { type: "expect_outbound", label: method, frame: { id, method, params } }, + { type: "emit_inbound", label: method, frame: { id, result } }, + ] satisfies Array; + const withReplayRequestId = ( + entries: ReadonlyArray, + id: number, + ) => + entries.map((entry) => + "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame + ? { ...entry, frame: { ...entry.frame, id } } + : entry, + ); + const sessionStart = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 5); + const goalTurnInput = ( + harness: { + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + }, + text: string, + ) => + DateTime.now.pipe( + Effect.map((now) => makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("goal-held-background-attempt"), - text: prompt, - }), - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.goal?.tokensUsed === 11, - ), - "the completed turn to be held", - ); - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId: "goal-turn", + now, + attemptId: RunAttemptId.make("goal-attempt"), + text, }), - requestRuntimeRestart: true, - }); - yield* harness.firstTerminal; - assert.deepEqual( - harness.terminalEvents().map((event) => event.status), - ["interrupted"], - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted", - ), - "the background command to be interrupted", - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ), - ); + ); + const providerGoals = (events: ReadonlyArray) => + events.flatMap((event) => + event.type === "provider_thread.updated" ? [event.providerThread.goal?.status ?? null] : [], + ); - it.effect("moves Stop to a continuation Codex starts while the goal pause is in flight", () => + it.effect("keeps a /goal run open across the turns Codex continues on its own", () => Effect.gen(function* () { - const prompt = "Keep going"; const transcript = makeCodexReplayTranscript({ - scenario: "goal-stop-races-continuation", + scenario: "goal-continuation", entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }), + ...sessionStart, + ...request(3, "thread/goal/get", { threadId: nativeThreadId }, { goal: null }), + ...request( + 4, + "thread/goal/set", + { threadId: nativeThreadId, objective, status: "paused" }, + { goal: codexGoal("paused", 0) }, + ), + ...withReplayRequestId( + codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "goal-turn-1", + prompt: objective, + }).slice(5), + 5, + ), + ...request( + 6, + "thread/goal/set", + { threadId: nativeThreadId, status: "active" }, + { goal: codexGoal("active", 0) }, + ), notification("goal active", "thread/goal/updated", { threadId: nativeThreadId, - turnId: "goal-turn-a", - goal: codexGoal("active", 10), + turnId: "goal-turn-1", + goal: codexGoal("active", 0), }), - { - type: "expect_outbound", - label: "thread/goal/set", - frame: { - id: 4, - method: "thread/goal/set", - params: { threadId: nativeThreadId, status: "paused" }, - }, - }, notification("first turn done", "turn/completed", { threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }), + turn: makeCodexReplayTurn({ id: "goal-turn-1", status: "completed" }), }), notification("continuation", "turn/started", { threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }), + turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "inProgress" }), }), - { - type: "emit_inbound", - label: "thread/goal/set", - frame: { id: 4, result: { goal: codexGoal("paused", 11) } }, - }, - ...request( - 5, - "turn/interrupt", - { threadId: nativeThreadId, turnId: "goal-turn-b" }, - {}, - ), - notification("continuation interrupted", "turn/completed", { + notification("goal complete", "thread/goal/updated", { threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "interrupted" }), + turnId: "goal-turn-2", + goal: codexGoal("complete", 1200), + }), + notification("continuation done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn-2", 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("goal-stop-race-attempt"), - text: prompt, - }), - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.goal?.status === "active", - ), - "the goal to be active", - ); - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId: "goal-turn-a", - }), - requestRuntimeRestart: true, - }); + yield* harness.runtime.startTurn(yield* goalTurnInput(harness, `/goal ${objective}`)); yield* harness.firstTerminal; + + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const providerTurnId = (nativeTurnId: string) => + idAllocator.derive.providerTurn({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeTurnId, + }); assert.deepEqual( - harness.terminalEvents().map((event) => event.status), - ["interrupted"], + harness.terminalEvents().map((event) => [event.providerTurnId, event.status]), + [[providerTurnId("goal-turn-2"), "completed"]], + ); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running" + ? [ + [ + event.providerTurn.id, + event.providerTurn.runAttemptId, + event.providerTurn.ordinal, + ], + ] + : [], + ), + [ + [providerTurnId("goal-turn-1"), RunAttemptId.make("goal-attempt"), 1], + [providerTurnId("goal-turn-2"), RunAttemptId.make("goal-attempt"), 2], + ], ); + assert.equal(providerGoals(harness.events).at(-1), "complete"); }).pipe( Effect.scoped, Effect.provide( @@ -9809,31 +5650,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("stops a subagent an earlier goal turn left running when Stop settles the run", () => + it.effect("settles a run held for the next goal turn when Stop arrives in between", () => Effect.gen(function* () { - const prompt = "Delegate the audit"; - const childThreadId = "goal-child-thread"; - const childTurnId = "goal-child-turn"; + const prompt = "Keep going"; const transcript = makeCodexReplayTranscript({ - scenario: "goal-stop-held-subagent", + scenario: "goal-stop-between-turns", entries: [ ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), - notification("subagent started", "item/completed", { - item: { - type: "subAgentActivity", - id: "goal-subagent-call", - kind: "started", - agentThreadId: childThreadId, - agentPath: "/root/audit", - }, - threadId: nativeThreadId, - turnId: "goal-turn", - completedAtMs: 1782622441000, - }), - notification("child turn", "turn/started", { - threadId: childThreadId, - turn: makeCodexReplayTurn({ id: childTurnId, status: "inProgress" }), - }), notification("goal active", "thread/goal/updated", { threadId: nativeThreadId, turnId: "goal-turn", @@ -9852,57 +5675,65 @@ describe("CodexAdapterV2 post-settle continuation", () => { 4, "thread/goal/set", { threadId: nativeThreadId, status: "paused" }, - { goal: codexGoal("paused", 11) }, + { goal: codexGoal("paused", 10) }, ), - ...request(5, "turn/interrupt", { threadId: childThreadId, turnId: childTurnId }, {}), - notification("child interrupted", "turn/completed", { - threadId: childThreadId, - turn: makeCodexReplayTurn({ id: childTurnId, status: "interrupted" }), + notification("goal paused", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("paused", 10), }), ], }); - const harness = yield* makeCodexReplayHarness(transcript); + const requests: Array = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + requests.push(method); + }), + ); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("goal-held-subagent-attempt"), + attemptId: RunAttemptId.make("goal-hold-attempt"), text: prompt, }), ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.goal?.tokensUsed === 11, - ), - "the completed turn to be held", + const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeTurnId: "goal-turn", + }); + const turnStatuses = () => + harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.id === providerTurnId + ? [event.providerTurn.status] + : [], + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), ); + assert.deepEqual(turnStatuses(), ["running"]); + assert.lengthOf(harness.terminalEvents(), 0); + yield* harness.runtime.interruptTurn({ providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId: "goal-turn", - }), - requestRuntimeRestart: true, + providerTurnId, }); yield* harness.firstTerminal; + + assert.notInclude(requests, "turn/interrupt"); + assert.deepEqual(turnStatuses(), ["running", "completed"]); assert.deepEqual( harness.terminalEvents().map((event) => event.status), ["interrupted"], ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === childTurnId && - event.providerTurn.status === "interrupted", - ), - "the subagent turn to be interrupted", - ); }).pipe( Effect.scoped, Effect.provide( @@ -9911,31 +5742,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("stops an earlier goal turn's subagent when the run settles during the pause", () => + it.effect("moves a Stop that names an earlier goal turn to the turn Codex continued with", () => Effect.gen(function* () { - const prompt = "Delegate the audit"; - const childThreadId = "goal-race-child-thread"; - const childTurnId = "goal-race-child-turn"; + const prompt = "Keep going"; const transcript = makeCodexReplayTranscript({ - scenario: "goal-stop-run-settles-during-pause", + scenario: "goal-stop-after-continuation", entries: [ ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }), - notification("subagent started", "item/completed", { - item: { - type: "subAgentActivity", - id: "goal-race-subagent-call", - kind: "started", - agentThreadId: childThreadId, - agentPath: "/root/audit", - }, - threadId: nativeThreadId, - turnId: "goal-turn-a", - completedAtMs: 1782622441000, - }), - notification("child turn", "turn/started", { - threadId: childThreadId, - turn: makeCodexReplayTurn({ id: childTurnId, status: "inProgress" }), - }), notification("goal active", "thread/goal/updated", { threadId: nativeThreadId, turnId: "goal-turn-a", @@ -9954,33 +5767,21 @@ describe("CodexAdapterV2 post-settle continuation", () => { turnId: "goal-turn-b", goal: codexGoal("active", 11), }), - { - type: "expect_outbound", - label: "thread/goal/set", - frame: { - id: 4, - method: "thread/goal/set", - params: { threadId: nativeThreadId, status: "paused" }, - }, - }, - notification("goal paused", "thread/goal/updated", { - threadId: nativeThreadId, - turnId: "goal-turn-b", - goal: codexGoal("paused", 11), - }), - notification("continuation done", "turn/completed", { + ...request( + 4, + "thread/goal/set", + { threadId: nativeThreadId, status: "paused" }, + { goal: codexGoal("paused", 11) }, + ), + ...request( + 5, + "turn/interrupt", + { threadId: nativeThreadId, turnId: "goal-turn-b" }, + {}, + ), + notification("continuation interrupted", "turn/completed", { threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "completed" }), - }), - { - type: "emit_inbound", - label: "thread/goal/set", - frame: { id: 4, result: { goal: codexGoal("paused", 11) } }, - }, - ...request(5, "turn/interrupt", { threadId: childThreadId, turnId: childTurnId }, {}), - notification("child interrupted", "turn/completed", { - threadId: childThreadId, - turn: makeCodexReplayTurn({ id: childTurnId, status: "interrupted" }), + turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "interrupted" }), }), ], }); @@ -9990,36 +5791,29 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("goal-settles-during-pause-attempt"), + attemptId: RunAttemptId.make("goal-successor-attempt"), text: prompt, }), ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.goal?.tokensUsed === 11, - ), - "the continuation to be adopted", + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), ); yield* harness.runtime.interruptTurn({ providerThread: harness.providerThread, providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId: "goal-turn-b", + nativeTurnId: "goal-turn-a", }), requestRuntimeRestart: true, }); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === childTurnId && - event.providerTurn.status === "interrupted", - ), - "the subagent turn to be interrupted", + yield* harness.firstTerminal; + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["interrupted"], ); }).pipe( Effect.scoped, @@ -10072,10 +5866,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { text: prompt, }), ); - yield* awaitUntil( - () => providerGoals(harness.events).includes("active"), - "the active goal to reach the provider thread", - ); + yield* harness.until(() => providerGoals(harness.events).includes("active")); yield* harness.runtime.interruptTurn({ providerThread: harness.providerThread, providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 7c6fb15540de..072c973207d2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -158,7 +158,7 @@ type CodexFileChangeKinds = Readonly< Record >; -export function codexFileChangeApprovalPrompt(input: { +function codexFileChangeApprovalPrompt(input: { readonly reason?: string | null; readonly grantRoot?: string | null; readonly fileChanges?: CodexFileChangeKinds; @@ -420,14 +420,14 @@ function codexItemStatus(status: "inProgress" | "completed" | "failed" | "declin * The composers let a skill be typed with any currency sigil (`€review`), but * Codex only parses `$name` as a skill mention. */ -export function codexSkillMentionText(text: string): string { +function codexSkillMentionText(text: string): string { return text.replace(SKILL_MENTION_PATTERN, "$1$$$2"); } const BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH = 200; const BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH = 1_000; -export function codexBackgroundCommandDetail(item: { +function codexBackgroundCommandDetail(item: { readonly command: string; readonly exitCode?: number | null | undefined; readonly aggregatedOutput?: string | null | undefined; @@ -492,37 +492,35 @@ function codexDynamicToolOutput( return item.success === false ? { success: false } : undefined; } -export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDynamicToolItem")( - function* ( - item: CodexDynamicToolItem, - ): Effect.fn.Return { - const output = - item.type === "mcpToolCall" ? codexMcpToolOutput(item) : codexDynamicToolOutput(item); - const toolName = - item.type === "mcpToolCall" - ? `${item.server}.${item.tool}` - : [trimText(item.namespace), item.tool].filter(Boolean).join("."); - const presentation: McpToolPresentation = - item.type === "mcpToolCall" ? yield* mcpToolPresentation(item) : {}; - const title = - dynamicToolTitle(toolName, item.arguments, presentation.title) ?? presentation.title; - const toolSurface = presentation.toolSurface ?? dynamicToolSurface(toolName, item.arguments); - const toolSource = - toolSurface === "computer" && presentation.toolSource?.kind !== "computer" - ? COMPUTER_USE_TOOL_SOURCE - : presentation.toolSource; - const projection: CodexDynamicToolProjection = { - ...presentation, - ...(toolSurface ? { toolSurface } : {}), - ...(toolSource ? { toolSource } : {}), - toolName, - ...(title ? { title } : {}), - input: item.arguments, - status: codexItemStatus(item.status).turnItem, - }; - return output === undefined ? projection : { ...projection, output }; - }, -); +const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDynamicToolItem")(function* ( + item: CodexDynamicToolItem, +): Effect.fn.Return { + const output = + item.type === "mcpToolCall" ? codexMcpToolOutput(item) : codexDynamicToolOutput(item); + const toolName = + item.type === "mcpToolCall" + ? `${item.server}.${item.tool}` + : [trimText(item.namespace), item.tool].filter(Boolean).join("."); + const presentation: McpToolPresentation = + item.type === "mcpToolCall" ? yield* mcpToolPresentation(item) : {}; + const title = + dynamicToolTitle(toolName, item.arguments, presentation.title) ?? presentation.title; + const toolSurface = presentation.toolSurface ?? dynamicToolSurface(toolName, item.arguments); + const toolSource = + toolSurface === "computer" && presentation.toolSource?.kind !== "computer" + ? COMPUTER_USE_TOOL_SOURCE + : presentation.toolSource; + const projection: CodexDynamicToolProjection = { + ...presentation, + ...(toolSurface ? { toolSurface } : {}), + ...(toolSource ? { toolSource } : {}), + toolName, + ...(title ? { title } : {}), + input: item.arguments, + status: codexItemStatus(item.status).turnItem, + }; + return output === undefined ? projection : { ...projection, output }; +}); function codexNativeItemRef(nativeItemId: string) { return { @@ -900,7 +898,7 @@ export type CodexGoalCommand = | { readonly type: "resume" } | { readonly type: "set"; readonly objective: string }; -export function parseCodexGoalCommand(text: string): CodexGoalCommand | null { +function parseCodexGoalCommand(text: string): CodexGoalCommand | null { const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim()); if (match === null) return null; const argument = (match[1] ?? "").trim(); diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index d8078783f604..d2c23e185ba9 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -37,21 +37,13 @@ import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as RuntimePolicy from "../RuntimePolicy.ts"; import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; -import { - AcpProviderCapabilitiesV2, - acpCompletedTurnShouldTerminalizeTool, - acpSubagentStatusBlocksTurnSettlement, - acpSupportsImagePrompts, -} from "@t3tools/provider-acp/server/adapter"; +import { acpCompletedTurnShouldTerminalizeTool } from "@t3tools/provider-acp/server/adapter"; import { makeGrokAcpAdapterFlavor, makeGrokAdapterV2, - GrokProviderCapabilitiesV2, GROK_PROVIDER, GROK_ACP_INITIALIZE_META, } from "@t3tools/provider-grok/testing"; @@ -94,20 +86,6 @@ function runtimePolicy(input: { }); } -describe("acpSubagentStatusBlocksTurnSettlement", () => { - it("blocks settlement for pending and running subagents", () => { - assert.isTrue(acpSubagentStatusBlocksTurnSettlement("pending")); - assert.isTrue(acpSubagentStatusBlocksTurnSettlement("running")); - }); - - it("does not block settlement for terminal subagents", () => { - assert.isFalse(acpSubagentStatusBlocksTurnSettlement("cancelled")); - assert.isFalse(acpSubagentStatusBlocksTurnSettlement("completed")); - assert.isFalse(acpSubagentStatusBlocksTurnSettlement("failed")); - assert.isFalse(acpSubagentStatusBlocksTurnSettlement("interrupted")); - }); -}); - describe("GrokAdapterV2 capabilities", () => { it("preserves Grok's rate-limit stop and distinguishes other prompt failures", () => { const flavor = makeGrokAcpAdapterFlavor( @@ -140,24 +118,6 @@ describe("GrokAdapterV2 capabilities", () => { ); }); - it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => { - const flavor = makeGrokAcpAdapterFlavor( - { - makeRuntime: () => Effect.never, - } as unknown as Parameters[0], - "/home/ada", - ); - - assert.isFalse(flavor.interruptPromptOnCancel); - // User Stop (requestRuntimeRestart) keeps the hard process-group kill and - // respawn: Grok cancel is detach-and-continue, so only a process kill - // stops the work. - assert.isTrue(flavor.restartRuntimeAfterInterrupt); - assert.isTrue(flavor.terminateRuntimeProcessGroupOnInterrupt); - assert.isUndefined(flavor.restartRuntimeOnEveryInterrupt); - assert.isTrue(flavor.preserveRuntimeOnSettledInterrupt); - }); - it("terminalizes only foreground tools under the actual Grok flavor", () => { const flavor = makeGrokAcpAdapterFlavor( { @@ -203,67 +163,9 @@ describe("GrokAdapterV2 capabilities", () => { assert.isFalse(acpCompletedTurnShouldTerminalizeTool(monitor, flavor)); assert.isFalse(acpCompletedTurnShouldTerminalizeTool(subagent, flavor)); }); - - it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => { - assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession); - assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession); - assert.isFalse(AcpProviderCapabilitiesV2.threads.canReadThreadSnapshot); - assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools); - }); - - it("overrides ACP image capability false so screenshot attachments can prompt", () => { - assert.isFalse( - acpSupportsImagePrompts({ - negotiatedImage: false, - }), - ); - assert.isTrue( - acpSupportsImagePrompts({ - flavorSupportsImagePrompts: true, - negotiatedImage: false, - }), - ); - assert.isTrue( - acpSupportsImagePrompts({ - negotiatedImage: true, - }), - ); - }); - - it("declares Grok Task envelopes as native subagents", () => { - assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread); - assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents); - assert.isTrue(GrokProviderCapabilitiesV2.subagents.exposesSubagentThreadIds); - assert.isTrue(GrokProviderCapabilitiesV2.subagents.emitsSubagentLifecycle); - assert.isFalse(GrokProviderCapabilitiesV2.turns.supportsActiveSteering); - assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsInterrupt); - assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart); - assert.isTrue(GrokProviderCapabilitiesV2.context.supportsFullThreadHandoff); - }); - - it("declares the optional ACP features verified by the Grok handshake", () => { - assert.isTrue(GrokProviderCapabilitiesV2.sessions.supportsModelSwitchInSession); - assert.isTrue(GrokProviderCapabilitiesV2.threads.canReadThreadSnapshot); - assert.isTrue(GrokProviderCapabilitiesV2.tools.supportsMcpTools); - assert.isTrue(GrokProviderCapabilitiesV2.checkpointing.providerCanReadConversationSnapshot); - }); }); describe("ACP permission policy", () => { - it("honors explicit on-request approval over full-access runtime mode", () => { - assert.equal( - acpPermissionDisposition( - runtimePolicy({ - runtimeMode: "full-access", - approvalPolicy: "on-request", - sandboxPolicy: { type: "readOnly" }, - }), - permissionRequest("execute"), - ), - "ask", - ); - }); - it("rejects mutating escalation under a non-interactive read-only policy", () => { const policy = runtimePolicy({ runtimeMode: "full-access", @@ -330,94 +232,6 @@ describe("Grok permission prompts", () => { }); }); -describe("Grok model context windows", () => { - const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); - const layerTest = Layer.mergeAll( - NodeServices.layer, - IdAllocator.layer, - McpProviderSessions.layer, - layerHost, - ); - - it.effect("budgets handoffs with the selected Grok model's window", () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make("grok-window-test"); - const threadId = ThreadId.make("grok-window-test"); - const modelSelection = { instanceId, model: "grok-build" }; - const policy = runtimePolicy({ runtimeMode: "full-access" }); - const path = yield* Path.Path; - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const adapter = yield* makeGrokAdapterV2({ - instanceId, - settings: LAUNCH_TEST_GROK_SETTINGS, - environment: {}, - hostPlatform: "linux", - selfInvocation: yield* resolveSelfInvocation(), - makeRuntime: (input) => - Effect.gen(function* () { - const context = yield* Layer.build( - AcpSessionRuntime.layer({ - ...input, - spawn: { - command: process.execPath, - args: [mockAgentPath], - cwd: process.cwd(), - env: { - T3_ACP_SESSION_LIFECYCLE: "1", - T3_ACP_GROK_MODEL: "grok-4.7", - T3_ACP_GROK_CONTEXT_TOKENS: "256000", - }, - }, - authMethodId: "test", - }), - ); - return yield* AcpSessionRuntime.AcpSessionRuntime.pipe(Effect.provide(context)); - }), - }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("grok-window-test"), - modelSelection, - runtimePolicy: policy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy: policy, - }); - const budgetInput = { - tokenCap: 16_000, - userText: "Continue", - attachments: [], - providerThread, - nativeContextEstimate: 160_684, - }; - assert.isNull(providerThread.contextUsage); - assert.equal(handoffBudget(budgetInput), 0); - assert.equal( - handoffBudget({ - ...budgetInput, - modelContextWindow: runtime.getModelContextWindow?.({ instanceId, model: "grok-4.7" }), - }), - 16_000, - ); - assert.equal(runtime.getModelContextWindow?.({ instanceId, model: "grok-4.7" }), 256_000); - assert.equal(runtime.getModelContextWindow?.({ instanceId, model: " grok-4.7 " }), 256_000); - for (const model of ["custom-model", "grok-build", "toString"]) { - assert.isUndefined(runtime.getModelContextWindow?.({ instanceId, model })); - } - assert.isUndefined( - runtime.getModelContextWindow?.({ - instanceId: ProviderInstanceId.make("another-grok"), - model: "grok-4.7", - }), - ); - }).pipe(Effect.scoped, Effect.provide(layerTest)), - ); -}); - describe("Grok launch permission mode", () => { const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); const layerTest = Layer.mergeAll( @@ -544,25 +358,6 @@ describe("Grok launch permission mode", () => { ]); }), ); - - it.effect("launches asking when an explicit approval or sandbox policy governs the thread", () => - Effect.gen(function* () { - const asking = [["--permission-mode", "default", "agent", "stdio"]]; - assert.deepEqual( - yield* launchArgs( - policy("full-access", { - approvalPolicy: "never", - sandboxPolicy: { type: "workspaceWrite", writableRoots: [], networkAccess: false }, - }), - ), - asking, - ); - assert.deepEqual( - yield* launchArgs(policy("full-access", { approvalPolicy: "on-request" })), - asking, - ); - }), - ); }); describe("Grok native questions", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 13312dab93f5..a068a08c5e23 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -31,14 +31,11 @@ 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 References from "effect/References"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Exit from "effect/Exit"; import { TestClock } from "effect/testing"; -import { describe } from "vite-plus/test"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -443,36 +440,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("creates the session with the thread's title, so OpenCode generates none", () => - Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntimeWithInstructions([ - ...opening, - out("session.create", { - location: { directory: WORK }, - model: { providerID: "opencode", id: "big-pickle" }, - permissions: t3Rules, - title: "Check whether 391 is prime", - }), - replyData("session.create", sessionInfo({ title: "Check whether 391 is prime" })), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const thread = yield* runtime.ensureThread({ - threadId, - title: " Check whether 391 is prime ", - modelSelection: bigPickle, - runtimePolicy: policy(), - }); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn({ - ...turnInput(thread), - appThread: { title: "Check whether 391 is prime" } as OrchestrationV2AppThread, - }); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - }).pipe(Effect.scoped), - ); - it.effect("renames the session before the first turn after the thread was renamed", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -519,6 +486,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + // regression: #18315 OpenCode Go quota stops were not shown as usage limits it.effect.each([ ["provider.quota", "usage_limit"], ["provider.rate-limit", "usage_limit"], @@ -630,55 +598,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }), ); - it.effect("sends nothing for a turn stopped while it reads the workspace's commands", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - out("command.list", ""), - reply("command.list", ""), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: false }), - ]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - const started = yield* runtime - .startTurn({ - ...turnInput(thread), - message: { ...turnInput(thread).message, text: "/hello WORLD" }, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("1 second"); - yield* runtime.interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - requestRuntimeRestart: true, - }); - yield* TestClock.adjust("6 seconds"); - yield* Fiber.join(started); - assert.equal((yield* Fiber.join(terminal))?.status, "interrupted"); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.effect("ends a turn locally when a stuck server never answers Stop", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", ""), - ]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread)); - const interrupt = yield* runtime - .interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("11 seconds"); - yield* Fiber.join(interrupt); - assert.equal((yield* Fiber.join(terminal))?.status, "interrupted"); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - const childCreated = (child: string) => ({ sessionID: child, slug: "proud-canyon", @@ -803,90 +722,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("keeps tracking a background subagent whose Stop did not reach it", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...backgroundLaunch(CHILD), - out("session.interrupt", { sessionID: CHILD }), - reply("session.interrupt", { - status: 500, - body: { _tag: "UnknownError", message: "interrupt failed" }, - }), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: true }), - event("session.execution.interrupted", { sessionID: SESSION }), - ]); - const watch = yield* watchBackgroundTurn(runtime); - yield* runtime.startTurn(withLineage(thread)); - yield* watch.attached; - yield* runtime.interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - requestRuntimeRestart: true, - }); - assert.equal((yield* watch.terminal)?.status, "interrupted"); - assert.isTrue(yield* runtime.hasPendingBackgroundWork!); - assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); - }).pipe(Effect.scoped), - ); - - it.effect("stops a background subagent announced before its call named it", () => - Effect.gen(function* () { - const call = "call-background"; - const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call }; - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...tool, name: "subagent" }), - event("session.tool.called", { - ...tool, - name: "subagent", - input: { description: "Sleep", prompt: "sleep", background: true }, - executed: false, - }), - event("session.created", childCreated(CHILD)), - event("session.text.ended", { - sessionID: SESSION, - assistantMessageID: "msg_assistant", - ordinal: 0, - text: "Launched.", - }), - out("session.interrupt", { sessionID: CHILD }), - reply("session.interrupt", { interrupted: true }), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: true }), - event("session.execution.interrupted", { sessionID: SESSION }), - ]); - const announced = yield* Deferred.make(); - const terminal = yield* runtime.events.pipe( - Stream.tap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" - ? Deferred.succeed(announced, undefined) - : Effect.void, - ), - Stream.filter( - ( - event, - ): event is Extract => - event.type === "turn.terminal", - ), - Stream.runHead, - Effect.map(Option.getOrUndefined), - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - yield* Deferred.await(announced); - yield* runtime.interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - requestRuntimeRestart: true, - }); - assert.equal((yield* Fiber.join(terminal))?.status, "interrupted"); - assert.isFalse(yield* runtime.hasPendingBackgroundWork!); - }).pipe(Effect.scoped), - ); - it.effect("gives a background subagent the rules of a mode changed while it runs", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -1115,31 +950,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("settles every call joined to a failed background subagent", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...launchedAndRunning, - // A second call joins the running child, so its turn answers for both. - ...callAgain({ background: true }), - event("session.execution.succeeded", { sessionID: SESSION }), - event("session.execution.failed", { - sessionID: CHILD, - error: { type: "provider.quota", message: "Rate limit exceeded." }, - }), - ]); - const watch = yield* watchCalls(runtime, thread); - yield* runtime.startTurn(withLineage(thread)); - yield* watch.until( - () => - watch.calls.get("Sleep")?.status === "failed" && - watch.calls.get("Again")?.status === "failed" && - watch.state.roster?.length === 0, - ); - assert.deepEqual(watch.state.roster, []); - assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); - }).pipe(Effect.scoped), - ); - + // regression: #21979 a failed background subagent pinned its thread as busy it.effect("settles the background call a foreground call joined to a failed child", () => Effect.gen(function* () { const again = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" }; @@ -1205,27 +1016,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("keeps a foreground call that joined a background run for that run's report", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...launchedAndRunning, - ...callAgain({}), - event("session.execution.succeeded", { sessionID: SESSION }), - event("session.execution.succeeded", { sessionID: CHILD }), - childReport("msg_report", "CHILD_OK"), - ]); - const watch = yield* watchCalls(runtime, thread); - yield* runtime.startTurn(withLineage(thread)); - yield* watch.until( - () => - watch.calls.get("Again")?.status === "completed" && - watch.calls.get("Sleep")?.status === "completed" && - watch.state.roster?.length === 0, - ); - assert.deepEqual(watch.calls.get("Again"), { status: "completed", result: "CHILD_OK" }); - }).pipe(Effect.scoped), - ); - it.effect("keeps a call to a finished subagent for its own run's report", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -1250,169 +1040,23 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("leaves no background work pending once a reconnect finds its subagent gone", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...backgroundLaunch(CHILD), - event("session.execution.started", { sessionID: CHILD }), - event("session.execution.succeeded", { sessionID: SESSION }), - { type: "runtime_exit", status: "success" } as const, - out("event.subscribe"), - out("session.active"), - replyData("session.active", {}), - ]); - const settled = yield* Deferred.make(); - yield* runtime.events.pipe( - Stream.tap((event) => - event.type === "subagent.updated" && event.subagent.status === "interrupted" - ? Deferred.succeed(settled, event.subagent.result) - : Effect.void, - ), - Stream.runDrain, - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - assert.include((yield* Deferred.await(settled)) ?? "", "lost its connection to OpenCode"); - assert.isFalse(yield* runtime.hasPendingBackgroundWork!); - assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); - }).pipe(Effect.scoped), - ); - - it.effect("stops a nested background subagent's report on its own parent's session", () => - Effect.gen(function* () { - const MIDDLE = "ses_middle0000000000000000000"; - const GRANDCHILD = "ses_grandchild00000000000000"; - const tool = (session: string, id: string) => ({ - sessionID: session, - assistantMessageID: `msg_assistant_${id}`, - id, - }); - const report = { - inboxID: "msg_nested_report", - sessionID: MIDDLE, - item: { - type: "synthetic", - payload: { - text: `\n`, - description: "Deep", - metadata: { - source: "subagent", - childID: GRANDCHILD, - agent: "General", - state: "cancelled", - }, - }, - delivery: "steer", - }, - }; - const offered = yield* Deferred.make(); - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...tool(SESSION, "call-middle"), name: "subagent" }), - event("session.tool.called", { - ...tool(SESSION, "call-middle"), - name: "subagent", - input: { description: "Middle", prompt: "delegate", background: true }, - executed: false, - }), - event("session.created", { ...childCreated(MIDDLE), title: "Middle" }), - event("session.tool.progress", { - ...tool(SESSION, "call-middle"), - metadata: { sessionID: MIDDLE, status: "running" }, - }), - event("session.execution.started", { sessionID: MIDDLE }), - event("session.tool.input.started", { ...tool(MIDDLE, "call-deep"), name: "subagent" }), - event("session.tool.called", { - ...tool(MIDDLE, "call-deep"), - name: "subagent", - input: { description: "Deep", prompt: "sleep", background: true }, - executed: false, - }), - event("session.created", { - ...childCreated(GRANDCHILD), - parentID: MIDDLE, - title: "Deep", - }), - event("session.tool.progress", { - ...tool(MIDDLE, "call-deep"), - metadata: { sessionID: GRANDCHILD, status: "running" }, - }), - event("session.text.ended", { - sessionID: SESSION, - assistantMessageID: "msg_assistant_root", - ordinal: 0, - text: "Launched.", - }), - out("session.interrupt", { sessionID: MIDDLE }), - reply("session.interrupt", { interrupted: true }), - out("session.interrupt", { sessionID: GRANDCHILD }), - reply("session.interrupt", { interrupted: true }), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: true }), - event("session.execution.interrupted", { sessionID: SESSION }), - event("session.execution.interrupted", { sessionID: MIDDLE }), - event("session.inbox.enqueued", report), - event("session.execution.started", { sessionID: MIDDLE }), - out("session.interrupt", { sessionID: MIDDLE }), - reply("session.interrupt", { interrupted: true }), - event("session.execution.interrupted", { sessionID: MIDDLE }), - event("session.execution.started", { sessionID: SESSION }), - ]).pipe( - Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), - take: Effect.never, - }), - ); - const launched = yield* Deferred.make(); - const middleTurns: Array = []; - yield* runtime.events.pipe( - Stream.tap((event) => - Effect.gen(function* () { - if ( - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${MIDDLE}:turn:`) === true - ) { - middleTurns.push(event.providerTurn.nativeTurnRef.nativeId); - } - if (event.type === "turn_item.updated" && event.turnItem.type === "assistant_message") { - yield* Deferred.succeed(launched, undefined); - } - }), - ), - Stream.runDrain, - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - yield* Deferred.await(launched); - yield* runtime.interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - requestRuntimeRestart: true, - }); - yield* Deferred.await(offered); - assert.deepEqual([...new Set(middleTurns)], [`${MIDDLE}:turn:1`]); - }).pipe(Effect.scoped), - ); - - const MIDDLE = "ses_middle0000000000000000000"; - const DEEP = "ses_grandchild00000000000000"; - const toolOf = (session: string, id: string) => ({ - sessionID: session, - assistantMessageID: `msg_assistant_${id}`, - id, - }); - const deepReport = (state: "completed" | "cancelled") => - event("session.inbox.enqueued", { - inboxID: "msg_deep_report", - sessionID: MIDDLE, - item: { - type: "synthetic", - payload: { - text: `\nDEEP_OK\n`, - description: "Deep", - metadata: { source: "subagent", childID: DEEP, agent: "General", state }, + const MIDDLE = "ses_middle0000000000000000000"; + const DEEP = "ses_grandchild00000000000000"; + const toolOf = (session: string, id: string) => ({ + sessionID: session, + assistantMessageID: `msg_assistant_${id}`, + id, + }); + const deepReport = (state: "completed" | "cancelled") => + event("session.inbox.enqueued", { + inboxID: "msg_deep_report", + sessionID: MIDDLE, + item: { + type: "synthetic", + payload: { + text: `\nDEEP_OK\n`, + description: "Deep", + metadata: { source: "subagent", childID: DEEP, agent: "General", state }, }, delivery: "steer", }, @@ -1537,58 +1181,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("keeps a reused subagent's background subagent tracked and stops it", () => - Effect.gen(function* () { - const again = "call-middle-again"; - const { runtime, thread } = yield* resumed([ - ...nestedLaunch, - out("session.update", { sessionID: DEEP, permissions: "" }), - reply("session.update", null), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...toolOf(SESSION, again), name: "subagent" }), - event("session.tool.called", { - ...toolOf(SESSION, again), - name: "subagent", - input: { description: "Middle", prompt: "again", sessionID: MIDDLE }, - executed: false, - }), - event("session.tool.progress", { - ...toolOf(SESSION, again), - metadata: { sessionID: MIDDLE, status: "running" }, - }), - out("session.update", { sessionID: MIDDLE, permissions: "" }), - reply("session.update", null), - event("session.execution.started", { sessionID: MIDDLE }), - event("session.execution.succeeded", { sessionID: MIDDLE }), - event("session.tool.success", { - ...toolOf(SESSION, again), - content: [{ type: "text", text: `` }], - metadata: { sessionID: MIDDLE, status: "completed", truncated: false }, - executed: false, - }), - event("session.execution.succeeded", { sessionID: SESSION }), - out("session.interrupt", { sessionID: DEEP }), - reply("session.interrupt", { interrupted: true }), - ]); - const watch = yield* watchNested(runtime); - yield* runtime.startTurn(withLineage(thread)); - yield* watch.until(() => watch.terminals.length === 1); - yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); - yield* watch.until(() => watch.terminals.length === 2); - assert.deepEqual(watch.terminals, ["completed", "completed"]); - assert.equal(watch.deep.at(-1), "running"); - assert.isTrue(yield* runtime.hasPendingBackgroundWork!); - yield* runtime.interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - requestRuntimeRestart: true, - }); - yield* watch.until(() => watch.deep.at(-1) === "interrupted"); - }).pipe(Effect.scoped), - ); - it.effect("stops the turn a foreground subagent runs to answer its background subagent", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -1621,28 +1213,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("leaves nothing pending once a reconnect drops a subagent's queued report", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...nestedLaunch, - event("session.execution.succeeded", { sessionID: DEEP }), - deepReport("completed"), - { type: "runtime_exit", status: "success" } as const, - out("event.subscribe"), - out("session.active"), - replyData("session.active", {}), - ]); - const watch = yield* watchNested(runtime); - yield* runtime.startTurn(withLineage(thread)); - yield* watch.until(() => watch.deep.at(-1) === "completed"); - assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); - yield* Effect.repeat(runtime.hasPendingBackgroundWorkForThread!(thread), { - while: (pending) => pending, - }); - assert.isFalse(yield* runtime.hasPendingBackgroundWork!); - }).pipe(Effect.scoped), - ); - const stopTimedOut: ReadonlyArray = [ out("session.prompt", { sessionID: SESSION, text: "" }), promptAccepted, @@ -1706,64 +1276,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); - it.effect( - "starts the next turn once the server no longer runs a timed-out Stop's execution", - () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...stopTimedOut, - out("session.active"), - reply("session.active", { data: {} }), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const ended = yield* terminals(runtime, 2); - yield* stopFirstTurn(runtime, thread); - yield* runtime.startTurn(secondTurn(thread)); - const [, second] = yield* Fiber.join(ended); - assert.equal(second?.status, "completed"); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.effect("reads a timed-out Stop's next turn from an execution start it cannot decode", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...stopTimedOut, - out("session.active"), - reply("session.active", { data: {} }), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - { - type: "emit_inbound", - frame: { - type: "sdk.event", - event: { - id: "evt_executionstartednewer", - created: 1, - type: "session.execution.started", - data: { sessionID: SESSION }, - durable: "not-an-envelope", - }, - }, - }, - event("session.text.ended", { - sessionID: SESSION, - assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", - ordinal: 0, - text: "DONE", - }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const ended = yield* terminals(runtime, 2); - yield* stopFirstTurn(runtime, thread); - yield* runtime.startTurn(secondTurn(thread)); - const [, second] = yield* Fiber.join(ended); - assert.equal(second?.status, "completed"); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.effect("checks the server before prompting again after a prompt request failed", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -1786,6 +1298,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + // regression: #14744 continuations hung after a reconnect took a Stop's end it.effect("still checks the server when a failed prompt's execution starts late", () => Effect.gen(function* () { const offered = yield* Deferred.make(); @@ -1816,88 +1329,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); - it.effect("still checks the server when a failed prompt starts after a reconnect", () => - Effect.gen(function* () { - const offered = yield* Deferred.make(); - const gate = makeProviderReplayGate(["drop"]); - const { runtime, thread } = yield* resumed( - [ - out("session.prompt", { sessionID: SESSION, text: "" }), - reply("session.prompt", { - status: 502, - body: { _tag: "UnknownError", message: "bad gateway" }, - }), - event("session.usage.updated", { sessionID: SESSION }, "drop"), - { type: "runtime_exit", status: "success" } as const, - out("event.subscribe"), - out("session.active"), - replyData("session.active", { [SESSION]: { type: "running" } }), - out("permission.list", { sessionID: SESSION }), - replyData("permission.list", []), - out("session.form.list", { sessionID: SESSION }), - replyData("session.form.list", []), - event("session.execution.started", { sessionID: SESSION }), - out("session.active"), - reply("session.active", { data: { [SESSION]: { type: "running" } } }), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: true }), - ], - { replayGate: gate }, - ).pipe( - Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), - take: Effect.never, - }), - ); - const ended = yield* terminals(runtime, 2); - yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); - gate.release("drop"); - yield* Deferred.await(offered); - yield* runtime.startTurn(secondTurn(thread)); - const [first, second] = yield* Fiber.join(ended); - assert.equal(first?.status, "failed"); - assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.effect("prompts again without a check after the server refused a prompt", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - reply("session.prompt", { - status: 400, - body: { _tag: "InvalidRequestError", message: "bad prompt" }, - }), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const ended = yield* terminals(runtime, 2); - yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore); - yield* runtime.startTurn(secondTurn(thread)); - const [first, second] = yield* Fiber.join(ended); - assert.deepEqual([first?.status, second?.status], ["failed", "completed"]); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.effect("stops a timed-out Stop's execution again and fails the turn while it runs", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...stopTimedOut, - out("session.active"), - reply("session.active", { data: { [SESSION]: { type: "running" } } }), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: true }), - ]); - const ended = yield* terminals(runtime, 2); - yield* stopFirstTurn(runtime, thread); - yield* runtime.startTurn(secondTurn(thread)); - const [, second] = yield* Fiber.join(ended); - assert.equal(second?.status, "failed"); - assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () => Effect.gen(function* () { const offered = @@ -2007,106 +1438,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); - it.effect("lets a prompt join a background reply after a lost stream took a Stop's end", () => - Effect.gen(function* () { - const offered = yield* Deferred.make(); - const gate = makeProviderReplayGate(["drop"]); - const { runtime, thread } = yield* resumed( - [ - ...backgroundLaunch(CHILD), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", ""), - out("session.active"), - replyData("session.active", { [SESSION]: { type: "running" } }), - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: true }), - event("session.usage.updated", { sessionID: SESSION }, "drop"), - { type: "runtime_exit", status: "success" } as const, - out("event.subscribe"), - out("session.active"), - replyData("session.active", { [CHILD]: { type: "running" } }), - out("permission.list", { sessionID: CHILD }), - replyData("permission.list", []), - out("session.form.list", { sessionID: CHILD }), - replyData("session.form.list", []), - event("session.execution.succeeded", { sessionID: CHILD }), - event("session.inbox.enqueued", { - inboxID: "msg_report", - sessionID: SESSION, - item: { - type: "synthetic", - payload: { - text: `\nCHILD_OK\n`, - description: "Sleep", - metadata: { - source: "subagent", - childID: CHILD, - agent: "General", - state: "completed", - }, - }, - delivery: "steer", - }, - }), - event("session.execution.started", { sessionID: SESSION }), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.text.ended", { - sessionID: SESSION, - assistantMessageID: "msg_followup", - ordinal: 0, - text: "CHILD_OK", - }), - event("session.execution.succeeded", { sessionID: SESSION }), - ], - { replayGate: gate }, - ).pipe( - Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid), - take: Effect.never, - }), - ); - const attached = yield* Deferred.make(); - const ended = yield* runtime.events.pipe( - Stream.tap((event) => - event.type === "subagent.updated" && event.subagent.childThreadId !== null - ? Deferred.succeed(attached, undefined) - : Effect.void, - ), - Stream.filter( - ( - event, - ): event is Extract => - event.type === "turn.terminal", - ), - Stream.take(3), - Stream.runCollect, - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - yield* Deferred.await(attached); - const interrupt = yield* runtime - .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("11 seconds"); - yield* Fiber.join(interrupt); - yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread }); - gate.release("drop"); - yield* Deferred.await(offered); - yield* runtime.startTurn({ - ...withLineage(thread), - runId: RunId.make("run:opencode2-adapter:3"), - runOrdinal: 3, - providerTurnOrdinal: 3, - attemptId: RunAttemptId.make("attempt:opencode2-adapter:3"), - }); - assert.deepEqual( - [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), - ["interrupted", "failed", "completed"], - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - const replyAfterUnclearFailure = (endedFirst: boolean) => Effect.gen(function* () { const offered = @@ -2215,33 +1546,11 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); + // regression: #14744 continuations hung after a reconnect took a Stop's end it.effect("ends a background reply's continuation after a prompt failed unclearly", () => replyAfterUnclearFailure(false), ); - it.effect("ends a finished background reply's continuation after a prompt failed unclearly", () => - replyAfterUnclearFailure(true), - ); - - it.effect("does not report a Stop the server says did nothing", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - out("session.interrupt", { sessionID: SESSION }), - reply("session.interrupt", { interrupted: false }), - ]); - yield* runtime.startTurn(turnInput(thread)); - const failed = yield* runtime - .interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - }) - .pipe(Effect.flip); - assert.equal(failed._tag, "ProviderAdapterProtocolError"); - }).pipe(Effect.scoped), - ); - it.effect("reports a turn as completed when its Stop request failed", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -2264,18 +1573,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("refuses to resume a thread without an OpenCode session as a protocol error", () => - Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime(opening); - const failed = yield* runtime - .resumeThread({ - providerThread: { ...providerThread(yield* DateTime.now), nativeThreadRef: null }, - }) - .pipe(Effect.flip); - assert.equal(failed._tag, "ProviderAdapterProtocolError"); - }).pipe(Effect.scoped), - ); - it.effect("gives a resumed session T3's rules when it was made with others", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntimeWithInstructions([ @@ -2323,28 +1620,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect( - "moves the session when a thread it resumes through ensureThread changed worktree", - () => - Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntimeWithInstructions([ - ...opening, - ...directoryModels("/work/opencode2-feature"), - out("session.get", { sessionID: SESSION }), - replyData("session.get", sessionInfo()), - ...noOpenRequests, - out("session.move", { sessionID: SESSION, directory: "/work/opencode2-feature" }), - reply("session.move", null), - ]); - yield* runtime.ensureThread({ - threadId, - modelSelection: bigPickle, - runtimePolicy: { ...policy(), cwd: "/work/opencode2-feature" }, - existingProviderThread: providerThread(yield* DateTime.now), - }); - }).pipe(Effect.scoped), - ); - it.effect("breaks the thread and forgets it when the session was deleted outside T3", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -2363,35 +1638,9 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { const ended = yield* Fiber.join(terminal); assert.equal(ended?.status, "failed"); assert.equal(ended?.threadDisposition, "broken"); - const again = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip); - assert.equal(again._tag, "ProviderAdapterProtocolError"); - assert.include(again.message, "not registered"); - }).pipe(Effect.scoped), - ); - - it.effect("refuses a model slug that is not provider/model before creating a session", () => - Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([...opening]); - const created = yield* runtime - .ensureThread({ - threadId, - modelSelection: { instanceId, model: "big-pickle" }, - runtimePolicy: policy(), - }) - .pipe(Effect.flip); - assert.equal(created._tag, "ProviderAdapterProtocolError"); - assert.include(created.message, "OpenCode model 'big-pickle' must use provider/model format"); - }).pipe(Effect.scoped), - ); - - it.effect("refuses a turn whose model slug is not provider/model before prompting", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread, { instanceId, model: "big-pickle" })); - const refused = yield* Fiber.join(terminal); - assert.equal(refused?.failure?.class, "validation_error"); - assert.include(refused?.failure?.message, "must use provider/model format"); + const again = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip); + assert.equal(again._tag, "ProviderAdapterProtocolError"); + assert.include(again.message, "not registered"); }).pipe(Effect.scoped), ); @@ -2592,254 +1841,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - const subagentAsks = (child: string): ReadonlyArray => { - const call = "call-subagent"; - const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: call }; - return [ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...tool, name: "subagent" }), - event("session.tool.called", { - ...tool, - name: "subagent", - input: { description: "Echo", prompt: "echo" }, - executed: false, - }), - event("session.created", { ...childCreated(child), title: "Echo" }), - event("session.tool.progress", { - ...tool, - metadata: { sessionID: child, status: "running" }, - }), - event("session.execution.started", { sessionID: child }), - event("permission.asked", { ...shellAsk.data, sessionID: child }), - ]; - }; - - it.effect("keeps a subagent's 'allow this session' in the subagent's own rules", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed( - [ - ...subagentAsks(CHILD), - out("session.update", { - sessionID: CHILD, - permissions: [ - ...supervisedRules.slice(0, 3), - { action: "shell", resource: "echo *", effect: "allow" }, - ...mcpRules, - ], - }), - reply("session.update", null), - out("permission.reply", { - sessionID: CHILD, - requestID: shellAsk.data.id, - decision: "once", - }), - reply("permission.reply", null), - event("session.execution.succeeded", { sessionID: CHILD }), - event("session.execution.succeeded", { sessionID: SESSION }), - ], - { supervised: true }, - ); - const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn({ - ...withLineage(thread), - runtimePolicy: policy("approval-required"), - }); - const request = yield* Fiber.join(requested); - yield* runtime.respondToRuntimeRequest({ - requestId: request!.id, - decision: "acceptForSession", - }); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - }).pipe(Effect.scoped), - ); - - it.effect("stops the subagent whose request's answer cannot be delivered", () => - Effect.gen(function* () { - const replyOut = out("permission.reply", { - sessionID: CHILD, - requestID: shellAsk.data.id, - decision: "once", - }); - const failedReply = reply("permission.reply", { - status: 500, - body: { _tag: "UnknownError", message: "reply failed" }, - }); - const { runtime, thread } = yield* resumed( - [ - ...subagentAsks(CHILD), - replyOut, - failedReply, - replyOut, - failedReply, - out("session.interrupt", { sessionID: CHILD }), - reply("session.interrupt", { interrupted: true }), - ], - { supervised: true }, - ); - const requested = yield* requestOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn({ - ...withLineage(thread), - runtimePolicy: policy("approval-required"), - }); - const request = yield* Fiber.join(requested); - yield* runtime.respondToRuntimeRequest({ requestId: request!.id, decision: "accept" }); - }).pipe(Effect.scoped), - ); - - it.effect("stops the subagent whose form T3 cannot show or decline", () => - Effect.gen(function* () { - const linkForm = { - id: "frm_0eb79ab35001fkvFECSh3wYNVD", - sessionID: CHILD, - title: "MCP authorization", - metadata: { kind: "mcp" }, - fields: [{ key: "authorization", type: "external", url: "https://example.com/authorize" }], - }; - const cancelOut = out("session.form.cancel", { sessionID: CHILD, formID: linkForm.id }); - const failedCancel = reply("session.form.cancel", { - status: 500, - body: { _tag: "UnknownError", message: "cancel failed" }, - }); - const { runtime, thread } = yield* resumed([ - ...subagentAsks(CHILD).slice(0, -1), - event("form.created", { form: linkForm }), - cancelOut, - failedCancel, - cancelOut, - failedCancel, - out("session.interrupt", { sessionID: CHILD }), - reply("session.interrupt", { interrupted: true }), - event("session.execution.interrupted", { sessionID: CHILD }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(withLineage(thread)); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - }).pipe(Effect.scoped), - ); - - it.effect("ends only the subagent's turn when it declines the subagent's form", () => - Effect.gen(function* () { - const linkForm = { - id: "frm_0eb79ab35001fkvFECSh3wYNVD", - sessionID: CHILD, - title: "MCP authorization", - metadata: { kind: "mcp" }, - fields: [{ key: "authorization", type: "external", url: "https://example.com/authorize" }], - }; - const { runtime, thread } = yield* resumed([ - ...subagentAsks(CHILD).slice(0, -1), - event("form.created", { form: linkForm }), - out("session.form.cancel", { sessionID: CHILD, formID: linkForm.id }), - reply("session.form.cancel", null), - event("session.execution.interrupted", { sessionID: CHILD }), - event("session.tool.failed", { - sessionID: SESSION, - assistantMessageID: "msg_assistant", - id: "call-subagent", - error: { type: "unknown", message: `Subagent cancelled (sessionID: ${CHILD})` }, - executed: true, - }), - event("session.text.ended", { - sessionID: SESSION, - assistantMessageID: "msg_assistant_after", - ordinal: 0, - text: "The subagent could not finish.", - }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(withLineage(thread)); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - }).pipe(Effect.scoped), - ); - - it.effect("gives a resumed subagent the rules of the thread's current mode", () => - Effect.gen(function* () { - const call = "call-resume"; - const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant_2", id: call }; - const { runtime, thread } = yield* resumed([ - ...subagentAsks(CHILD).slice(0, -1), - event("session.execution.succeeded", { sessionID: CHILD }), - event("session.tool.success", { - sessionID: SESSION, - assistantMessageID: "msg_assistant", - id: "call-subagent", - content: [{ type: "text", text: "done" }], - metadata: { sessionID: CHILD, status: "completed" }, - }), - event("session.execution.succeeded", { sessionID: SESSION }), - out("agent.list", ""), - reply("agent.list", agentList), - out("session.update", { sessionID: SESSION, permissions: supervisedRules }), - reply("session.update", null), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...tool, name: "subagent" }), - event("session.tool.called", { - ...tool, - name: "subagent", - input: { description: "Echo", prompt: "again", sessionID: CHILD }, - executed: false, - }), - event("session.tool.progress", { - ...tool, - metadata: { sessionID: CHILD, status: "running" }, - }), - out("session.update", { - sessionID: CHILD, - permissions: [...supervisedRules.slice(0, 3), ...mcpRules], - }), - reply("session.update", null), - event("session.execution.started", { sessionID: CHILD }), - event("session.execution.succeeded", { sessionID: CHILD }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const firstEnded = yield* Deferred.make(); - const childTurns = new Set(); - const ended = yield* runtime.events.pipe( - Stream.tap((event) => - Effect.sync(() => { - const nativeId = - event.type === "provider_turn.updated" - ? event.providerTurn.nativeTurnRef?.nativeId - : undefined; - if (nativeId?.startsWith(`${CHILD}:turn:`) === true) childTurns.add(nativeId); - }), - ), - Stream.filter( - ( - event, - ): event is Extract => - event.type === "turn.terminal", - ), - Stream.tap(() => Deferred.succeed(firstEnded, undefined)), - Stream.take(2), - Stream.runCollect, - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - yield* Deferred.await(firstEnded); - yield* runtime.startTurn({ - ...withLineage(thread), - runId: RunId.make("run:opencode2-adapter:2"), - runOrdinal: 2, - providerTurnOrdinal: 2, - attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), - runtimePolicy: policy("approval-required"), - }); - assert.deepEqual( - [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), - ["completed", "completed"], - ); - assert.deepEqual([...childTurns], [`${CHILD}:turn:1`, `${CHILD}:turn:2`]); - }).pipe(Effect.scoped), - ); - it.effect("keeps 'allow this session' in the session's rules, not OpenCode's saved grants", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed( @@ -2906,66 +1907,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { assert.equal((yield* Fiber.join(created)).nativeThreadRef?.nativeId, SESSION); }).pipe(Effect.scoped), ); - - it.effect("refuses a turn once reconnecting to a lost event stream has given up", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([{ type: "runtime_exit", status: "success" }]); - const drained = yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped); - yield* TestClock.adjust("1 minute"); - yield* Fiber.join(drained); - const refused = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.flip); - assert.equal(refused._tag, "ProviderAdapterEventStreamError"); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.effect("logs why reconnecting to a lost event stream gave up", () => { - const logs: Array<{ - readonly message: unknown; - readonly cause: Cause.Cause; - readonly annotations: Readonly>; - }> = []; - const logger = Logger.make(({ fiber, message, cause }) => { - logs.push({ message, cause, annotations: fiber.getRef(References.CurrentLogAnnotations) }); - }); - return Effect.gen(function* () { - const { runtime } = yield* resumed([{ type: "runtime_exit", status: "success" }]); - const drained = yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped); - yield* TestClock.adjust("1 minute"); - yield* Fiber.join(drained); - const gaveUp = logs.find((entry) => - JSON.stringify(entry.message).includes("Could not reconnect to the OpenCode event stream"), - ); - assert.isDefined(gaveUp); - assert.isTrue(gaveUp !== undefined && gaveUp.cause.reasons.length > 0); - assert.isString(gaveUp?.annotations.errorTag); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.merge(TestClock.layer(), Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }); - - it.effect("fails the session once reconnecting to a lost event stream has given up", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - { type: "runtime_exit", status: "success" }, - ]); - const events = yield* runtime.events.pipe(Stream.runCollect, Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread)); - yield* TestClock.adjust("1 minute"); - const collected = yield* Fiber.join(events); - const terminal = collected.find((event) => event.type === "turn.terminal"); - assert.deepInclude(terminal, { status: "failed", threadDisposition: "broken" }); - const last = collected.findLast((event) => event.type === "provider_session.updated"); - assert.equal( - last?.type === "provider_session.updated" ? last.providerSession.status : undefined, - "error", - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); const reconnected = ( active: Record, ended?: "succeeded", @@ -3159,93 +2100,9 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ? [event.turnItem.text] : [], ); - assert.include(texts, "HELLO WORLD"); - assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); - }).pipe(Effect.scoped), - ); - - it.effect( - "backfills and ends a subagent's turn whose execution ended while the stream was down", - () => - Effect.gen(function* () { - const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-sub" }; - const SUB_PROMPT = "msg_0f73f91e0002lgRNb7FtMzcoQu"; - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...tool, name: "subagent" }), - event("session.tool.called", { - ...tool, - name: "subagent", - input: { description: "Sleep", prompt: "sleep" }, - executed: false, - }), - event("session.created", childCreated(CHILD)), - event("session.tool.progress", { - ...tool, - metadata: { sessionID: CHILD, status: "running" }, - }), - event("session.inbox.enqueued", { - inboxID: SUB_PROMPT, - sessionID: CHILD, - item: { type: "user", payload: { text: "sleep" }, delivery: "steer" }, - }), - event("session.execution.started", { sessionID: CHILD }), - { type: "runtime_exit", status: "success" } as const, - out("event.subscribe"), - out("session.active"), - replyData("session.active", { [SESSION]: { type: "running" } }), - out("message.list", { sessionID: CHILD, order: "desc", limit: "50" }), - reply("message.list", { - data: [ - { id: "msg_idle_sub", time: { created: 4 }, type: "idle", outcome: "succeeded" }, - { - id: "msg_assistant_sub", - time: { created: 3 }, - type: "assistant", - agent: "general", - model: { id: "big-pickle", providerID: "opencode", variant: "default" }, - content: [{ type: "text", text: "Slept while the stream was down." }], - finish: "stop", - }, - { id: SUB_PROMPT, time: { created: 2 }, text: "sleep", type: "user" }, - ], - cursor: {}, - }), - out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }), - reply("message.list", { - data: [{ id: PROMPT_ID, time: { created: 1 }, text: "hi", type: "user" }], - cursor: {}, - }), - ...openRequests([]), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const events = yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - const collected = yield* Fiber.join(events); - const childTurn = collected.findLast( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === `${CHILD}:turn:1`, - ); - assert.isTrue( - collected.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === "Slept while the stream was down.", - ), - ); - assert.equal( - childTurn?.type === "provider_turn.updated" ? childTurn.providerTurn.status : undefined, - "completed", - ); - }).pipe(Effect.scoped), + assert.include(texts, "HELLO WORLD"); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), ); it.effect("keeps a turn still running after a reconnect open for its next events", () => @@ -3383,47 +2240,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect( - "registers T3's MCP server for the thread alone and removes it when the thread unloads", - () => - Effect.gen(function* () { - const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - yield* mcpSessions.set({ - environmentId: EnvironmentId.make("environment:opencode2-adapter"), - threadId, - providerSessionId: "mcp:opencode2-adapter", - providerInstanceId: instanceId, - endpoint: "http://127.0.0.1:3773/mcp", - authorizationHeader: "Bearer thread-credential", - browserToolsAvailable: false, - }); - yield* Effect.addFinalizer(() => mcpSessions.clear(threadId)); - const server = "t3-code-thread_opencode2-adapter"; - const { runtime, thread } = yield* resumed([ - out("mcp.add", { - server, - "location[directory]": WORK, - config: { - type: "remote", - url: "http://127.0.0.1:3773/mcp", - headers: { Authorization: "Bearer thread-credential" }, - oauth: false, - }, - }), - reply("mcp.add", null), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.succeeded", { sessionID: SESSION }), - out("mcp.remove", { server, "location[directory]": WORK }), - reply("mcp.remove", null), - ]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread)); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - yield* runtime.unloadThread!({ providerThread: thread }); - }).pipe(Effect.scoped), - ); - it.effect("re-registers T3 MCP each turn and drops its instructions when adding fails", () => Effect.gen(function* () { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; @@ -3539,6 +2355,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + // regression: #16142 OpenCode 2 threads were missing T3 Code's MCP tools it.effect("names each thread's MCP server within OpenCode's limits, one name per thread", () => Effect.gen(function* () { const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258"; @@ -4000,10 +2817,10 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + // regression: #15672 OpenCode 2 provider retries ran silently instead of showing it.effect.each([ { name: "restores retry pending at reconnect", pending: true, streamed: false }, { name: "settles retry recovered during drop", pending: false, streamed: false }, - { name: "keeps retry pending when only streamed at reconnect", pending: true, streamed: true }, ])("$name", ({ pending, streamed }) => Effect.gen(function* () { const gate = makeProviderReplayGate(["finish"]); @@ -4153,13 +2970,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("reports a model's input limit as its context window", () => - Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([...opening]); - assert.equal(runtime.getModelContextWindow?.(bigPickle), 160000); - }).pipe(Effect.scoped), - ); - it.effect("reports each directory's own limit for a model its project config changed", () => Effect.gen(function* () { const custom = "/work/opencode2-custom"; @@ -4453,6 +3263,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), ); + // regression: #16775 unchanged session status updates were rewritten on every event it.effect("reports the shared session's status only when it changes", () => Effect.gen(function* () { const OTHER = "ses_f148ca2deffeOtherSession000"; @@ -4519,7 +3330,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect.each([ ["refuses a rollback while a timed-out Stop's run is still going", true], - ["rolls back once a timed-out Stop's run has left the server", false], ] as const)("%s", ([, running]) => Effect.gen(function* () { const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`; @@ -4709,12 +3519,14 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }), ]); const turns: Array = []; + const firstEnded = yield* Deferred.make(); const bothEnded = yield* Deferred.make(); yield* runtime.events.pipe( Stream.tap((event) => Effect.gen(function* () { if (event.type === "provider_turn.updated" && event.providerTurn.status !== "running") { turns.push(event.providerTurn); + if (turns.length === 1) yield* Deferred.succeed(firstEnded, undefined); if (turns.length === 2) yield* Deferred.succeed(bothEnded, undefined); } }), @@ -4723,9 +3535,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { Effect.forkScoped, ); yield* runtime.startTurn(turnInput(thread)); - yield* Effect.gen(function* () { - while (turns.length === 0) yield* Effect.yieldNow; - }); + yield* Deferred.await(firstEnded); yield* runtime.startTurn(secondTurn(thread)); yield* Deferred.await(bothEnded); const [first, second] = turns; @@ -4798,7 +3608,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ); it.effect.each([ - ["refuses to roll back a session not loaded yet while the server runs it", true], [ "keeps the recorded turns before the target when rolling back a session not loaded yet", false, @@ -4904,91 +3713,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("asks the server before forking a session this runtime has not loaded", () => - Effect.gen(function* () { - const runtime = yield* openCode2ReplayRuntime([ - ...opening, - out("session.active"), - reply("session.active", { data: { [SESSION]: { type: "running" } } }), - ]); - const refused = yield* runtime - .forkThread({ - sourceProviderThread: providerThread(yield* DateTime.now), - targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"), - }) - .pipe(Effect.flip); - assert.equal(refused._tag, "ProviderAdapterProtocolError"); - }).pipe(Effect.scoped), - ); - - it.effect("lets the next turn start when a rollback's stage never answers", () => - Effect.gen(function* () { - const prompted = `msg_t3_turn_${SESSION}:attempt:earlier`; - const { runtime, thread } = yield* resumed([ - out("message.list", ""), - reply("message.list", { - data: [{ id: prompted, time: { created: 1 }, text: "earlier", type: "user" }], - cursor: {}, - }), - out("session.revert.stage", { sessionID: SESSION, messageID: prompted, files: false }), - reply("session.revert.stage", ""), - out("session.revert.clear", { sessionID: SESSION }), - reply("session.revert.clear", null), - event("session.execution.started", { sessionID: SESSION }), - event("session.execution.succeeded", { sessionID: SESSION }), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const now = yield* DateTime.now; - const earlier = { - id: ProviderTurnId.make("provider-turn:earlier"), - providerThreadId: thread.id, - nodeId: NodeId.make("node:earlier"), - runAttemptId: RunAttemptId.make("attempt:earlier"), - nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId: prompted, strength: "weak" as const }, - ordinal: 1, - status: "completed" as const, - startedAt: now, - completedAt: now, - }; - const rollback = yield* runtime - .rollbackThread({ - providerThread: thread, - target: { - type: "thread_start", - checkpointId: CheckpointId.make("checkpoint:start"), - appRunOrdinal: 0, - }, - providerThreadTurns: [earlier], - }) - .pipe(Effect.exit, Effect.forkScoped); - yield* TestClock.adjust("11 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(rollback))); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread)); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.effect("refuses to fork a session while its turn runs", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - ]); - yield* runtime.startTurn(turnInput(thread)); - const refused = yield* runtime - .forkThread({ - sourceProviderThread: thread, - targetThreadId: ThreadId.make("thread:opencode2-adapter:fork"), - }) - .pipe(Effect.flip); - assert.equal(refused._tag, "ProviderAdapterProtocolError"); - }).pipe(Effect.scoped), - ); - it.effect("moves a fork into its target thread's worktree before the first prompt", () => Effect.gen(function* () { const FORK = "ses_f1484db83ffeLGtrRCFimo1H0e"; @@ -5021,6 +3745,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { const continued = (after: ReadonlyArray) => Effect.gen(function* () { const offers: Array = []; + const offered = yield* Deferred.make(); const { runtime, thread } = yield* resumed([ ...backgroundLaunch(CHILD), event("session.execution.succeeded", { sessionID: SESSION }), @@ -5055,7 +3780,11 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ...after, ]).pipe( Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: (request) => Effect.sync(() => void offers.push(request)), + offer: (request) => + Effect.sync(() => void offers.push(request)).pipe( + Effect.andThen(Deferred.succeed(offered, undefined)), + Effect.asVoid, + ), take: Effect.never, }), ); @@ -5074,9 +3803,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { Effect.forkScoped, ); yield* runtime.startTurn(withLineage(thread)); - yield* Effect.gen(function* () { - while (offers.length === 0) yield* Effect.yieldNow; - }).pipe(Effect.timeout("2 seconds"), Effect.orDie); + yield* Deferred.await(offered); yield* runtime.startTurn(continuationTurn(thread, offers[0]!)); yield* Deferred.await(bothEnded); const [first, second] = turns; @@ -5113,35 +3840,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("rolls back before a continuation's report, so its answer leaves the history", () => - Effect.gen(function* () { - const { runtime, thread, first, second } = yield* continued([ - out("message.list", ""), - reply("message.list", continuedHistory), - out("session.revert.stage", { sessionID: SESSION, messageID: "msg_report", files: false }), - replyData("session.revert.stage", { messageID: "msg_report", files: [] }), - out("session.revert.commit", { sessionID: SESSION }), - reply("session.revert.commit", null), - out("message.list", ""), - reply("message.list", { data: [continuedHistory.data[1]], cursor: {} }), - ]); - const snapshot = yield* runtime.rollbackThread({ - providerThread: thread, - target: { - type: "provider_turn", - checkpointId: CheckpointId.make("checkpoint:first"), - appRunOrdinal: 1, - providerTurn: first, - }, - providerThreadTurns: [first, second], - }); - assert.deepEqual( - snapshot.providerTurns.map((turn) => turn.id), - [first.id], - ); - }).pipe(Effect.scoped), - ); - it.effect("keeps a subagent a stopped background reply started off the next turn", () => Effect.gen(function* () { const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; @@ -5329,144 +4027,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect( - "keeps every subagent a stopped background reply's subagent started off the next turn", - () => - Effect.gen(function* () { - const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; - const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD"; - const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN"; - const DEEP = "ses_f1485c529ffeEEEEEEEEEEEEEE"; - const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" }; - const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" }; - const { runtime, thread } = yield* resumed([ - ...backgroundLaunch(CHILD), - event("session.tool.input.started", { ...toolB, name: "subagent" }), - event("session.tool.called", { - ...toolB, - name: "subagent", - input: { description: "Sleep", prompt: "sleep", background: true }, - executed: false, - }), - event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }), - event("session.tool.progress", { - ...toolB, - metadata: { sessionID: CHILD_B, status: "running" }, - }), - event("session.execution.succeeded", { sessionID: SESSION }), - event("session.execution.started", { sessionID: CHILD_B }), - event("session.execution.succeeded", { sessionID: CHILD }), - event("session.inbox.enqueued", { - inboxID: "msg_report", - sessionID: SESSION, - item: { - type: "synthetic", - payload: { - text: `\nCHILD_OK\n`, - description: "Sleep", - metadata: { - source: "subagent", - childID: CHILD, - agent: "General", - state: "completed", - }, - }, - delivery: "steer", - }, - }), - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...spawn, name: "subagent" }), - event("session.tool.called", { - ...spawn, - name: "subagent", - input: { description: "Sleep", prompt: "sleep", background: true }, - executed: false, - }), - event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }), - event("session.tool.progress", { - ...spawn, - metadata: { sessionID: SPAWNED, status: "running" }, - }), - event("session.created", { - ...childCreated(NESTED), - sessionID: NESTED, - parentID: SPAWNED, - }), - event("session.created", { ...childCreated(DEEP), sessionID: DEEP, parentID: NESTED }), - event("session.execution.succeeded", { sessionID: SESSION }), - event("session.text.ended", { - sessionID: CHILD_B, - assistantMessageID: "msg_child_b", - ordinal: 0, - text: "B_WORKING", - }), - out("session.interrupt", { sessionID: CHILD_B }), - reply("session.interrupt", { interrupted: true }), - event("session.execution.interrupted", { sessionID: CHILD_B }), - out("session.interrupt", { sessionID: SPAWNED }), - reply("session.interrupt", { interrupted: true }), - out("session.interrupt", { sessionID: NESTED }), - reply("session.interrupt", { interrupted: true }), - out("session.interrupt", { sessionID: DEEP }), - reply("session.interrupt", { - status: 500, - body: { _tag: "UnknownError", message: "interrupt failed" }, - }), - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("permission.asked", { ...shellAsk.data, id: "per_deep", sessionID: DEEP }), - out("permission.reply", { sessionID: DEEP, requestID: "per_deep", decision: "reject" }), - reply("permission.reply", null), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const collected: Array = []; - const replyEnded = yield* Deferred.make(); - const bothEnded = yield* Deferred.make(); - yield* runtime.events.pipe( - Stream.tap((event) => - Effect.gen(function* () { - collected.push(event); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === "B_WORKING" - ) { - yield* Deferred.succeed(replyEnded, undefined); - } - if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { - yield* Deferred.succeed(bothEnded, undefined); - } - }), - ), - Stream.runDrain, - Effect.forkScoped, - ); - yield* runtime.startTurn(withLineage(thread)); - yield* Deferred.await(replyEnded); - yield* runtime.interruptTurn({ - providerThread: thread, - providerTurnId: yield* providerTurnId, - requestRuntimeRestart: true, - }); - yield* runtime.startTurn({ - ...secondTurn(thread), - appThread: withLineage(thread).appThread, - }); - yield* Deferred.await(bothEnded); - assert.deepEqual( - collected.flatMap((event) => - event.type === "runtime_request.updated" - ? [ - `${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`, - ] - : [], - ), - [], - ); - }).pipe(Effect.scoped), - ); - + // regression: #14752 continuations replayed background replies that were not theirs it.effect("ends a continuation while a skipped reply's subagent does not answer its Stop", () => Effect.gen(function* () { const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB"; @@ -5661,6 +4222,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { const reportText = `\nCHILD_OK\n`; const steerId = `msg_t3_steer_${SESSION}:message:opencode2-adapter:steer`; const offers: Array = []; + const offered = yield* Deferred.make(); const { runtime, thread } = yield* resumed([ ...backgroundLaunch(CHILD), event("session.execution.succeeded", { sessionID: SESSION }), @@ -5709,7 +4271,11 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { event("session.execution.succeeded", { sessionID: SESSION }), ]).pipe( Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: (request) => Effect.sync(() => void offers.push(request)), + offer: (request) => + Effect.sync(() => void offers.push(request)).pipe( + Effect.andThen(Deferred.succeed(offered, undefined)), + Effect.asVoid, + ), take: Effect.never, }), ); @@ -5727,9 +4293,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { Effect.forkScoped, ); yield* runtime.startTurn(withLineage(thread)); - yield* Effect.gen(function* () { - while (offers.length === 0) yield* Effect.yieldNow; - }).pipe(Effect.timeout("2 seconds"), Effect.orDie); + yield* Deferred.await(offered); const followUp = continuationTurn(thread, offers[0]!); yield* runtime.startTurn(followUp); const followUpTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ @@ -5755,30 +4319,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ); }); -describe("OpenCode2 adapter server connection", () => { - it.live("returns the server it reconnected to once the session closes", () => - Effect.gen(function* () { - // A spawned server stops after it has no borrowers for a while, so a - // session must not keep holding the connection it reconnected with. - const borrowers = { current: 0 }; - yield* Effect.gen(function* () { - yield* openCode2ReplayRuntime( - [ - ...opening, - { type: "runtime_exit", status: "success" }, - out("event.subscribe"), - event("server.connected", {}), - ], - { borrowers }, - ); - yield* Effect.sleep("200 millis"); - assert.equal(borrowers.current, 1); - }).pipe(Effect.scoped); - assert.equal(borrowers.current, 0); - }).pipe(Effect.provide(McpProviderSessions.layer)), - ); -}); - const providerTurnId = Effect.gen(function* () { const ids = yield* IdAllocator.IdAllocatorV2; return ids.derive.providerTurn({ @@ -5786,44 +4326,3 @@ const providerTurnId = Effect.gen(function* () { nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, }); }).pipe(Effect.provide(IdAllocator.layer)); - -it.layer(McpProviderSessions.layer)("OpenCode reported model variants", (it) => { - it.effect( - "updates reported variants from selected-model and step events without duplicate updates", - () => - Effect.gen(function* () { - const model = { providerID: "opencode", id: "big-pickle", variant: "thinking" }; - const step = { - sessionID: SESSION, - assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", - agent: "build", - model, - started: 1, - }; - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.model.selected", { sessionID: SESSION, model }), - event("session.step.started", step), - event("session.step.started", { ...step, model: { ...model, variant: "none" } }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); - const collected = yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - Effect.forkScoped, - ); - yield* runtime.startTurn(turnInput(thread)); - const updates = (yield* Fiber.join(collected)).filter( - (event) => event.type === "provider_thread.updated", - ); - assert.deepEqual( - updates.map( - (event) => event.providerThread.nativeMetadata?.modelSelection?.options?.[0]?.value, - ), - ["default", "thinking", "none", "none"], - ); - }).pipe(Effect.scoped), - ); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts index c5d5b980092b..baea36ca738d 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts @@ -1,17 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - mergeClaudeWorkflowProgress, - parseClaudeWorkflowRunHandles, -} from "./claudeWorkflowProgress.ts"; - -const startFrame = { - type: "system", - subtype: "task_started", - task_id: "w1", - task_type: "local_workflow", - workflow_name: "probe-wf", -}; +import { mergeClaudeWorkflowProgress } from "./claudeWorkflowProgress.ts"; const snapshotFrame = ( entries: ReadonlyArray>, @@ -38,30 +27,6 @@ const agent = (overrides: Record) => ({ }); describe("mergeClaudeWorkflowProgress", () => { - it("marks a coordinator as a workflow before any member exists", () => { - const workflow = mergeClaudeWorkflowProgress({ - previous: undefined, - name: "probe-wf", - message: startFrame, - }); - expect(workflow).toEqual({ name: "probe-wf", phases: [], agents: [] }); - }); - - it("leaves an ordinary subagent's frames alone", () => { - expect( - mergeClaudeWorkflowProgress({ - previous: undefined, - message: { - type: "system", - subtype: "task_progress", - task_id: "t1", - description: "reading files", - usage: { total_tokens: 500, tool_uses: 2, duration_ms: 90 }, - }, - }), - ).toBeUndefined(); - }); - it("maps provider agent states and per-member metrics", () => { const workflow = mergeClaudeWorkflowProgress({ previous: undefined, @@ -140,42 +105,6 @@ describe("mergeClaudeWorkflowProgress", () => { expect(second?.totalTokens).toBe(250); }); - it("harvests run handles from the launch acknowledgement", () => { - const workflow = mergeClaudeWorkflowProgress({ - previous: undefined, - runHandles: parseClaudeWorkflowRunHandles({ - status: "async_launched", - taskType: "local_workflow", - runId: "wf_abc", - transcriptDir: "/root/.claude/projects/p/s/subagents/workflows/wf_abc", - scriptPath: "/root/.claude/projects/p/s/workflows/scripts/x.js", - }), - message: startFrame, - }); - expect(workflow?.runHandles?.transcriptDir).toBe( - "/root/.claude/projects/p/s/subagents/workflows/wf_abc", - ); - expect(workflow?.runHandles?.runId).toBe("wf_abc"); - }); - - it("ignores a non-workflow tool acknowledgement", () => { - expect( - parseClaudeWorkflowRunHandles({ status: "async_launched", taskType: "local_agent" }), - ).toBeUndefined(); - }); - - it("keeps a phase the plan reached at runtime even if a later frame omits it", () => { - const first = mergeClaudeWorkflowProgress({ - previous: undefined, - message: snapshotFrame([phase(1, "Alpha"), phase(2, "Beta"), phase(3, "Gamma")]), - }); - const second = mergeClaudeWorkflowProgress({ - previous: first, - message: snapshotFrame([phase(1, "Alpha")]), - }); - expect(second?.phases.map((entry) => entry.title)).toEqual(["Alpha", "Beta", "Gamma"]); - }); - it("keeps other members and phase links when a sparse frame starts a new attempt", () => { const first = mergeClaudeWorkflowProgress({ previous: undefined, @@ -250,73 +179,6 @@ describe("mergeClaudeWorkflowProgress", () => { expect(second?.agents).toEqual(first?.agents); }); - it("retains member fields omitted by a later frame in the same attempt", () => { - const first = mergeClaudeWorkflowProgress({ - previous: undefined, - message: snapshotFrame([agent({ attempt: 2, promptPreview: "Current prompt", tokens: 100 })]), - }); - const second = mergeClaudeWorkflowProgress({ - previous: first, - message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]), - }); - expect(second?.agents[0]).toMatchObject({ - state: "completed", - attempt: 2, - prompt: "Current prompt", - result: "Current result", - totalTokens: 100, - }); - }); - - it("keeps existing run handles when a later acknowledgement supplies only a script path", () => { - const first = mergeClaudeWorkflowProgress({ - previous: undefined, - runHandles: { runId: "wf_abc", transcriptDir: "/workflows/wf_abc" }, - message: startFrame, - }); - const second = mergeClaudeWorkflowProgress({ - previous: first, - runHandles: { scriptPath: "/scripts/workflow.js" }, - message: startFrame, - }); - expect(second?.runHandles).toEqual({ - runId: "wf_abc", - transcriptDir: "/workflows/wf_abc", - scriptPath: "/scripts/workflow.js", - }); - }); - - it("preserves prompt and result whitespace", () => { - const workflow = mergeClaudeWorkflowProgress({ - previous: undefined, - message: snapshotFrame([ - agent({ promptPreview: " indented\n", resultPreview: "\n answer\n" }), - ]), - }); - expect(workflow?.agents[0]?.prompt).toBe(" indented\n"); - expect(workflow?.agents[0]?.result).toBe("\n answer\n"); - }); - - it("drops timestamps outside the DateTime range", () => { - const workflow = mergeClaudeWorkflowProgress({ - previous: undefined, - message: snapshotFrame([ - agent({ - state: "done", - lastProgressAt: 8_640_000_000_000_001, - startedAt: 8_640_000_000_000_001, - queuedAt: Number.MAX_SAFE_INTEGER, - }), - agent({ index: 2, startedAt: 8_640_000_000_000_000, queuedAt: 0 }), - ]), - }); - expect(workflow?.agents[0]).not.toHaveProperty("startedAt"); - expect(workflow?.agents[0]).not.toHaveProperty("queuedAt"); - expect(workflow?.agents[0]).not.toHaveProperty("completedAt"); - expect(workflow?.agents[1]?.startedAt).toBe(8_640_000_000_000_000); - expect(workflow?.agents[1]?.queuedAt).toBe(0); - }); - it("drops malformed entries instead of failing the frame", () => { const workflow = mergeClaudeWorkflowProgress({ previous: undefined, diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts index cddcbd8013f4..324ff6b1fb8d 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts @@ -17,11 +17,7 @@ import { parseThreadSegmentFromAttachmentId, } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; -import { - attachmentIsPendingUpload, - claimPendingAttachments, - releaseClaimedAttachments, -} from "./AttachmentClaims.ts"; +import { attachmentIsPendingUpload, claimPendingAttachments } from "./AttachmentClaims.ts"; const layerTest = Layer.mergeAll(NodeServices.layer).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-claims-" })), @@ -50,21 +46,6 @@ const stagePendingUpload = Effect.fn("test.stagePendingUpload")(function* (input }); describe("AttachmentClaims", () => { - it.effect("accepts 100 existing files without claiming new copies", () => - Effect.gen(function* () { - const attachments: ChatAttachment[] = Array.from({ length: 100 }, (_, index) => ({ - type: "file", - id: ChatAttachmentId.make(`existing-file-${index}`), - name: `${index}.txt`, - mimeType: "text/plain", - sizeBytes: 1, - })); - const claimed = yield* claimPendingAttachments({ threadId: "thread-many", attachments }); - expect(claimed.attachments).toEqual(attachments); - expect(claimed.claimedPaths).toEqual([]); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("rejects oversized image batches before copying a pending upload", () => Effect.gen(function* () { const pending = yield* stagePendingUpload({ @@ -119,40 +100,6 @@ describe("AttachmentClaims", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("passes already-claimed attachments through untouched", () => - Effect.gen(function* () { - const stored: ChatAttachment = { - type: "image", - id: ChatAttachmentId.make("thread-claims-2-00000000-0000-4000-8000-000000000001"), - name: "existing.png", - mimeType: "image/png", - sizeBytes: 4, - }; - const claimed = yield* claimPendingAttachments({ - threadId: "thread-claims-2", - attachments: [stored], - }); - expect(claimed.attachments[0]).toBe(stored); - expect(claimed.claimedPaths).toHaveLength(0); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("rejects a pending ref whose staged file is missing", () => - Effect.gen(function* () { - const missing: ChatAttachment = { - type: "image", - id: ChatAttachmentId.make(createPendingAttachmentId()!), - name: "gone.png", - mimeType: "image/png", - sizeBytes: 4, - }; - const result = yield* Effect.exit( - claimPendingAttachments({ threadId: "thread-claims-3", attachments: [missing] }), - ); - expect(result._tag).toBe("Failure"); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("rejects a size mismatch and cleans up earlier claims from the batch", () => Effect.gen(function* () { const good = yield* stagePendingUpload({ @@ -265,40 +212,4 @@ describe("AttachmentClaims", () => { ).toHaveLength(1); }).pipe(Effect.provide(layerTest)), ); - - it.effect("releaseClaimedAttachments removes claimed copies best-effort", () => - Effect.gen(function* () { - const pending = yield* stagePendingUpload({ - name: "release.png", - bytes: new Uint8Array([9, 9]), - mimeType: "image/png", - }); - const claimed = yield* claimPendingAttachments({ - threadId: "thread-claims-5", - attachments: [pending], - }); - expect(NodeFS.existsSync(claimed.claimedPaths[0]!)).toBe(true); - yield* releaseClaimedAttachments(claimed.claimedPaths); - expect(NodeFS.existsSync(claimed.claimedPaths[0]!)).toBe(false); - yield* releaseClaimedAttachments(claimed.claimedPaths); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("treats malformed ids as non-pending and passes them through", () => - Effect.gen(function* () { - const bogus: ChatAttachment = { - type: "image", - id: ChatAttachmentId.make("pending-not-a-uuid"), - name: "weird.png", - mimeType: "image/png", - sizeBytes: 1, - }; - const claimed = yield* claimPendingAttachments({ - threadId: "thread-claims-6", - attachments: [bogus], - }); - expect(claimed.attachments[0]).toBe(bogus); - expect(claimed.claimedPaths).toHaveLength(0); - }).pipe(Effect.provide(layerTest)), - ); }); diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index 666531762d70..e59ad3da7cd9 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -47,12 +47,7 @@ const stopEarlierBackgroundWork = ({ readonly stopWithQueue?: "thread.stop" | "run.interrupt"; readonly olderStart?: boolean; readonly workflowMember?: boolean; - readonly stalledRun?: - | "missing-session" - | "missing-session-terminal" - | "returned-interrupt" - | "returned-interrupt-terminal" - | "superseded-attempt"; + readonly stalledRun?: "missing-session-terminal" | "returned-interrupt" | "superseded-attempt"; }) => Effect.scoped( Effect.gen(function* () { @@ -235,7 +230,7 @@ const stopEarlierBackgroundWork = ({ const run = before.runs[0]!; const node = before.nodes.find((candidate) => candidate.id === run.rootNodeId)!; const attempt = before.attempts[0]!; - if (stalledRun === "missing-session" || stalledRun === "missing-session-terminal") { + if (stalledRun === "missing-session-terminal") { const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const failed = yield* watch( (event) => event.type === "run.updated" && event.payload.status === "failed", @@ -1003,10 +998,6 @@ it.effect( () => stopEarlierBackgroundWork({ failedStart: true }), ); -it.effect("Stop preserves the roster when background work survives the interrupt", () => - stopEarlierBackgroundWork({ failedStart: true, backgroundWorkAfterStop: "running" }), -); - it.effect("Stop preserves the roster when the background work probe fails", () => stopEarlierBackgroundWork({ failedStart: true, backgroundWorkAfterStop: "unknown" }), ); @@ -1021,12 +1012,7 @@ it.effect.each(["thread.stop", "run.interrupt"] as const)( (stopType) => stopEarlierBackgroundWork({ stopWithQueue: stopType, olderStart: true }), ); -it.effect.each([ - "missing-session", - "missing-session-terminal", - "returned-interrupt", - "returned-interrupt-terminal", - "superseded-attempt", -] as const)("Stop recovers a stalled run after %s without changing a newer attempt", (stalledRun) => - stopEarlierBackgroundWork({ stalledRun }), +it.effect.each(["missing-session-terminal", "returned-interrupt", "superseded-attempt"] as const)( + "Stop recovers a stalled run after %s without changing a newer attempt", + (stalledRun) => stopEarlierBackgroundWork({ stalledRun }), ); diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index 640d5ce2b94a..dfbcd7aa2271 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -14,7 +14,6 @@ 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 { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -32,14 +31,8 @@ import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; it.effect.each([ "nested", "ancestor", - "archived-nested", - ...(symlinksSupported ? (["aliased-nested"] as const) : []), - "project", "provider", - "scope", "sibling", - "stopped-provider", - "errored-provider", "shared-provider", "conversation", ] as const)("preserves overlapping workspace files, owner=%s", (owner) => @@ -54,8 +47,6 @@ it.effect.each([ yield* fs.makeDirectory(sibling); const otherFile = path.join(nested, "other-thread.txt"); yield* fs.writeFileString(otherFile, "other thread's uncommitted work"); - const alias = path.join(parent, "alias"); - if (owner === "aliased-nested") yield* fs.symlink(nested, alias); const threadId = ThreadId.make("restore-current"); const otherId = ThreadId.make("restore-other"); const projectId = ProjectId.make("restore-project"); @@ -75,15 +66,11 @@ it.effect.each([ projectId, deletedAt: null, worktreePath: - owner === "project" - ? null - : owner === "ancestor" || owner === "conversation" - ? parent - : owner === "nested" || owner === "archived-nested" - ? nested - : owner === "aliased-nested" - ? alias - : sibling, + owner === "ancestor" || owner === "conversation" + ? parent + : owner === "nested" + ? nested + : sibling, }; const projection = { thread: { @@ -123,42 +110,34 @@ it.effect.each([ Effect.succeed({ schemaVersion: 1, snapshotSequence: 0, - threads: owner === "archived-nested" ? [] : ([otherThread] as never), - archivedThreads: owner === "archived-nested" ? ([otherThread] as never) : [], + threads: [otherThread] as never, + archivedThreads: [], }), getCheckpointContext: () => Effect.succeed({ runs: [], checkpoints: [], - checkpointScopes: owner === "scope" ? ([{ cwd: nested }] as never) : [], + checkpointScopes: [], }), getThreadProviderContext: () => Effect.succeed({ thread: otherThread as never, providerThreads: [], - providerSessions: [ - "provider", - "stopped-provider", - "errored-provider", - "shared-provider", - ].includes(owner) - ? ([ - { - cwd: owner === "shared-provider" ? cwd : nested, - status: - owner === "provider" || owner === "shared-provider" - ? "running" - : owner === "errored-provider" - ? "error" - : "stopped", - capabilities: { - sessions: { - supportsMultipleProviderThreadsPerSession: owner === "shared-provider", + providerSessions: + owner === "provider" || owner === "shared-provider" + ? ([ + { + cwd: owner === "shared-provider" ? cwd : nested, + status: "running", + capabilities: { + sessions: { + supportsMultipleProviderThreadsPerSession: + owner === "shared-provider", + }, }, }, - }, - ] as never) - : [], + ] as never) + : [], }), }), Layer.mock(CheckpointServiceV2)({ @@ -187,9 +166,7 @@ it.effect.each([ Effect.provide(layerTest), ); const restoreFiles = owner !== "conversation"; - const rejected = !["sibling", "stopped-provider", "shared-provider", "conversation"].includes( - owner, - ); + const rejected = !["sibling", "shared-provider", "conversation"].includes(owner); if (rejected) { const error = yield* service .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index cb11c4ba8f26..149c82452b60 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -26,7 +26,6 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Option from "effect/Option"; -import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Layer from "effect/Layer"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -36,11 +35,9 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts"; -import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; -import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -173,242 +170,8 @@ it.effect.each(["initial", "waiting"])( }, ); -it.effect("rejects a rollback when another provider thread became active", () => { - const threadId = ThreadId.make("thread:rollback-inactive-provider-thread"); - const requestedProviderThreadId = ProviderThreadId.make( - "provider-thread:rollback-inactive-provider-thread:requested", - ); - const activeProviderThreadId = ProviderThreadId.make( - "provider-thread:rollback-inactive-provider-thread:active", - ); - const providerSessionId = ProviderSessionId.make( - "provider-session:rollback-inactive-provider-thread", - ); - const checkpointId = CheckpointId.make("checkpoint:rollback-inactive-provider-thread"); - const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-inactive-provider-thread"); - const providerInstanceId = ProviderInstanceId.make("provider_rollback_inactive_provider_thread"); - const restore = vi.fn(() => Effect.die("checkpoint restore must not run")); - const open = vi.fn(() => Effect.die("provider session open must not run")); - const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run")); - const projection = { - thread: { - worktreePath: null, - activeProviderThreadId, - modelSelection: { instanceId: providerInstanceId, model: "test-model" }, - }, - providerThread: { - id: requestedProviderThreadId, - providerSessionId, - providerInstanceId, - }, - checkpoint: { id: checkpointId, scopeId, status: "ready" }, - scope: { id: scopeId, cwd: process.cwd() }, - } as unknown as ProjectionStore.ProjectionCheckpointRollbackContext; - const layerTest = layerCheckpointRollbackService.pipe( - Layer.provide( - Layer.mergeAll( - ThreadCommandExecutor.layer, - Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), - Layer.mock(EventSink.EventSinkV2)({}), - IdAllocator.layer, - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThread: () => Effect.succeed(projection.thread), - getCheckpointRollbackContext: () => Effect.succeed(projection), - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }), - Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: resolveRuntimePolicy }), - ), - ), - ); - - return Effect.gen(function* () { - const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; - const error = yield* service - .execute({ - threadId, - providerThreadId: requestedProviderThreadId, - checkpointId, - scopeId, - }) - .pipe(Effect.flip); - - assert.equal(error.reason, "active-provider-changed"); - assert.equal( - error.message, - `Active provider changed before rollback target ${checkpointId} could execute on thread ${threadId}.`, - ); - assert.equal(error.cause, undefined); - assert.equal(resolveRuntimePolicy.mock.calls.length, 0); - assert.equal(open.mock.calls.length, 0); - assert.equal(restore.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect("rejects a rollback when provider selection changed before execution", () => { - const threadId = ThreadId.make("thread:rollback-provider-selection-changed"); - const providerThreadId = ProviderThreadId.make( - "provider-thread:rollback-provider-selection-changed", - ); - const providerSessionId = ProviderSessionId.make( - "provider-session:rollback-provider-selection-changed", - ); - const checkpointId = CheckpointId.make("checkpoint:rollback-provider-selection-changed"); - const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-provider-selection-changed"); - const originalProviderInstanceId = ProviderInstanceId.make( - "provider_rollback_provider_selection_changed_original", - ); - const selectedProviderInstanceId = ProviderInstanceId.make( - "provider_rollback_provider_selection_changed_selected", - ); - const restore = vi.fn(() => Effect.die("checkpoint restore must not run")); - const open = vi.fn(() => Effect.die("provider session open must not run")); - const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run")); - const projection = { - thread: { - worktreePath: process.cwd(), - activeProviderThreadId: providerThreadId, - modelSelection: { instanceId: selectedProviderInstanceId, model: "test-model" }, - }, - providerThread: { - id: providerThreadId, - providerSessionId, - providerInstanceId: originalProviderInstanceId, - }, - checkpoint: { id: checkpointId, scopeId, status: "ready" }, - scope: { id: scopeId, cwd: process.cwd() }, - } as unknown as ProjectionStore.ProjectionCheckpointRollbackContext; - const layerTest = layerCheckpointRollbackService.pipe( - Layer.provide( - Layer.mergeAll( - ThreadCommandExecutor.layer, - Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), - Layer.mock(EventSink.EventSinkV2)({}), - IdAllocator.layer, - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThread: () => Effect.succeed(projection.thread), - getCheckpointRollbackContext: () => Effect.succeed(projection), - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }), - Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: resolveRuntimePolicy }), - ), - ), - ); - - return Effect.gen(function* () { - const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; - const error = yield* service - .execute({ - threadId, - providerThreadId, - checkpointId, - scopeId, - }) - .pipe(Effect.flip); - - assert.equal(error.reason, "active-provider-changed"); - assert.equal( - error.message, - `Active provider changed before rollback target ${checkpointId} could execute on thread ${threadId}.`, - ); - assert.equal(error.cause, undefined); - assert.equal(resolveRuntimePolicy.mock.calls.length, 0); - assert.equal(open.mock.calls.length, 0); - assert.equal(restore.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect("rejects an unavailable rollback target before opening a session", () => { - const threadId = ThreadId.make("thread:rollback-provider-turn-unavailable"); - const providerThreadId = ProviderThreadId.make( - "provider-thread:rollback-provider-turn-unavailable", - ); - const providerSessionId = ProviderSessionId.make( - "provider-session:rollback-provider-turn-unavailable", - ); - const checkpointId = CheckpointId.make("checkpoint:rollback-provider-turn-unavailable"); - const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-provider-turn-unavailable"); - const providerInstanceId = ProviderInstanceId.make("provider_rollback_provider_turn_unavailable"); - const restore = vi.fn(() => Effect.die("checkpoint restore must not run")); - const open = vi.fn(() => Effect.die("provider session open must not run")); - const projection = { - thread: { - worktreePath: "/tmp/missing-invalid-rollback-worktree", - branch: "feature/rollback", - activeProviderThreadId: providerThreadId, - modelSelection: { instanceId: providerInstanceId, model: "test-model" }, - }, - providerThread: { id: providerThreadId, providerSessionId, providerInstanceId }, - providerSession: undefined, - checkpoint: { id: checkpointId, scopeId, status: "ready", appRunOrdinal: 1 }, - scope: { id: scopeId, cwd: process.cwd() }, - runsToRollback: [], - targetAttempt: undefined, - rolledBackAttemptIds: [], - staleCheckpoints: [], - providerTurns: [], - } as unknown as ProjectionStore.ProjectionCheckpointRollbackContext; - const layerTest = layerCheckpointRollbackService.pipe( - Layer.provide( - Layer.mergeAll( - ThreadCommandExecutor.layer, - Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), - Layer.mock(EventSink.EventSinkV2)({}), - IdAllocator.layer, - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThread: () => Effect.succeed(projection.thread), - getCheckpointRollbackContext: () => Effect.succeed(projection), - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - open, - }), - Layer.mock(RuntimePolicy.RuntimePolicyV2)({ - resolve: () => Effect.succeed({} as never), - }), - ), - ), - ); - - return Effect.gen(function* () { - const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; - const error = yield* service - .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles: false }) - .pipe(Effect.flip); - assert.equal(open.mock.calls.length, 0); - assert.equal(error.reason, "provider-turn-unavailable"); - assert.equal( - error.message, - `Provider turn for rollback target ${checkpointId} is unavailable on provider thread ${providerThreadId}.`, - ); - assert.equal(error.cause, undefined); - assert.equal(restore.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)); -}); - it.effect.each([ - { order: "normal", restoreFiles: true }, { order: "cleanup-first", restoreFiles: true }, - { order: "cleanup-first", restoreFiles: false }, { order: "rollback-first", restoreFiles: true }, // A path the server would not have made is left missing, not recreated. { order: "cleanup-first", restoreFiles: false, unmanaged: true }, @@ -578,223 +341,12 @@ it.effect.each([ calls, order === "cleanup-first" ? ["cleanup", "prune", "create", ...rollbackCalls] - : order === "rollback-first" - ? [...rollbackCalls, "cleanup"] - : rollbackCalls, + : [...rollbackCalls, "cleanup"], ); }).pipe(Effect.provide(testLayer)); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect.each([ - { restoreFiles: true, shared: "none" }, - { restoreFiles: false, shared: "root" }, - { restoreFiles: true, shared: "root" }, - { restoreFiles: true, shared: "worktree" }, - { restoreFiles: false, shared: "worktree" }, - { restoreFiles: true, shared: "historical" }, - { restoreFiles: false, shared: "none", targetOrdinal: 1 }, - { restoreFiles: false, shared: "none", targetOrdinal: 1, multipleTurns: true }, -])( - "rewinds safely with %s", - ({ restoreFiles, shared, targetOrdinal = 0, multipleTurns = false }) => { - const threadId = ThreadId.make("rewind-files"); - const providerThreadId = ProviderThreadId.make("rewind-provider"); - const providerSessionId = ProviderSessionId.make("rewind-session"); - const instanceId = ProviderInstanceId.make("rewind-instance"); - const checkpointId = CheckpointId.make("rewind-start"); - const scopeId = CheckpointScopeId.make("rewind-scope"); - const calls: string[] = []; - let lockedThreads = Effect.succeed>([]); - const layerThreadCommands = Layer.effect( - ThreadCommandExecutor.ThreadCommandExecutor, - Effect.tap(KeyedLock.make(), (lock) => - Effect.sync(() => { - lockedThreads = lock.activeKeys; - }), - ), - ); - const providerThread = { - id: providerThreadId, - providerSessionId, - providerInstanceId: instanceId, - }; - const projection = { - thread: { - worktreePath: shared === "root" ? null : process.cwd(), - activeProviderThreadId: providerThreadId, - modelSelection: { instanceId, model: "test" }, - }, - providerThread, - providerSession: undefined, - providerTurns: [ - ...(multipleTurns - ? [ - { - id: "turn-0", - providerThreadId, - runAttemptId: "attempt-1", - ordinal: 0, - status: "completed", - }, - ] - : []), - ...[1, 2, 3].map((ordinal) => ({ - id: `turn-${ordinal}`, - providerThreadId, - runAttemptId: `attempt-${ordinal}`, - ordinal, - status: "completed", - })), - ], - rootNodes: [], - targetAttempt: - targetOrdinal === 0 ? undefined : { id: "attempt-1", providerTurnId: "turn-1" }, - rolledBackAttemptIds: ["attempt-3"], - staleCheckpoints: [], - checkpoint: { - id: checkpointId, - scopeId, - status: "ready", - appRunOrdinal: targetOrdinal || null, - }, - scope: { id: scopeId, cwd: process.cwd() }, - runsToRollback: [1, 2] - .filter((ordinal) => ordinal > targetOrdinal) - .map((ordinal) => ({ - id: `run-${ordinal}`, - ordinal, - status: "completed", - rootNodeId: null, - activeAttemptId: `attempt-${ordinal}`, - })), - } as unknown as ProjectionStore.ProjectionCheckpointRollbackContext; - const layerTest = layerCheckpointRollbackService.pipe( - Layer.provide( - Layer.mergeAll( - layerThreadCommands, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - restore: () => - Effect.sync(() => { - calls.push("files"); - }), - }), - Layer.mock(EventSink.EventSinkV2)({ - write: ({ events }) => - Effect.map(lockedThreads, (locked) => { - assert.include(locked, threadId); - assert.ok( - events.some( - (event) => - event.type === "run.updated" && event.payload.status === "rolled_back", - ), - ); - calls.push("projection"); - return []; - }), - }), - IdAllocator.layer, - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThread: () => Effect.succeed(projection.thread), - getCheckpointRollbackContext: () => Effect.succeed(projection), - getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never), - getCheckpointContext: () => - Effect.succeed({ - checkpointScopes: [{ cwd: process.cwd() }], - runs: [], - checkpoints: [], - } as never), - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: - shared === "worktree" || shared === "historical" - ? [ - { - id: ThreadId.make("other-thread"), - deletedAt: null, - worktreePath: shared === "worktree" ? process.cwd() : null, - } as never, - ] - : [], - }), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - open: () => - Effect.succeed({ - rollbackThread: (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) => - Effect.gen(function* () { - const count = yield* resolveCodexRollbackTurnCount(input); - assert.equal(count, 2 - targetOrdinal); - calls.push("provider"); - return { providerThread }; - }), - } as never), - }), - Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never) }), - ), - ), - ); - return Effect.gen(function* () { - const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; - if (restoreFiles && shared !== "none") { - const error = yield* Effect.flip( - service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }), - ); - assert.equal(error.reason, "shared-workspace"); - assert.deepEqual(calls, []); - return; - } - yield* service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }); - assert.deepEqual( - calls, - restoreFiles ? ["provider", "files", "projection"] : ["provider", "projection"], - ); - }).pipe(Effect.provide(layerTest)); - }, -); - -it.effect.skipIf(!symlinksSupported)( - "rejects an archived thread sharing a worktree through a symlink", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-restore-isolation-" }); - const path = yield* Path.Path; - const alias = path.join(cwd, "alias"); - yield* fileSystem.symlink(cwd, alias); - const threadId = ThreadId.make("restore-alias-current"); - const otherId = ThreadId.make("restore-alias-archived"); - const projections = ProjectionStore.ProjectionStoreV2.of({ - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [{ id: otherId, deletedAt: null, worktreePath: alias } as never], - }), - getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never), - getCheckpointContext: () => - Effect.succeed({ runs: [], checkpointScopes: [], checkpoints: [] }), - } as never); - const isolated = yield* isCheckpointRestoreIsolated( - { id: threadId, worktreePath: cwd }, - { cwd }, - { - fileSystem, - path, - projections, - projects: ProjectStore.ProjectStoreV2.of({ - get: () => Effect.succeed(unrelatedProject), - } as never), - }, - ); - assert.isFalse(isolated); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - const seedRollbackHistory = Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/CheckpointService.test.ts b/apps/server/src/orchestration-v2/CheckpointService.test.ts index 7c0c83b6a72d..39643ff38cf4 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.test.ts @@ -1,5 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { assert, it, vi } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import { CheckpointScopeId, NodeId, @@ -12,99 +12,12 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -it.effect.each([false, true, "interrupt"] as const)( - "materializes baseline, lookup fails=%s", - (lookupFails) => { - const scope: OrchestrationV2CheckpointScope = { - id: CheckpointScopeId.make("checkpoint-scope:materialize-baseline"), - threadId: ThreadId.make("thread:materialize-baseline"), - runId: RunId.make("run:materialize-baseline:3"), - nodeId: NodeId.make("node:materialize-baseline:3"), - parentScopeId: null, - providerThreadId: ProviderThreadId.make("provider-thread:materialize-baseline"), - kind: "root_run", - ordinalWithinParent: 0, - advancesAppRunCount: true, - cwd: "/repo", - createdAt: DateTime.makeUnsafe("2026-07-28T00:00:00.000Z"), - }; - const hasCheckpointRef = vi.fn((_input: CheckpointStore.RestoreCheckpointInput) => - lookupFails === "interrupt" - ? Effect.interrupt - : lookupFails - ? Effect.fail( - new VcsProcessTimeoutError({ - operation: "test.ref", - command: "git", - cwd: "/repo", - timeoutMs: 30000, - }), - ) - : Effect.succeed(true), - ); - const layerTest = CheckpointService.layer.pipe( - Layer.provide( - Layer.mergeAll( - IdAllocator.layer, - Layer.mock(CheckpointStore.CheckpointStore)({ - isGitRepository: () => Effect.succeed(true), - hasCheckpointRef, - captureCheckpoint: () => Effect.void, - }), - ), - ), - Layer.provideMerge(NodeCrypto.layer), - ); - - return Effect.gen(function* () { - const checkpoints = yield* CheckpointService.CheckpointServiceV2; - if (lookupFails === "interrupt") { - const exit = yield* Effect.exit( - checkpoints.materializeBaselineCheckpoint({ scope, ordinalWithinScope: 2 }), - ); - assert.isTrue(Exit.hasInterrupts(exit)); - const captureExit = yield* Effect.exit( - checkpoints.capture({ - scope, - ordinalWithinScope: 1, - runId: scope.runId!, - nodeId: scope.nodeId!, - appRunOrdinal: 1, - capturedAt: scope.createdAt, - }), - ); - assert.isTrue(Exit.hasInterrupts(captureExit)); - return; - } - const baseline = yield* checkpoints.materializeBaselineCheckpoint({ - scope, - ordinalWithinScope: 2, - }); - - assert.equal(baseline.ordinalWithinScope, 2); - assert.equal( - baseline.ref, - yield* CheckpointService.checkpointRefForScopeOrdinal({ - scopeId: scope.id, - ordinalWithinScope: 2, - }), - ); - assert.equal(baseline.status, lookupFails ? "missing" : "ready"); - assert.deepEqual(hasCheckpointRef.mock.calls[0]?.[0], { - cwd: scope.cwd, - checkpointRef: baseline.ref, - }); - }).pipe(Effect.provide(layerTest)); - }, -); - it.effect( "skips unsupported baseline and turn checkpoints without swallowing capture failures", () => diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts index 9ec21bacd799..e3f722d45517 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts @@ -16,7 +16,6 @@ import { TERMINAL_OUTPUT_TAIL_CHARS, type TerminalOutputState, } from "@t3tools/shared/terminalOutput"; -import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -270,95 +269,6 @@ layer("CommandOutputHub", (it) => { ), ); - it.effect("every viewer of one command reconstructs the same text", () => - Effect.scoped( - Effect.gen(function* () { - const hub = yield* CommandOutputHub.CommandOutputHub; - const threadId = yield* seedThread; - yield* writeCommandItem(threadId, { status: "running" }); - yield* hub.append({ threadId, itemId: ITEM_ID, chunk: "first\n" }); - const viewers = [yield* subscribeFrames(threadId), yield* subscribeFrames(threadId)]; - const states = [EMPTY_TERMINAL_OUTPUT, EMPTY_TERMINAL_OUTPUT]; - for (const [index, frames] of viewers.entries()) { - states[index] = applyFrame(states[index]!, yield* takeFrame(frames)); - } - yield* TestClock.adjust("1 second"); - yield* hub.append({ threadId, itemId: ITEM_ID, chunk: "second\r\nthird" }); - for (const [index, frames] of viewers.entries()) { - states[index] = applyFrame(states[index]!, yield* takeFrame(frames)); - } - assert.equal(states[0]!.text, "first\nsecond\nthird"); - assert.equal(states[1]!.text, states[0]!.text); - }), - ), - ); - - it.effect("a flood is sent as bounded tails at a slower rate, not as every chunk", () => - Effect.scoped( - Effect.gen(function* () { - const hub = yield* CommandOutputHub.CommandOutputHub; - const threadId = yield* seedThread; - yield* writeCommandItem(threadId, { status: "running" }); - const stamped = yield* Queue.unbounded<{ - readonly frame: OrchestrationV2CommandOutputFrame; - readonly at: number; - }>(); - yield* hub.subscribe({ threadId, itemId: ITEM_ID }).pipe( - Stream.runForEach((frame) => - Clock.currentTimeMillis.pipe( - Effect.flatMap((at) => Queue.offer(stamped, { frame, at })), - ), - ), - Effect.forkScoped, - ); - let client = applyFrame(EMPTY_TERMINAL_OUTPUT, (yield* Queue.take(stamped)).frame); - yield* TestClock.adjust("1 second"); - - // `yes | head -n 1000000`: 2 MB in 4 KB chunks, 64 KB every 100 ms. - const chunk = "y\n".repeat(2_048); - const sent: Array<{ - readonly frame: OrchestrationV2CommandOutputFrame; - readonly at: number; - }> = []; - const drain = Effect.gen(function* () { - for (const entry of yield* Queue.clear(stamped)) { - sent.push(entry); - client = applyFrame(client, entry.frame); - } - }); - for (let tick = 0; tick < 32; tick += 1) { - for (let index = 0; index < 16; index += 1) { - yield* hub.append({ threadId, itemId: ITEM_ID, chunk }); - } - yield* TestClock.adjust(CommandOutputHub.COMMAND_OUTPUT_FRAME_INTERVAL_MS); - yield* drain; - } - yield* hub.append({ threadId, itemId: ITEM_ID, chunk: "tail\n" }); - yield* TestClock.adjust("1 second"); - yield* drain; - - const bytes = sent.reduce((total, { frame }) => total + frame.text.length, 0); - // 3.2 s of flooding: a frame per chunk would be 512 frames and 2 MB. - assert.isAtMost(sent.length, 10); - assert.isAtMost(bytes, 10 * TERMINAL_OUTPUT_TAIL_CHARS); - for (const { frame } of sent) { - assert.isAtMost(frame.text.length, TERMINAL_OUTPUT_TAIL_CHARS); - } - for (let index = 1; index < sent.length; index += 1) { - if (sent[index - 1]!.frame.kind === "replace") { - assert.isAtLeast( - sent[index]!.at - sent[index - 1]!.at, - CommandOutputHub.COMMAND_OUTPUT_REPLACE_INTERVAL_MS, - ); - } - } - assert.isTrue(client.truncated); - assert.isTrue(client.text.endsWith("y\ny\ntail\n")); - assert.isAtMost(client.text.length, TERMINAL_OUTPUT_TAIL_CHARS); - }), - ), - ); - it.effect("a finished command sends only its bounded final output", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.ts b/apps/server/src/orchestration-v2/CommandOutputHub.ts index 532a44df601e..57b4b855cd9b 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.ts @@ -37,9 +37,9 @@ import * as ProjectionStore from "./ProjectionStore.ts"; */ /** Fastest a subscriber receives frames while output flows. */ -export const COMMAND_OUTPUT_FRAME_INTERVAL_MS = 100; +const COMMAND_OUTPUT_FRAME_INTERVAL_MS = 100; /** After a subscriber falls behind and is sent the whole tail, it waits this long. */ -export const COMMAND_OUTPUT_REPLACE_INTERVAL_MS = 500; +const COMMAND_OUTPUT_REPLACE_INTERVAL_MS = 500; /** Raw text a subscriber may accumulate before it is sent the tail instead. */ const COMMAND_OUTPUT_MAX_APPEND_CHARS = 16 * 1024; /** Running commands tracked at once; the least recently written is dropped beyond this. */ diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 824076a93a1f..1bf294641a01 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -15,7 +15,6 @@ import * as Effect from "effect/Effect"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; -import { GrokProviderCapabilitiesV2 } from "@t3tools/provider-grok/testing"; import * as CommandPolicy from "./CommandPolicy.ts"; const commandId = CommandId.make("command-policy-test"); @@ -165,30 +164,6 @@ it.each([ ); }); -it("targets the latest active run for explicit steer and restart intent", () => { - const projection = dispatchProjection(baseCapabilities); - assert.deepEqual( - CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "steer"), - { type: "steer_active", targetRunId: activeRunId }, - ); - assert.deepEqual( - CommandPolicy.resolveMessageDispatchIntent( - projection, - { type: "start_immediately" }, - "restart", - ), - { type: "restart_active", targetRunId: activeRunId }, - ); - assert.deepEqual( - CommandPolicy.resolveMessageDispatchIntent( - dispatchProjection(), - { type: "start_immediately" }, - "steer", - ), - { type: "start_immediately" }, - ); -}); - it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) => { const projection = dispatchProjection(baseCapabilities); const maintenance = { @@ -225,342 +200,6 @@ it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) = const layer = it.layer(CommandPolicy.layer); layer("CommandPolicyV2", (it) => { - it.effect("prefers direct active steering when the provider supports it", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const result = yield* policy.decideSteeringExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: baseCapabilities, - }); - - assert.equal(result, "active_steering"); - }), - ); - - it.effect("uses interrupt-and-restart steering when direct steering is unavailable", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const result = yield* policy.decideSteeringExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: capabilities((current) => ({ - ...current, - turns: { - ...current.turns, - supportsActiveSteering: false, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: true, - }, - })), - }); - - assert.equal(result, "interrupt_restart"); - }), - ); - - it.effect("uses interrupt-and-restart steering for Grok ACP", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const result = yield* policy.decideSteeringExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("grok"), - capabilities: GrokProviderCapabilitiesV2, - }); - - assert.equal(result, "interrupt_restart"); - }), - ); - - it.effect("honors an explicit interrupt-and-restart request", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const result = yield* policy.decideSteeringExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: CodexProviderCapabilitiesV2, - forceRestart: true, - }); - - assert.equal(result, "interrupt_restart"); - }), - ); - - it.effect("reports the actually missing capability across the steering matrix", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - const providerInstanceId = ProviderInstanceId.make("codex"); - const fallbackDetail = - "providerInstanceId cannot steer active turns directly or by interrupt-and-restart"; - const forcedRestartDetail = - "providerInstanceId cannot satisfy a required interrupt-and-restart"; - - const cases: ReadonlyArray<{ - readonly forceRestart: boolean; - readonly supportsActiveSteering: boolean; - readonly supportsInterrupt: boolean; - readonly supportsSteeringByInterruptRestart: boolean; - readonly expected: - | { readonly type: "policy"; readonly value: CommandPolicy.SteeringExecutionPolicyV2 } - | { - readonly type: "error"; - readonly capability: CommandPolicy.CommandPolicyCapability; - readonly detail: string; - }; - }> = [ - { - forceRestart: false, - supportsActiveSteering: true, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: false, - expected: { type: "policy", value: "active_steering" }, - }, - { - forceRestart: false, - supportsActiveSteering: true, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: true, - expected: { type: "policy", value: "active_steering" }, - }, - { - forceRestart: false, - supportsActiveSteering: true, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: false, - expected: { type: "policy", value: "active_steering" }, - }, - { - forceRestart: false, - supportsActiveSteering: true, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: true, - expected: { type: "policy", value: "active_steering" }, - }, - { - forceRestart: false, - supportsActiveSteering: false, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: true, - expected: { type: "policy", value: "interrupt_restart" }, - }, - { - forceRestart: false, - supportsActiveSteering: false, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: false, - expected: { - type: "error", - capability: "interrupt_restart_steering", - detail: fallbackDetail, - }, - }, - { - forceRestart: false, - supportsActiveSteering: false, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: true, - expected: { type: "error", capability: "active_steering", detail: fallbackDetail }, - }, - { - forceRestart: false, - supportsActiveSteering: false, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: false, - expected: { type: "error", capability: "active_steering", detail: fallbackDetail }, - }, - { - forceRestart: true, - supportsActiveSteering: true, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: true, - expected: { type: "policy", value: "interrupt_restart" }, - }, - { - forceRestart: true, - supportsActiveSteering: false, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: true, - expected: { type: "policy", value: "interrupt_restart" }, - }, - { - forceRestart: true, - supportsActiveSteering: true, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: false, - expected: { - type: "error", - capability: "interrupt_restart_steering", - detail: forcedRestartDetail, - }, - }, - { - forceRestart: true, - supportsActiveSteering: false, - supportsInterrupt: true, - supportsSteeringByInterruptRestart: false, - expected: { - type: "error", - capability: "interrupt_restart_steering", - detail: forcedRestartDetail, - }, - }, - { - forceRestart: true, - supportsActiveSteering: true, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: true, - expected: { - type: "error", - capability: "interrupt_restart_steering", - detail: forcedRestartDetail, - }, - }, - { - forceRestart: true, - supportsActiveSteering: false, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: true, - expected: { - type: "error", - capability: "interrupt_restart_steering", - detail: forcedRestartDetail, - }, - }, - { - forceRestart: true, - supportsActiveSteering: true, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: false, - expected: { - type: "error", - capability: "interrupt_restart_steering", - detail: forcedRestartDetail, - }, - }, - { - forceRestart: true, - supportsActiveSteering: false, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: false, - expected: { - type: "error", - capability: "interrupt_restart_steering", - detail: forcedRestartDetail, - }, - }, - ]; - - for (const entry of cases) { - const decision = policy.decideSteeringExecution({ - commandId, - threadId, - providerInstanceId, - forceRestart: entry.forceRestart, - capabilities: capabilities((current) => ({ - ...current, - turns: { - ...current.turns, - supportsActiveSteering: entry.supportsActiveSteering, - supportsInterrupt: entry.supportsInterrupt, - supportsSteeringByInterruptRestart: entry.supportsSteeringByInterruptRestart, - }, - })), - }); - - if (entry.expected.type === "policy") { - assert.equal(yield* decision, entry.expected.value); - continue; - } - - const error = yield* decision.pipe(Effect.flip); - assert.instanceOf(error, CommandPolicy.CommandPolicyCapabilityUnsupportedError); - assert.equal(error.commandId, commandId); - assert.equal(error.threadId, threadId); - assert.equal(error.providerInstanceId, providerInstanceId); - assert.equal(error.capability, entry.expected.capability); - assert.equal(error.detail, entry.expected.detail); - } - }), - ); - - it.effect("returns typed capability errors for unsupported active steering", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const error = yield* policy - .decideSteeringExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: capabilities((current) => ({ - ...current, - turns: { - ...current.turns, - supportsActiveSteering: false, - supportsInterrupt: false, - supportsSteeringByInterruptRestart: false, - }, - })), - }) - .pipe(Effect.flip); - - assert.instanceOf(error, CommandPolicy.CommandPolicyCapabilityUnsupportedError); - assert.equal(error.capability, "active_steering"); - }), - ); - - it.effect("guards native fork behind fork and identity capabilities", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const error = yield* policy - .ensureNativeFork({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - fromSpecificTurn: true, - capabilities: capabilities((current) => ({ - ...current, - identity: { - ...current.identity, - nativeThreadIds: "weak", - }, - })), - }) - .pipe(Effect.flip); - - assert.instanceOf(error, CommandPolicy.CommandPolicyCapabilityUnsupportedError); - assert.equal(error.capability, "native_fork"); - }), - ); - - it.effect("uses a native fork when the provider supports the requested source point", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const result = yield* policy.decideForkExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: CodexProviderCapabilitiesV2, - sameProvider: true, - hasStrongNativeSource: true, - sourceRunStatus: "completed", - fromSpecificTurn: true, - }); - - assert.equal(result, "native_fork"); - }), - ); - it.effect("falls back to portable context when Cursor cannot fork natively", () => Effect.gen(function* () { const policy = yield* CommandPolicy.CommandPolicyV2; @@ -579,128 +218,4 @@ layer("CommandPolicyV2", (it) => { assert.equal(result, "portable_context"); }), ); - - it.effect("falls back to portable context when Grok ACP cannot fork natively", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const result = yield* policy.decideForkExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("grok"), - capabilities: GrokProviderCapabilitiesV2, - sameProvider: true, - hasStrongNativeSource: true, - sourceRunStatus: "completed", - fromSpecificTurn: true, - }); - - assert.equal(result, "portable_context"); - }), - ); - - it.effect("returns a typed error when neither native nor portable fork is available", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const error = yield* policy - .decideForkExecution({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("cursor"), - capabilities: capabilities((current) => ({ - ...current, - threads: { - ...current.threads, - canForkThread: false, - }, - context: { - ...current.context, - canConsumeHandoffSummaries: false, - }, - })), - sameProvider: true, - hasStrongNativeSource: true, - sourceRunStatus: "completed", - fromSpecificTurn: true, - }) - .pipe(Effect.flip); - - assert.instanceOf(error, CommandPolicy.CommandPolicyCapabilityUnsupportedError); - assert.equal(error.capability, "context_handoff"); - }), - ); - - it.effect("guards rollback behind provider rollback snapshot support", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const error = yield* policy - .ensureRollback({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: capabilities((current) => ({ - ...current, - checkpointing: { - ...current.checkpointing, - providerRollbackReturnsSnapshot: false, - }, - })), - }) - .pipe(Effect.flip); - - assert.instanceOf(error, CommandPolicy.CommandPolicyCapabilityUnsupportedError); - assert.equal(error.capability, "rollback_snapshot"); - }), - ); - - it.effect("guards fork-delta handoff behind context handoff capabilities", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const error = yield* policy - .ensureContextHandoff({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - strategy: "fork_delta_context", - capabilities: capabilities((current) => ({ - ...current, - context: { - ...current.context, - supportsDeltaHandoff: false, - }, - })), - }) - .pipe(Effect.flip); - - assert.instanceOf(error, CommandPolicy.CommandPolicyCapabilityUnsupportedError); - assert.equal(error.capability, "context_handoff"); - }), - ); - - it.effect("guards queued turns behind queued-message support", () => - Effect.gen(function* () { - const policy = yield* CommandPolicy.CommandPolicyV2; - - const error = yield* policy - .ensureQueuedMessages({ - commandId, - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: capabilities((current) => ({ - ...current, - turns: { - ...current.turns, - supportsQueuedMessages: false, - }, - })), - }) - .pipe(Effect.flip); - - assert.instanceOf(error, CommandPolicy.CommandPolicyCapabilityUnsupportedError); - assert.equal(error.capability, "queued_messages"); - }), - ); }); diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index c2024926e378..c4068702e3a5 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -148,19 +148,6 @@ describe("handoff budget", () => { assert.equal(historyResponseItems([command!], "Activity")[1]?.type, "message"); }); - it("retains short conversations verbatim in role and order", () => { - const selected = selectHistory({ messages, coverage: "History", budget: 16_000 }); - assert.deepEqual(selected.messages, messages); - const items = historyResponseItems(selected.messages, selected.context); - assert.deepEqual( - items.map((item) => item.role), - ["user", "user", "assistant"], - ); - assert.include(items[1]!.content[0]!.text, messages[0]!.text); - assert.include(items[2]!.content[0]!.text, messages[1]!.text); - assert.equal(selected.omittedItems, 0); - }); - it("omits oversized multilingual items whole, preserves original constraints and recent work", () => { const candidates = [ messages[0]!, @@ -457,39 +444,6 @@ describe("handoff delivery", () => { assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); }), ); - it.effect("loads the history budget only when a handoff needs delivery", () => - Effect.gen(function* () { - let reads = 0; - const input = { - providerThread, - budget: Effect.sync(() => { - reads++; - return 16_000; - }), - alreadyDeliveredItemIds: new Set(), - persist: () => Effect.void, - }; - yield* deliverContextHandoffs({ ...input, handoffs: [] }); - yield* deliverContextHandoffs({ ...input, handoffs: [handoff], deferInline: true }); - yield* deliverContextHandoffs({ - ...input, - handoffs: [ - { - ...handoff, - delivery: { - nativeThreadId: providerThread.nativeThreadRef!.nativeId!, - status: "injected", - itemIds: [], - }, - }, - ], - }); - assert.equal(reads, 0); - const result = yield* deliverContextHandoffs({ ...input, handoffs: [handoff] }); - assert.equal(reads, 1); - assert.include(result.context, messages[0]!.text); - }), - ); it.effect("persists successful injection before turn start and skips it on retry", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts deleted file mode 100644 index e5e34d92aa2f..000000000000 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ /dev/null @@ -1,386 +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, - MessageId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, - type OrchestrationV2ThreadProjection, -} 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 EffectWorker from "./EffectWorker.ts"; -import * as RuntimeLayer from "./runtimeLayer.ts"; -import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; -import { CURSOR_MODEL_SELECTION, SUBAGENT_PROMPT } from "./testkit/fixtures/shared.ts"; - -const layerPlatformTest = Layer.merge( - NodeServices.layer, - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("unused title link"), - }), -); - -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-cursor-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: { - [ProviderInstanceId.make("cursor")]: { - driver: ProviderDriverKind.make("cursor"), - enabled: true, - }, - }, -}); -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(ProviderTurnStartServiceTestkit.layer), - 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(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), -); - -const waitForIdle = Effect.fn("CursorOrchestratorV2Live.waitForIdle")(function* ( - threadId: ThreadId, -) { - const orchestrator = yield* Orchestrator.OrchestratorV2; - for (let attempt = 0; attempt < 600; attempt += 1) { - const projection = yield* orchestrator.getThreadProjection(threadId); - if ( - projection.runs.length > 0 && - 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 Cursor thread ${threadId}.`)); -}); - -describe.runIf(process.env.T3_CURSOR_LIVE_ORCHESTRATOR === "1")( - "Cursor V2 live orchestrator", - () => { - it.live( - "forks through portable context using real Cursor agents", - () => - Effect.gen(function* () { - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("project:cursor-live-portable-fork"); - const sourceThreadId = ThreadId.make("thread:cursor-live-portable-fork:source"); - const targetThreadId = ThreadId.make("thread:cursor-live-portable-fork:target"); - const marker = "CURSOR_LIVE_PORTABLE_FORK_7H3Q"; - - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-live-portable-fork:create"), - threadId: sourceThreadId, - projectId, - title: "Cursor live portable fork source", - modelSelection: CURSOR_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-live-portable-fork:source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cursor-live-portable-fork:source"), - text: `Remember this opaque marker. Respond with exactly: ${marker}`, - attachments: [], - modelSelection: CURSOR_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }); - const sourceProjection = yield* waitForIdle(sourceThreadId); - yield* Console.log("Cursor live source turn completed; dispatching portable fork."); - - yield* orchestrator.dispatch({ - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-live-portable-fork:fork"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Cursor live portable fork target", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-live-portable-fork:target"), - threadId: targetThreadId, - messageId: MessageId.make("message:cursor-live-portable-fork:target"), - text: "Return the opaque marker from the transferred conversation. Respond with only the marker.", - attachments: [], - modelSelection: CURSOR_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }); - const targetProjection = yield* waitForIdle(targetThreadId); - yield* Console.log("Cursor live portable fork target completed."); - - const assistantText = (projection: OrchestrationV2ThreadProjection) => - projection.messages - .filter((message) => message.role === "assistant") - .map((message) => message.text) - .join("\n"); - - assert.deepEqual( - sourceProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [["cursor", "completed"]], - ); - assert.deepEqual( - targetProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [["cursor", "completed"]], - ); - assert.deepEqual( - targetProjection.contextTransfers.map((transfer) => [ - transfer.type, - transfer.status, - transfer.resolution?.strategy, - ]), - [["fork", "consumed", "portable_context"]], - ); - assert.deepEqual( - targetProjection.contextHandoffs.map((handoff) => handoff.strategy), - ["full_thread_summary"], - ); - assert.include(targetProjection.contextHandoffs[0]?.summaryText ?? "", marker); - assert.include(assistantText(targetProjection), marker); - }).pipe(Effect.provide(layerLive), Effect.scoped), - 360_000, - ); - - it.live( - "runs a sandboxed thread after a full access thread in the same server", - () => - Effect.gen(function* () { - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("project:cursor-live-sandbox-after-full-access"); - - const runThread = Effect.fn("CursorOrchestratorV2Live.runThread")(function* (input: { - readonly name: string; - readonly runtimeMode: "full-access" | "approval-required"; - }) { - const threadId = ThreadId.make(`thread:cursor-live-sandbox:${input.name}`); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:cursor-live-sandbox:${input.name}:create`), - threadId, - projectId, - title: `Cursor live sandbox ${input.name}`, - modelSelection: CURSOR_MODEL_SELECTION, - runtimeMode: input.runtimeMode, - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:cursor-live-sandbox:${input.name}:message`), - threadId, - messageId: MessageId.make(`message:cursor-live-sandbox:${input.name}`), - text: "Respond with exactly: OK. Do not use any tools.", - attachments: [], - modelSelection: CURSOR_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }); - return yield* waitForIdle(threadId); - }); - - // The SDK decides once per process whether local sandboxing works. - // The unsandboxed thread must run first to catch a wrong verdict. - const fullAccess = yield* runThread({ name: "full-access", runtimeMode: "full-access" }); - const supervised = yield* runThread({ - name: "supervised", - runtimeMode: "approval-required", - }); - - assert.deepEqual( - fullAccess.runs.map((run) => run.status), - ["completed"], - ); - assert.deepEqual( - supervised.runs.map((run) => run.status), - ["completed"], - ); - }).pipe(Effect.provide(layerLive), Effect.scoped), - 360_000, - ); - - it.live( - "spawns native subagents with child thread lineage", - () => - Effect.gen(function* () { - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("project:cursor-live-subagent-lineage"); - const sourceThreadId = ThreadId.make("thread:cursor-live-subagent-lineage"); - - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-live-subagent-lineage:create"), - threadId: sourceThreadId, - projectId, - title: "Cursor live subagent lineage", - modelSelection: CURSOR_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-live-subagent-lineage:message"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cursor-live-subagent-lineage"), - text: `${SUBAGENT_PROMPT}. Do not edit files. After both subagents finish, summarize each result briefly.`, - attachments: [], - modelSelection: CURSOR_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }); - - const parentProjection = yield* waitForIdle(sourceThreadId); - yield* Console.log( - `Cursor live subagent lineage completed with ${parentProjection.subagents.length} subagents.`, - ); - - assert.deepEqual( - parentProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [["cursor", "completed"]], - ); - assert.isAtLeast(parentProjection.subagents.length, 1); - - for (const subagent of parentProjection.subagents) { - assert.equal(subagent.origin, "provider_native"); - assert.equal(subagent.driver, "cursor"); - assert.equal(subagent.status, "completed"); - assert.isNotNull(subagent.childThreadId); - assert.isNotNull(subagent.result); - - if (subagent.childThreadId === null) { - throw new Error(`Cursor live subagent ${subagent.id} is missing a child thread.`); - } - - const childProjection = yield* orchestrator.getThreadProjection(subagent.childThreadId); - assert.equal(childProjection.thread.lineage.parentThreadId, parentProjection.thread.id); - assert.equal(childProjection.thread.lineage.relationshipToParent, "subagent"); - assert.equal( - childProjection.thread.lineage.rootThreadId, - parentProjection.thread.lineage.rootThreadId, - ); - assert.lengthOf(childProjection.runs, 0); - assert.isTrue( - childProjection.messages.some((message) => message.role === "assistant"), - `child thread ${subagent.childThreadId} should contain the subagent response`, - ); - } - }).pipe(Effect.provide(layerLive), Effect.scoped), - 360_000, - ); - }, -); diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index d11bc51dfbfa..e58c8a1e528f 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -22,7 +22,6 @@ 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 Tracer from "effect/Tracer"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; @@ -977,23 +976,6 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { ); }); -const traceSqlStatements = () => { - const statements: 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); - const query = span.attributes.get("db.query.text"); - if (typeof query === "string") statements.push(query); - }; - return span; - }, - }); - return { statements, tracer }; -}; - const reconcileCommandId = (name: string) => CommandId.make(`command:runtime-reconcile:${name}`); const parentProviderThreadId = (threadId: ThreadId) => @@ -2168,106 +2150,6 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); - it.effect("settles only the current delivery of a parent with long delivery history", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:delivery-history"); - const runId = RunId.make("run:delivery-history"); - const taskId = NodeId.make("node:delivery-history-task"); - const currentMessageId = MessageId.make(`message:delegated-delivery:${threadId}`); - const currentRunId = RunId.make("run:delivery-history:current"); - const HISTORY = 40; - yield* seedParentWithTerminalTask({ - threadId, - projectId: ProjectId.make("project:delivery-history"), - runId, - rootNodeId: NodeId.make("node:delivery-history-root"), - taskId, - deliveryState: "claimed", - completionWake: "always", - deliveryTaskIds: [taskId], - now, - }); - const deliveryMessage = (id: MessageId, deliveryRunId: RunId) => ({ - id: EventId.make(`event:${deliveryRunId}:message`), - type: "message.updated" as const, - threadId, - runId: deliveryRunId, - occurredAt: now, - payload: { - id, - threadId, - runId: deliveryRunId, - nodeId: null, - role: "user" as const, - text: `Delegated task ${taskId} reached a terminal state.`, - attachments: [], - streaming: false, - createdBy: "agent" as const, - creationSource: "server" as const, - createdAt: now, - updatedAt: now, - delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, - }, - }); - const history = Array.from({ length: HISTORY }, (_, index) => { - const historyRunId = RunId.make(`run:delivery-history:${index}`); - const messageId = MessageId.make(`message:delivery-history:${index}`); - return [ - deliveryMessage(messageId, historyRunId), - runEvent({ - threadId, - runId: historyRunId, - ordinal: index + 2, - status: "completed", - now, - providerThreadId: parentProviderThreadId(threadId), - userMessageId: messageId, - }), - ]; - }).flat(); - yield* eventSink.write({ - commandId: reconcileCommandId("delivery-history"), - events: [ - ...history, - deliveryMessage(currentMessageId, currentRunId), - runEvent({ - threadId, - runId: currentRunId, - ordinal: HISTORY + 2, - status: "completed", - now, - providerThreadId: parentProviderThreadId(threadId), - userMessageId: currentMessageId, - }), - ], - }); - - const { statements, tracer } = traceSqlStatements(); - yield* orchestrator.recoverDelegatedTasks.pipe(Effect.withTracer(tracer)); - - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - projection.subagents.find((row) => row.id === taskId)?.completionDelivery?.state, - "delivered", - ); - assert.isNull(projection.runs.find((row) => row.id === runId)?.delegatedCompletion?.delivery); - assert.lengthOf( - statements.filter((statement) => - statement.includes("FROM orchestration_v2_projection_messages AS message"), - ), - 1, - ); - - yield* orchestrator.recoverDelegatedTasks; - const again = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(again.runs, projection.runs); - assert.deepEqual(again.subagents, projection.subagents); - }), - ); - it.effect("settles a cancelled child whose held wakes wait behind it", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2520,4 +2402,224 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { ); }), ); + + it.effect.each(["a background command", "a nested subagent"] as const)( + "finalizes a child whose run ended before %s settled", + (blocker) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const key = blocker === "a background command" ? "command" : "nested"; + const threadId = ThreadId.make(`late-settle-parent-${key}`); + const runId = RunId.make(`late-settle-parent-run-${key}`); + const rootNodeId = NodeId.make(`late-settle-parent-root-${key}`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`late-settle-project-${key}`), + runId, + rootNodeId, + taskId: NodeId.make(`late-settle-seed-task-${key}`), + deliveryState: "acknowledged", + now, + }); + // A delegated child with a running task on the parent and a running run of its own. + const delegate = (name: string) => + Effect.gen(function* () { + const taskId = NodeId.make(`node:late-settle-${key}-${name}`); + const childThreadId = ThreadId.make(`thread:late-settle-${key}-${name}`); + const run = runEvent({ + threadId: childThreadId, + runId: RunId.make(`run:late-settle-${key}-${name}`), + ordinal: 1, + status: "running", + now, + }); + yield* sink.write({ + events: [ + { + id: EventId.make(`event:late-settle-${key}-${name}:thread`), + type: "thread.created", + threadId: childThreadId, + occurredAt: now, + payload: { + createdBy: "agent", + creationSource: "server", + id: childThreadId, + projectId: ProjectId.make(`late-settle-project-${key}`), + title: name, + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: threadId, + relationshipToParent: "subagent", + rootThreadId: threadId, + }, + forkedFrom: { type: "node", nodeId: taskId }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + id: EventId.make(`event:late-settle-${key}-${name}:task`), + type: "subagent.updated", + threadId, + runId, + nodeId: taskId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId, + parentNodeId: rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: `Run ${name}.`, + title: null, + model: null, + completionWake: "always", + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + run, + ], + }); + return { task: { id: taskId }, childThreadId, run: run.payload }; + }); + const settled = (taskId: NodeId, afterSequence: number) => + sink.stream({ afterSequence, eventType: "subagent.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === taskId && + stored.event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + ); + const blocked = yield* delegate("blocked"); + const barrier = yield* delegate("barrier"); + const providerThread = { + id: ProviderThreadId.make(`provider-thread:late-settle-child-${key}`), + driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: null, + appThreadId: blocked.childThreadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const nested = { + id: NodeId.make(`late-settle-nested-${key}`), + threadId: blocked.childThreadId, + runId: blocked.run.id, + parentNodeId: NodeId.make(`late-settle-child-root-${key}`), + origin: "provider_native" as const, + createdBy: "agent" as const, + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Nested work", + title: null, + model: null, + status: "running" as const, + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }; + const blockerEvent = (open: boolean) => + blocker === "a background command" + ? { + id: EventId.make(`event:late-settle-roster-${key}-${open}`), + type: "provider-thread.updated" as const, + threadId: blocked.childThreadId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...providerThread, + pendingBackgroundTasks: open + ? [{ taskId: "sleep-900", kind: "command" as const }] + : [], + }, + } + : { + id: EventId.make(`event:late-settle-nested-${key}-${open}`), + type: "subagent.updated" as const, + threadId: blocked.childThreadId, + runId: blocked.run.id, + nodeId: nested.id, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: open + ? nested + : { ...nested, status: "completed" as const, result: "done", completedAt: now }, + }; + const ended = (child: typeof blocked) => ({ + ...runEvent({ + threadId: child.childThreadId, + runId: child.run.id, + ordinal: child.run.ordinal, + status: "completed", + now, + }), + payload: { ...child.run, status: "completed" as const, startedAt: now, completedAt: now }, + }); + + const beforeEnd = yield* sink.latestSequence(); + yield* sink.write({ events: [blockerEvent(true)] }); + yield* sink.write({ events: [ended(blocked)] }); + yield* sink.write({ events: [ended(barrier)] }); + // Run updates are handled in order, so the barrier settling means the blocked child's + // run end was handled too, while its work still ran. + yield* settled(barrier.task.id, beforeEnd); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).subagents.find( + (task) => task.id === blocked.task.id, + )?.status, + "running", + ); + + const beforeSettle = yield* sink.latestSequence(); + yield* sink.write({ events: [blockerEvent(false)] }); + yield* settled(blocked.task.id, beforeSettle); + const finished = (yield* orchestrator.getThreadProjection(threadId)).subagents.find( + (task) => task.id === blocked.task.id, + ); + assert.equal(finished?.status, "completed"); + assert.notEqual(finished?.completionDelivery?.state, "disposed"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts index eb4d87c3f720..ba371f10524a 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts @@ -94,30 +94,6 @@ it.layer(layerTest)("DispatchModeLimit", (it) => { }), ); - it.effect("names the interaction mode when that is what was raised", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("thread:limit-plan"); - yield* createThread(threadId); - const refused = yield* orchestrator - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("rename-plan"), - threadId, - title: "After", - }) - .pipe( - Effect.provideService(DispatchModeLimit, { - runtimeMode: "full-access", - interactionMode: "plan", - }), - Effect.flip, - ); - assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError"); - assert.equal(refused.mode, "interaction"); - }), - ); - it.effect("refuses to fork a source thread its user raised", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 77e9a263ddb9..671d7902fb45 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -19,9 +19,7 @@ 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 Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as TestClock from "effect/testing/TestClock"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as CheckpointService from "./CheckpointService.ts"; @@ -38,9 +36,6 @@ import * as ServerSettings from "../serverSettings.ts"; const threadId = ThreadId.make("thread:effect-worker-restart"); const oldSessionId = ProviderSessionId.make("provider-session:effect-worker-restart:old"); -const replacementSessionId = ProviderSessionId.make( - "provider-session:effect-worker-restart:replacement", -); const providerThreadId = ProviderThreadId.make("provider-thread:effect-worker-restart"); const providerTurnId = ProviderTurnId.make("provider-turn:effect-worker-restart"); const attemptId = RunAttemptId.make("run-attempt:effect-worker-restart"); @@ -169,39 +164,6 @@ function layerExecutorFor(input: { ); } -it("does not retry pure interrupt races where the turn is already gone", () => { - assert.isTrue( - EffectWorker.isNonRetryableProviderTurnControlFailure( - "provider-turn.interrupt", - "ProviderAdapterInterruptError: ... ACP provider turn provider-turn:x is not active", - ), - ); - assert.isTrue( - EffectWorker.isNonRetryableProviderTurnControlFailure( - "provider-turn.interrupt", - "Provider session provider-session:x is not active.", - ), - ); - assert.isFalse( - EffectWorker.isNonRetryableProviderTurnControlFailure( - "provider-turn.restart", - "Provider session provider-session:x is not active.", - ), - ); - assert.isFalse( - EffectWorker.isNonRetryableProviderTurnControlFailure( - "provider-turn.start", - "Provider session provider-session:x is not active.", - ), - ); - assert.isFalse( - EffectWorker.isNonRetryableProviderTurnControlFailure( - "provider-turn.interrupt", - "ACP hard teardown failed unexpectedly; the session is poisoned", - ), - ); -}); - it.effect("settles a stopped run when its adapter has already lost the native turn", () => Effect.gen(function* () { const now = yield* DateTime.now; @@ -513,362 +475,6 @@ it.effect("terminalizes a process-bound claim when success settlement fails", () }), ); -it.effect("requeues a replay-safe claim when success settlement fails", () => - Effect.gen(function* () { - const now = DateTime.formatIso(yield* DateTime.now); - const effectId = "effect:worker-replay-safe-settlement-failure"; - const workerId = "worker-replay-safe-settlement-failure"; - const claimedEffect: EffectOutbox.OrchestrationEffectV2 = { - id: effectId, - commandId: CommandId.make("command:worker-replay-safe-settlement-failure"), - threadId: ThreadId.make("thread:worker-replay-safe-settlement-failure"), - request: { type: "terminal.cleanup" }, - status: "running", - attemptCount: 1, - availableAt: now, - leaseOwner: workerId, - leaseExpiresAt: now, - createdAt: now, - updatedAt: now, - completedAt: null, - lastError: null, - }; - const retries = yield* Ref.make(0); - const terminalizations = yield* Ref.make(0); - const layerOutbox = Layer.mock(EffectOutbox.EffectOutboxV2)({ - claimNext: () => Effect.succeed(Option.some(claimedEffect)), - get: () => Effect.succeed(Option.some(claimedEffect)), - awaitCancellation: () => Effect.never, - clearCancellation: () => Effect.void, - succeed: () => - Effect.fail( - new EffectOutbox.EffectOutboxError({ - operation: "succeed", - effectId, - cause: "simulated replay-safe settlement failure", - }), - ), - retry: () => Ref.update(retries, (count) => count + 1).pipe(Effect.as(true)), - fail: () => Ref.update(terminalizations, (count) => count + 1).pipe(Effect.as(true)), - }); - const layerExecutor = Layer.succeed( - EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ execute: () => Effect.void }), - ); - const layerWorker = EffectWorker.layerWithOptions({ workerId }).pipe( - Layer.provide(Layer.merge(layerOutbox, layerExecutor)), - ); - - const exit = yield* EffectWorker.OrchestrationEffectWorkerV2.pipe( - Effect.flatMap((worker) => worker.runOnce), - Effect.provide(layerWorker), - Effect.exit, - ); - - assert.isTrue(Exit.isFailure(exit)); - assert.equal(yield* Ref.get(retries), 1); - assert.equal(yield* Ref.get(terminalizations), 0); - }), -); - -it.effect("keeps a process-bound executor failure retryable when retry settlement fails", () => - Effect.gen(function* () { - const now = DateTime.formatIso(yield* DateTime.now); - const effectId = "effect:worker-process-bound-retry-settlement-failure"; - const workerId = "worker-process-bound-retry-settlement-failure"; - const claimedEffect: EffectOutbox.OrchestrationEffectV2 = { - id: effectId, - commandId: CommandId.make("command:worker-process-bound-retry-settlement-failure"), - threadId: ThreadId.make("thread:worker-process-bound-retry-settlement-failure"), - request: { - type: "provider-turn.start", - runId: RunId.make("run:worker-process-bound-retry-settlement-failure"), - }, - status: "running", - attemptCount: 1, - availableAt: now, - leaseOwner: workerId, - leaseExpiresAt: now, - createdAt: now, - updatedAt: now, - completedAt: null, - lastError: null, - }; - const retryAttempts = yield* Ref.make(0); - const terminalizations = yield* Ref.make(0); - const layerOutbox = Layer.mock(EffectOutbox.EffectOutboxV2)({ - claimNext: () => Effect.succeed(Option.some(claimedEffect)), - get: () => Effect.succeed(Option.some(claimedEffect)), - awaitCancellation: () => Effect.never, - clearCancellation: () => Effect.void, - retry: () => - Ref.updateAndGet(retryAttempts, (count) => count + 1).pipe( - Effect.flatMap((attempt) => - attempt === 1 - ? Effect.fail( - new EffectOutbox.EffectOutboxError({ - operation: "retry", - effectId, - cause: "simulated retry settlement failure", - }), - ) - : Effect.succeed(true), - ), - ), - fail: () => Ref.update(terminalizations, (count) => count + 1).pipe(Effect.as(true)), - }); - const layerExecutor = Layer.succeed( - EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ - execute: () => - Effect.fail( - new EffectWorker.OrchestrationEffectExecutionError({ - effectId, - effectType: claimedEffect.request.type, - cause: "simulated provider execution failure", - }), - ), - }), - ); - const layerWorker = EffectWorker.layerWithOptions({ workerId }).pipe( - Layer.provide(Layer.merge(layerOutbox, layerExecutor)), - ); - - const exit = yield* EffectWorker.OrchestrationEffectWorkerV2.pipe( - Effect.flatMap((worker) => worker.runOnce), - Effect.provide(layerWorker), - Effect.exit, - ); - - assert.isTrue(Exit.isFailure(exit)); - assert.equal(yield* Ref.get(retryAttempts), 2); - assert.equal(yield* Ref.get(terminalizations), 0); - }), -); - -it.effect("keeps a max-attempt replay-safe failure terminal when fail settlement fails", () => - Effect.gen(function* () { - const now = DateTime.formatIso(yield* DateTime.now); - const effectId = "effect:worker-replay-safe-terminal-settlement-failure"; - const workerId = "worker-replay-safe-terminal-settlement-failure"; - const claimedEffect: EffectOutbox.OrchestrationEffectV2 = { - id: effectId, - commandId: CommandId.make("command:worker-replay-safe-terminal-settlement-failure"), - threadId: ThreadId.make("thread:worker-replay-safe-terminal-settlement-failure"), - request: { type: "terminal.cleanup" }, - status: "running", - attemptCount: 5, - availableAt: now, - leaseOwner: workerId, - leaseExpiresAt: now, - createdAt: now, - updatedAt: now, - completedAt: null, - lastError: null, - }; - const failAttempts = yield* Ref.make(0); - const retries = yield* Ref.make(0); - const layerOutbox = Layer.mock(EffectOutbox.EffectOutboxV2)({ - claimNext: () => Effect.succeed(Option.some(claimedEffect)), - get: () => Effect.succeed(Option.some(claimedEffect)), - awaitCancellation: () => Effect.never, - clearCancellation: () => Effect.void, - fail: () => - Ref.updateAndGet(failAttempts, (count) => count + 1).pipe( - Effect.flatMap((attempt) => - attempt === 1 - ? Effect.fail( - new EffectOutbox.EffectOutboxError({ - operation: "fail", - effectId, - cause: "simulated terminal settlement failure", - }), - ) - : Effect.succeed(true), - ), - ), - retry: () => Ref.update(retries, (count) => count + 1).pipe(Effect.as(true)), - }); - const layerExecutor = Layer.succeed( - EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ - execute: () => - Effect.fail( - new EffectWorker.OrchestrationEffectExecutionError({ - effectId, - effectType: claimedEffect.request.type, - cause: "simulated terminal cleanup failure", - }), - ), - }), - ); - const layerWorker = EffectWorker.layerWithOptions({ workerId, maxAttempts: 5 }).pipe( - Layer.provide(Layer.merge(layerOutbox, layerExecutor)), - ); - - const exit = yield* EffectWorker.OrchestrationEffectWorkerV2.pipe( - Effect.flatMap((worker) => worker.runOnce), - Effect.provide(layerWorker), - Effect.exit, - ); - - assert.isTrue(Exit.isFailure(exit)); - assert.equal(yield* Ref.get(failAttempts), 2); - assert.equal(yield* Ref.get(retries), 0); - }), -); - -it.effect("uses durable deadlines, notifications, and a slow liveness poll", () => - Effect.gen(function* () { - const attempts = yield* Ref.make(0); - const available = yield* Queue.unbounded(); - const now = yield* DateTime.now; - const nextClaimableAt = yield* Ref.make>( - Option.some(DateTime.add(now, { milliseconds: 100 })), - ); - const worker = EffectWorker.OrchestrationEffectWorkerV2.of({ - awaitWork: Queue.take(available), - runRecoveryOnce: Effect.succeed(false), - runOnce: Effect.gen(function* () { - const count = yield* Ref.updateAndGet(attempts, (current) => current + 1); - if (count === 2) { - yield* Ref.set(nextClaimableAt, Option.some(DateTime.add(now, { milliseconds: 5_000 }))); - } - if (count === 3) yield* Ref.set(nextClaimableAt, Option.none()); - return false; - }), - nextClaimableAt: Ref.get(nextClaimableAt), - drain: () => Effect.succeed(0), - }); - const awaitAttempts = Effect.fnUntraced(function* (expected: number) { - while ((yield* Ref.get(attempts)) < expected) { - yield* Effect.yieldNow; - } - }); - - yield* EffectWorker.runDaemonWithOptions({ - concurrency: 1, - livenessPollIntervalMs: 1_000, - }).pipe( - Effect.provideService(EffectWorker.OrchestrationEffectWorkerV2, worker), - Effect.forkScoped, - ); - - yield* awaitAttempts(1); - yield* TestClock.adjust("99 millis"); - assert.equal(yield* Ref.get(attempts), 1); - - yield* TestClock.adjust("1 millis"); - yield* awaitAttempts(2); - yield* TestClock.adjust("999 millis"); - assert.equal(yield* Ref.get(attempts), 2); - - yield* Queue.offer(available, undefined); - yield* awaitAttempts(3); - yield* TestClock.adjust("999 millis"); - assert.equal(yield* Ref.get(attempts), 3); - - yield* TestClock.adjust("1 millis"); - yield* awaitAttempts(4); - }).pipe(Effect.provide(TestClock.layer())), -); - -it.effect("does not hot-loop when a claim fails", () => - Effect.gen(function* () { - const attempts = yield* Ref.make(0); - const now = yield* DateTime.now; - const worker = EffectWorker.OrchestrationEffectWorkerV2.of({ - awaitWork: Effect.never, - runRecoveryOnce: Effect.succeed(false), - runOnce: Ref.update(attempts, (count) => count + 1).pipe( - Effect.andThen( - new EffectWorker.OrchestrationEffectWorkerError({ - operation: "claim", - cause: "simulated database failure", - }), - ), - ), - nextClaimableAt: Effect.succeed(Option.some(now)), - drain: () => Effect.succeed(0), - }); - - yield* EffectWorker.runDaemonWithOptions({ - concurrency: 1, - livenessPollIntervalMs: 1_000, - }).pipe( - Effect.provideService(EffectWorker.OrchestrationEffectWorkerV2, worker), - Effect.forkScoped, - ); - - while ((yield* Ref.get(attempts)) < 1) yield* Effect.yieldNow; - yield* TestClock.adjust("999 millis"); - assert.equal(yield* Ref.get(attempts), 1); - yield* TestClock.adjust("1 millis"); - while ((yield* Ref.get(attempts)) < 2) yield* Effect.yieldNow; - }).pipe(Effect.provide(TestClock.layer())), -); - -it.effect("backs off briefly when a due deadline loses a claim race", () => - Effect.gen(function* () { - const attempts = yield* Ref.make(0); - const now = yield* DateTime.now; - const worker = EffectWorker.OrchestrationEffectWorkerV2.of({ - awaitWork: Effect.never, - runRecoveryOnce: Effect.succeed(false), - runOnce: Ref.update(attempts, (count) => count + 1).pipe(Effect.as(false)), - nextClaimableAt: Effect.succeed(Option.some(now)), - drain: () => Effect.succeed(0), - }); - - yield* EffectWorker.runDaemonWithOptions({ - concurrency: 1, - livenessPollIntervalMs: 1_000, - }).pipe( - Effect.provideService(EffectWorker.OrchestrationEffectWorkerV2, worker), - Effect.forkScoped, - ); - - while ((yield* Ref.get(attempts)) < 1) yield* Effect.yieldNow; - yield* TestClock.adjust("24 millis"); - assert.equal(yield* Ref.get(attempts), 1); - yield* TestClock.adjust("1 millis"); - while ((yield* Ref.get(attempts)) < 2) yield* Effect.yieldNow; - }).pipe(Effect.provide(TestClock.layer())), -); - -it.effect("safely retries after replacement cleanup succeeds and start fails", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const events = yield* Ref.make>([]); - const failFirstStart = yield* Ref.make(true); - const effect = restartEffect(now, { - type: "replace", - replacementProviderSessionId: replacementSessionId, - }); - const layer = layerExecutorFor({ events, failFirstStart }); - - const first = yield* Effect.gen(function* () { - const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; - return yield* Effect.exit(executor.execute(effect)); - }).pipe(Effect.provide(layer)); - assert.isTrue(Exit.isFailure(first)); - - yield* Effect.gen(function* () { - const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; - yield* executor.execute(effect); - }).pipe(Effect.provide(layer)); - - assert.deepEqual(yield* Ref.get(events), [ - `interrupt:${replacementSessionId}`, - "detach", - "start", - `interrupt:${replacementSessionId}`, - "detach", - "start", - ]); - }), -); - it.effect("settles a delegated child once its restart continuation fails for good", () => Effect.gen(function* () { const timestamp = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index bb7b76a0ab2c..ebcb50debd11 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -44,10 +44,7 @@ export class OrchestrationEffectExecutionError extends Schema.TaggedError }).pipe(Effect.provide(layerTest)), ); -it.effect("verifies thread membership using only the thread-created partial index", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - const now = DateTime.formatIso(yield* DateTime.now); - yield* sql` - WITH RECURSIVE history(n) AS ( - SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 - ) - INSERT INTO orchestration_events ( - event_id, aggregate_kind, stream_id, stream_version, event_type, - occurred_at, actor_kind, payload_json, metadata_json, application_event_version - ) - SELECT 'history:' || n, 'thread', 'thread:history', n, - 'provider-session.detached', ${now}, 'server', '{}', '{}', 2 - FROM history - `; - let membershipQuery: string | undefined; - 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); - const query = span.attributes.get("db.query.text"); - if ( - typeof query === "string" && - query.includes("SELECT DISTINCT stream_id AS thread_id") - ) { - membershipQuery = query; - } - }; - return span; - }, - }); - yield* maintenance.verify.pipe(Effect.withTracer(tracer)); - assert.isDefined(membershipQuery); - const plan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${membershipQuery}`, - ); - const details = plan.map((row) => row.detail).join("\n"); - assert.match(details, /USING (?:COVERING )?INDEX orchestration_events_v2_created_threads_idx/); - assert.notMatch(details, /idx_orch_events_stream_sequence|TEMP B-TREE/); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("keeps other database work runnable while discovering compaction candidates", () => - Effect.scoped( - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - const now = DateTime.formatIso(yield* DateTime.now); - yield* sql` - WITH RECURSIVE history(n) AS ( - SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 2001 - ) - INSERT INTO orchestration_events ( - event_id, aggregate_kind, stream_id, stream_version, event_type, - occurred_at, actor_kind, payload_json, metadata_json, application_event_version - ) - SELECT 'retained:' || n, 'thread', 'thread:retained', n, - 'provider-session.detached', ${now}, 'server', '{}', '{}', 2 - FROM history - `; - yield* sql` - WITH RECURSIVE history(n) AS ( - SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 2001 - ) - INSERT INTO orchestration_command_receipts ( - command_id, aggregate_kind, aggregate_id, accepted_at, result_sequence, status, command_type - ) - SELECT 'retained:' || n, 'thread', 'thread:retained', ${now}, n, 'accepted', 'thread.create' - FROM history - `; - const discoveryStarted = { - events: yield* Deferred.make(), - receipts: yield* Deferred.make(), - }; - const queries = { events: 0, receipts: 0 }; - let finished = false; - 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); - const query = span.attributes.get("db.query.text"); - if (typeof query !== "string" || !query.trimStart().startsWith("SELECT")) return; - if (query.includes("MAX(")) return; - const table = query.includes("FROM orchestration_command_receipts") - ? "receipts" - : query.includes("FROM orchestration_events") - ? "events" - : undefined; - if (table === undefined) return; - queries[table] += 1; - Deferred.doneUnsafe(discoveryStarted[table], Effect.void); - }; - return span; - }, - }); - const probes = yield* Effect.forEach(["events", "receipts"] as const, (table) => - Effect.gen(function* () { - yield* Deferred.await(discoveryStarted[table]); - const result = yield* sql<{ readonly responsive: number }>`SELECT 1 AS responsive`; - assert.equal(result[0]?.responsive, 1); - return { table, queriesAtProbe: queries[table], finished }; - }).pipe(Effect.forkScoped), - ); - - const summary = yield* maintenance.compactEventStore.pipe( - Effect.withTracer(tracer), - Effect.provideService(Scheduler.MaxOpsBeforeYield, Number.POSITIVE_INFINITY), - Effect.tap(() => - Effect.sync(() => { - finished = true; - }), - ), - ); - assert.equal(summary.deletedEventCount, 0); - assert.equal(summary.deletedReceiptCount, 0); - for (const probe of probes) { - const result = yield* Fiber.join(probe); - assert.isFalse(result.finished); - assert.isAtLeast(result.queriesAtProbe, 1); - assert.isBelow(result.queriesAtProbe, queries[result.table]); - } - }), - ).pipe(Effect.provide(layerTest)), -); - it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { it.effect("projects oversized tool bodies before both replay and live RPC retention", () => Effect.scoped( @@ -449,31 +316,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { ), ); - it.effect("traces an event write and its transaction without its SQL statements", () => - Effect.gen(function* () { - const sink = yield* EventSink.EventSinkV2; - const now = yield* DateTime.now; - const thread = makeThread(ThreadId.make("thread:untraced-write-statements"), now); - const names: Array = []; - const tracer = Tracer.make({ - span: (options) => { - names.push(options.name); - return new Tracer.NativeSpan(options); - }, - }); - - yield* sink - .write({ - events: [ - threadCreatedEvent({ id: "event:untraced-write-statements:created", thread, now }), - ], - }) - .pipe(Effect.withTracer(tracer)); - - assert.deepEqual(names, ["orchestrationV2.EventSink.write", "sql.transaction"]); - }), - ); - it.effect.each(["high-water", "replay"] as const)( "bounds live events while the V2 %s query is blocked", (phase) => @@ -1507,45 +1349,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }), ); - it.effect("does not wake claimers for effects from an idempotent command retry", () => - Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const now = yield* DateTime.now; - const commandId = CommandId.make("command:foundation-idempotent-wakeup"); - const threadId = ThreadId.make("thread:foundation-idempotent-wakeup"); - const input = { - commandId, - threadId, - commandType: "foundation.idempotent-wakeup", - acceptedAt: now, - events: [ - threadCreatedEvent({ - id: "event:foundation-idempotent-wakeup", - thread: makeThread(threadId, now), - now, - }), - ], - effects: [ - { - id: "effect:foundation-idempotent-wakeup", - commandId, - threadId, - request: { type: "terminal.cleanup" as const }, - }, - ], - }; - - assert.isTrue((yield* eventSink.commitCommand(input)).committed); - yield* outbox.awaitAvailable; - assert.isFalse((yield* eventSink.commitCommand(input)).committed); - - const unexpectedWake = yield* outbox.awaitAvailable.pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.isUndefined(unexpectedWake.pollUnsafe()); - }).pipe(Effect.provide(Layer.fresh(layerTest))), - ); - it.effect("does not publish a stale provider start after an interrupt wins", () => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; @@ -1962,60 +1765,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }), ); - it.effect("treats cancellation between execution and settlement as a normal outcome", () => - Effect.gen(function* () { - const effectId = "effect:foundation-cancel-before-settlement"; - const threadId = ThreadId.make("thread:foundation-cancel-before-settlement"); - const commandId = CommandId.make("command:foundation-cancel-before-settlement"); - const now = DateTime.formatIso(yield* DateTime.now); - const claimedEffect = { - id: effectId, - commandId, - threadId, - request: { type: "terminal.cleanup" as const }, - status: "running" as const, - attemptCount: 1, - availableAt: now, - leaseOwner: "settlement-race-worker", - leaseExpiresAt: now, - createdAt: now, - updatedAt: now, - completedAt: null, - lastError: null, - }; - const layerOutbox = Layer.mock(EffectOutbox.EffectOutboxV2)({ - claimNext: () => Effect.succeed(Option.some(claimedEffect)), - awaitCancellation: () => Effect.never, - clearCancellation: () => Effect.void, - succeed: () => Effect.succeed(false), - get: () => - Effect.succeed( - Option.some({ - ...claimedEffect, - status: "cancelled" as const, - leaseOwner: null, - leaseExpiresAt: null, - completedAt: now, - }), - ), - }); - const layerExecutor = Layer.succeed( - EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ execute: () => Effect.void }), - ); - const layerWorker = EffectWorker.layerWithOptions({ - workerId: "settlement-race-worker", - }).pipe(Layer.provide(Layer.merge(layerOutbox, layerExecutor))); - - assert.isTrue( - yield* EffectWorker.OrchestrationEffectWorkerV2.pipe( - Effect.flatMap((worker) => worker.runOnce), - Effect.provide(layerWorker), - ), - ); - }), - ); - it.effect("does not start an effect that was cancelled during claim registration", () => Effect.gen(function* () { const effectId = "effect:foundation-cancelled-during-claim"; @@ -2367,69 +2116,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }).pipe(Effect.provide(layerIsolatedOutbox)), ); - it.effect("prunes settled effects hourly from the layer-owned worker", () => - Effect.gen(function* () { - const outbox = yield* EffectOutbox.EffectOutboxV2; - const commandId = CommandId.make("command:foundation-prune-worker"); - const threadId = ThreadId.make("thread:foundation-prune-worker"); - const workerId = "prune-worker"; - const request = { type: "terminal.cleanup" } as const; - yield* outbox.enqueue([{ id: "effect:prune-worker:done", commandId, threadId, request }]); - yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); - assert.isTrue(yield* outbox.succeed({ effectId: "effect:prune-worker:done", workerId })); - yield* outbox.enqueue([{ id: "effect:prune-worker:pending", commandId, threadId, request }]); - const runs = yield* Queue.unbounded(); - const observed = EffectOutbox.EffectOutboxV2.of({ - ...outbox, - pruneSettled: outbox.pruneSettled.pipe(Effect.tap((pruned) => Queue.offer(runs, pruned))), - }); - const ids = Effect.map(outbox.listByCommandId(commandId), (rows) => - rows.map((row) => row.id).toSorted(), - ); - - yield* TestClock.adjust( - Duration.subtract(EffectOutbox.SETTLED_EFFECT_RETENTION, Duration.minutes(30)), - ); - yield* Layer.build( - EffectOutbox.layerPruneWorker.pipe( - Layer.provide(Layer.succeed(EffectOutbox.EffectOutboxV2, observed)), - ), - ); - assert.equal(yield* Queue.take(runs), 0); - assert.deepEqual(yield* ids, ["effect:prune-worker:done", "effect:prune-worker:pending"]); - - yield* TestClock.adjust("1 hour"); - assert.equal(yield* Queue.take(runs), 1); - assert.deepEqual(yield* ids, ["effect:prune-worker:pending"]); - }).pipe(Effect.scoped, Effect.provide(layerIsolatedOutbox)), - ); - - it.effect("does not emit a SQL span for an empty safety claim", () => - Effect.gen(function* () { - const outbox = yield* EffectOutbox.EffectOutboxV2; - const statements: 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); - const query = span.attributes.get("db.query.text"); - if (query !== undefined) statements.push(query); - }; - return span; - }, - }); - - const claim = yield* outbox - .claimNext({ workerId: "idle-safety-worker", leaseDurationMs: 30_000 }) - .pipe(Effect.withTracer(tracer)); - - assert.isTrue(Option.isNone(claim)); - assert.deepEqual(statements, []); - }).pipe(Effect.provide(Layer.fresh(layerEffectOutboxProvided))), - ); - it.effect("wakes claimers when cancellation unblocks same-thread work", () => Effect.gen(function* () { const outbox = yield* EffectOutbox.EffectOutboxV2; @@ -2555,73 +2241,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }).pipe(Effect.provide(Layer.fresh(layerEffectOutboxProvided))), ); - it.effect("executes a retry at its durable deadline instead of the liveness interval", () => - Effect.gen(function* () { - const outbox = yield* EffectOutbox.EffectOutboxV2; - const effectId = "effect:foundation-durable-retry-deadline"; - const commandId = CommandId.make("command:foundation-durable-retry-deadline"); - const threadId = ThreadId.make("thread:foundation-durable-retry-deadline"); - const executions = yield* Ref.make(0); - const completed = yield* Deferred.make(); - const layerExecutor = Layer.succeed( - EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ - execute: () => - Effect.gen(function* () { - const attempt = yield* Ref.updateAndGet(executions, (count) => count + 1); - if (attempt === 1) { - return yield* new EffectWorker.OrchestrationEffectExecutionError({ - effectId, - effectType: "terminal.cleanup", - cause: "simulated retry", - }); - } - yield* Deferred.succeed(completed, undefined); - }), - }), - ); - const layerWorker = EffectWorker.layerWithOptions({ - workerId: "durable-retry-deadline-worker", - }).pipe( - Layer.provide( - Layer.merge(Layer.succeed(EffectOutbox.EffectOutboxV2, outbox), layerExecutor), - ), - ); - - yield* Effect.gen(function* () { - yield* EffectWorker.runDaemonWithOptions({ - concurrency: 1, - livenessPollIntervalMs: 30_000, - }).pipe(Effect.forkScoped); - yield* outbox.enqueue([ - { - id: effectId, - commandId, - threadId, - request: { type: "terminal.cleanup" }, - }, - ]); - yield* outbox.notifyAvailable(); - - let retryScheduled = false; - while (!retryScheduled) { - const effect = yield* outbox.get(effectId); - retryScheduled = - Option.isSome(effect) && - effect.value.status === "pending" && - effect.value.attemptCount === 1; - if (!retryScheduled) yield* Effect.yieldNow; - } - - yield* TestClock.adjust("99 millis"); - assert.equal(yield* Ref.get(executions), 1); - yield* TestClock.adjust("1 millis"); - yield* Deferred.await(completed); - assert.equal(yield* Ref.get(executions), 2); - }).pipe(Effect.provide(layerWorker), Effect.scoped); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("runs distinct threads concurrently while serializing effects within a thread", () => Effect.gen(function* () { const outbox = yield* EffectOutbox.EffectOutboxV2; @@ -3429,50 +3048,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { ); }); -it.live("keeps claiming new work after repeated idle periods", () => - Effect.gen(function* () { - const outbox = yield* EffectOutbox.EffectOutboxV2; - const completed = new Map>(); - const layerExecutor = Layer.succeed( - EffectWorker.OrchestrationEffectExecutorV2, - EffectWorker.OrchestrationEffectExecutorV2.of({ - execute: (effect) => { - const completion = completed.get(effect.id); - return completion === undefined - ? Effect.die(`Missing completion signal for ${effect.id}`) - : Deferred.succeed(completion, undefined).pipe(Effect.asVoid); - }, - }), - ); - const layerWorker = EffectWorker.layerWithOptions({ - workerId: "idle-wave-worker", - }).pipe( - Layer.provide(Layer.merge(Layer.succeed(EffectOutbox.EffectOutboxV2, outbox), layerExecutor)), - ); - - yield* Effect.gen(function* () { - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - for (let wave = 1; wave <= 6; wave += 1) { - yield* Effect.sleep("125 millis"); - const effectId = `effect:foundation-idle-wave:${wave}`; - const completion = yield* Deferred.make(); - completed.set(effectId, completion); - yield* outbox.enqueue([ - { - id: effectId, - commandId: CommandId.make(`command:foundation-idle-wave:${wave}`), - threadId: ThreadId.make(`thread:foundation-idle-wave:${wave}`), - request: { type: "terminal.cleanup" }, - }, - ]); - yield* outbox.notifyAvailable(); - const observed = yield* Deferred.await(completion).pipe(Effect.timeoutOption("2 seconds")); - assert.isTrue(Option.isSome(observed), `worker stopped before idle wave ${wave}`); - } - }).pipe(Effect.provide(layerWorker), Effect.scoped); - }).pipe(Effect.provide(layerTest)), -); - it.effect("publishes live events in commit order across concurrent writers", () => Effect.gen(function* () { const firstCommitted = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts deleted file mode 100644 index a544fa6aaa17..000000000000 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ /dev/null @@ -1,244 +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, - MessageId, - type OrchestrationV2ThreadProjection, - 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"; -import { GROK_MODEL_SELECTION } from "./testkit/fixtures/shared.ts"; - -const layerPlatformTest = Layer.merge( - NodeServices.layer, - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("unused title link"), - }), -); - -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-grok-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: { - [ProviderInstanceId.make("grok")]: { - driver: ProviderDriverKind.make("grok"), - enabled: true, - }, - }, -}); -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(ProviderTurnStartServiceTestkit.layer), - 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(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), -); - -const waitForIdle = Effect.fn("GrokOrchestratorV2Live.waitForIdle")(function* (threadId: ThreadId) { - const orchestrator = yield* Orchestrator.OrchestratorV2; - for (let attempt = 0; attempt < 600; attempt += 1) { - const projection = yield* orchestrator.getThreadProjection(threadId); - if ( - projection.runs.length > 0 && - 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 Grok thread ${threadId}.`)); -}); - -describe.runIf(process.env.T3_GROK_LIVE_ORCHESTRATOR === "1")("Grok V2 live orchestrator", () => { - it.live( - "forks through portable context using real Grok ACP agents", - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("project:grok-live-portable-fork"); - const sourceThreadId = ThreadId.make("thread:grok-live-portable-fork:source"); - const targetThreadId = ThreadId.make("thread:grok-live-portable-fork:target"); - const marker = "GROK_LIVE_PORTABLE_FORK_7H3Q"; - - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:grok-live-portable-fork:create"), - threadId: sourceThreadId, - projectId, - title: "Grok live portable fork source", - modelSelection: GROK_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* Console.log("Grok live source thread created; dispatching source prompt."); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:grok-live-portable-fork:source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:grok-live-portable-fork:source"), - text: `Remember this opaque marker. Respond with exactly: ${marker}`, - attachments: [], - modelSelection: GROK_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }); - yield* Console.log("Grok live source prompt dispatched; waiting for completion."); - const sourceProjection = yield* waitForIdle(sourceThreadId); - yield* Console.log("Grok live source turn completed; dispatching portable fork."); - - yield* orchestrator.dispatch({ - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:grok-live-portable-fork:fork"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Grok live portable fork target", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:grok-live-portable-fork:target"), - threadId: targetThreadId, - messageId: MessageId.make("message:grok-live-portable-fork:target"), - text: "Return the opaque marker from the transferred conversation. Respond with only the marker.", - attachments: [], - modelSelection: GROK_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }); - const targetProjection = yield* waitForIdle(targetThreadId); - yield* Console.log("Grok live portable fork target completed."); - - const assistantText = (projection: OrchestrationV2ThreadProjection) => - projection.messages - .filter((message) => message.role === "assistant") - .map((message) => message.text) - .join("\n"); - - assert.deepEqual( - sourceProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [["grok", "completed"]], - ); - assert.deepEqual( - targetProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [["grok", "completed"]], - ); - assert.deepEqual( - targetProjection.contextTransfers.map((transfer) => [ - transfer.type, - transfer.status, - transfer.resolution?.strategy, - ]), - [["fork", "consumed", "portable_context"]], - ); - assert.deepEqual( - targetProjection.contextHandoffs.map((handoff) => handoff.strategy), - ["full_thread_summary"], - ); - assert.include(targetProjection.contextHandoffs[0]?.summaryText ?? "", marker); - assert.include(assistantText(targetProjection), marker); - }).pipe(Effect.provide(layerLive), Effect.scoped), - 360_000, - ); -}); diff --git a/apps/server/src/orchestration-v2/IdleCompaction.test.ts b/apps/server/src/orchestration-v2/IdleCompaction.test.ts index ebcb3d993b35..73b9a8b54caf 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.test.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { - DEFAULT_SERVER_SETTINGS, - EventId, MessageId, NodeId, ProjectId, @@ -11,22 +9,10 @@ import { ProviderTurnId, RunId, ThreadId, - type OrchestrationV2DomainEvent, - type OrchestrationV2ServerCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Queue from "effect/Queue"; -import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as ServerSettings from "../serverSettings.ts"; import { idleCompactionWindow, type IdleCompactionRecords } from "./IdleCompaction.ts"; -import * as IdleCompactionService from "./IdleCompactionService.ts"; -import * as Orchestrator from "./Orchestrator.ts"; -import * as ProjectionStore from "./ProjectionStore.ts"; const NOW_MS = Date.parse("2026-10-19T12:00:00.000Z"); const MINUTE_MS = 60_000; @@ -148,18 +134,6 @@ function records( } describe("idleCompactionWindow", () => { - it("starts two minutes before the cache Claude reported expires", () => { - expect(idleCompactionWindow(records({ cacheExpiresInMs: 60 * MINUTE_MS }), NOW_MS)).toEqual({ - runId, - startMs: NOW_MS + 58 * MINUTE_MS, - endMs: NOW_MS + 60 * MINUTE_MS, - }); - // A 5m cache whose last request started 3 minutes before the run ended. - const late = records({ cacheExpiresInMs: 2 * MINUTE_MS }); - expect(idleCompactionWindow(late, NOW_MS)).toMatchObject({ startMs: NOW_MS }); - expect(idleCompactionWindow(late, NOW_MS + 2 * MINUTE_MS)).toBeNull(); - }); - it("skips small contexts, unknown expiry, other providers, switched threads, and compactions", () => { const cache = { cacheExpiresInMs: 60 * MINUTE_MS }; expect(idleCompactionWindow(records({ ...cache, usedTokens: 199_999 }), NOW_MS)).toBeNull(); @@ -180,90 +154,3 @@ describe("idleCompactionWindow", () => { expect(idleCompactionWindow(archived, NOW_MS)).toBeNull(); }); }); - -const seed = Effect.fn("seedIdleThread")(function* (value: IdleCompactionRecords) { - const store = yield* ProjectionStore.ProjectionStoreV2; - const event = { threadId, occurredAt: at() }; - yield* store.apply({ - ...event, - id: EventId.make("e1"), - type: "thread.created", - payload: value.thread, - }); - yield* store.apply({ - ...event, - id: EventId.make("e2"), - type: "run.created", - payload: value.runs[0]!, - }); - yield* store.apply({ - ...event, - id: EventId.make("e3"), - type: "message.updated", - payload: value.messages[0]!, - }); - yield* store.apply({ - ...event, - id: EventId.make("e4"), - type: "provider-thread.updated", - payload: value.providerThreads[0]!, - }); - yield* store.apply({ - ...event, - id: EventId.make("e5"), - type: "provider-turn.updated", - payload: value.providerTurns[0]!, - }); -}); - -it.effect("sends /compact as T3 Code once the cache is about to expire", () => - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const value = records({ cacheExpiresInMs: 5 * MINUTE_MS }); - yield* seed(value); - const events = yield* Queue.unbounded(); - const commands = yield* Queue.unbounded(); - const reads = yield* Queue.unbounded(); - const store = yield* ProjectionStore.ProjectionStoreV2; - const dependencies = Layer.mergeAll( - Layer.succeed(ProjectionStore.ProjectionStoreV2, { - ...store, - getThreadRecords: (...args) => - store.getThreadRecords(...args).pipe(Effect.tap(() => Queue.offer(reads, undefined))), - }), - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed({ ...DEFAULT_SERVER_SETTINGS, compactIdleThreads: true }), - }), - Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.fromQueue(events), - dispatch: (command) => - Queue.offer(commands, command).pipe(Effect.as({ sequence: 1, storedEvents: [] })), - }), - ); - yield* Effect.gen(function* () { - const service = yield* IdleCompactionService.make; - yield* service.start(); - yield* Queue.offer(events, { - id: EventId.make("completed"), - type: "run.updated", - threadId, - occurredAt: at(), - payload: value.runs[0]!, - }); - yield* Queue.take(reads); - yield* TestClock.adjust("2 minutes"); - expect(yield* Queue.size(commands)).toBe(0); - yield* TestClock.adjust("1 minute"); - expect(yield* Queue.take(commands)).toMatchObject({ - type: "message.dispatch", - threadId, - text: "/compact", - createdBy: "agent", - creationSource: "server", - }); - }).pipe(Effect.provide(dependencies)); - }).pipe( - Effect.provide(ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory))), - Effect.scoped, - ), -); diff --git a/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts b/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts index ba19ed3227b9..be612837c6ed 100644 --- a/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts +++ b/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts @@ -8,7 +8,6 @@ import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; import { - bufferLiveStream, bufferLatestLiveStream, makeLiveStreamBudget, replayAndBufferLiveEvents, @@ -52,28 +51,6 @@ describe("LiveStreamBudget", () => { ); }); -it.effect("stops draining a slow subscriber when its unacknowledged tail fills", () => - Effect.scoped( - Effect.gen(function* () { - const input = yield* Queue.unbounded<{ text: string }>(); - const sourceClosed = yield* Deferred.make(); - const pull = yield* Stream.toPull( - bufferLiveStream( - Stream.fromQueue(input).pipe(Stream.ensuring(Deferred.succeed(sourceClosed, undefined))), - { maxItems: 2 }, - ), - ); - yield* Queue.offer(input, { text: "first" }); - expect(yield* pull).toEqual([{ text: "first" }]); - yield* Queue.offerAll(input, [{ text: "second" }, { text: "third" }]); - yield* Deferred.await(sourceClosed); - const next = yield* pull.pipe(Effect.result); - expect(next._tag).toBe("Failure"); - if (next._tag === "Failure") expect(next.failure._tag).toBe("LiveStreamBufferError"); - }), - ), -); - type Event = { readonly sequence: number; readonly text: string; readonly threadId?: string }; describe("bufferLatestLiveStream", () => { @@ -155,42 +132,6 @@ describe("replayAndBufferLiveEvents", () => { ), ); - it.effect.each(["items", "bytes"] as const)( - "counts an unacknowledged replay batch toward the live %s limit", - (limit) => - Effect.scoped( - Effect.gen(function* () { - const pubsub = yield* PubSub.unbounded(); - const subscribed = yield* Deferred.make>(); - const replayed = { sequence: 1, text: "replay" }; - const live = { sequence: 2, text: "live" }; - const pull = yield* Stream.toPull( - replayAndBufferLiveEvents( - { - subscribe: PubSub.subscribe(pubsub).pipe( - Effect.tap((subscription) => Deferred.succeed(subscribed, subscription)), - ), - latestSequence: Effect.succeed(1), - replay: () => Stream.succeed(replayed), - }, - limit === "items" - ? { maxItems: 1 } - : { - maxSerializedBytes: 40, - }, - ), - ); - expect(yield* pull).toEqual([replayed]); - yield* PubSub.publish(pubsub, live); - yield* (yield* Deferred.await(subscribed)).shutdownHook.await; - expect(yield* PubSub.size(pubsub)).toBe(0); - const result = yield* pull.pipe(Effect.result); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError"); - }), - ), - ); - it.effect("lets a fast reader replay a database page larger than both buffer limits", () => Effect.scoped( Effect.gen(function* () { @@ -251,57 +192,4 @@ describe("replayAndBufferLiveEvents", () => { }), ), ); - - it.effect( - "caps raw and projected stages while projection and client acknowledgement are blocked", - () => - Effect.scoped( - Effect.gen(function* () { - const pubsub = yield* PubSub.unbounded(); - const subscribed = yield* Deferred.make>(); - const projectionStarted = yield* Deferred.make(); - const projectionClosed = yield* Deferred.make(); - const releaseProjection = yield* Deferred.make(); - const source = replayAndBufferLiveEvents( - { - subscribe: PubSub.subscribe(pubsub).pipe( - Effect.tap((subscription) => Deferred.succeed(subscribed, subscription)), - ), - latestSequence: Effect.succeed(0), - replay: () => Stream.empty, - }, - { maxItems: 2 }, - ).pipe( - Stream.mapEffect((event) => - event.sequence === 3 - ? Deferred.succeed(projectionStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseProjection)), - Effect.as(event), - ) - : Effect.succeed(event), - ), - Stream.ensuring(Deferred.succeed(projectionClosed, undefined)), - ); - const pull = yield* Stream.toPull(bufferLiveStream(source, { maxItems: 2 })); - const firstPull = yield* pull.pipe(Effect.forkScoped); - const subscription = yield* Deferred.await(subscribed); - yield* PubSub.publish(pubsub, { sequence: 1, text: "awaiting ACK" }); - expect(yield* Fiber.join(firstPull)).toEqual([{ sequence: 1, text: "awaiting ACK" }]); - yield* PubSub.publish(pubsub, { sequence: 2, text: "projected tail" }); - yield* PubSub.publish(pubsub, { sequence: 3, text: "projecting" }); - yield* Deferred.await(projectionStarted); - yield* PubSub.publishAll(pubsub, [ - { sequence: 4, text: "raw tail" }, - { sequence: 5, text: "overflow" }, - ]); - yield* subscription.shutdownHook.await; - expect(yield* PubSub.size(pubsub)).toBe(0); - yield* Deferred.succeed(releaseProjection, undefined); - yield* Deferred.await(projectionClosed); - const result = yield* pull.pipe(Effect.result); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError"); - }), - ), - ); }); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 18037701cbeb..187c25935a07 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -15,8 +15,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import { OPENCODE2_HTTP_PROTOCOL, @@ -27,10 +25,7 @@ import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime. import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; -import { - decodeProviderReplayNdjson, - readProviderReplayTranscript, -} from "@t3tools/provider-testing/replayTranscript"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; @@ -47,7 +42,6 @@ const BACKGROUND_PROMPT = const instanceId = ProviderInstanceId.make("opencode"); const bigPickle: ModelSelection = { instanceId, model: "opencode/big-pickle" }; const mimo: ModelSelection = { instanceId, model: "opencode/mimo-v2.6-flash-free" }; -const nemotron: ModelSelection = { instanceId, model: "opencode/nemotron-3.5-lightning-free" }; const out = (type: string, input?: unknown): ProviderReplayEntry => ({ type: "expect_outbound", @@ -118,19 +112,6 @@ const agentList = (directory: string) => ({ ], }); -const noOpenRequests: ReadonlyArray = [ - out("permission.list", { sessionID: SESSION }), - reply("permission.list", { data: [] }), - out("session.form.list", { sessionID: SESSION }), - reply("session.form.list", { data: [] }), -]; -const supervisedRules = (name: string) => [ - { action: "shell", resource: "*", effect: "ask" }, - { action: "edit", resource: "*", effect: "ask" }, - { action: "external_directory", resource: "*", effect: "ask" }, - ...BUILD_PATHS, - ...mcpRules(name), -]; const autoEditRules = (name: string) => [ { action: "shell", resource: "*", effect: "ask" }, { action: "edit", resource: "*", effect: "allow" }, @@ -186,16 +167,6 @@ const answeredPrompt = (text: string): ReadonlyArray => [ event("session.execution.succeeded", { sessionID: SESSION }), ]; -const directoryModels = (directory: string): ReadonlyArray => [ - out("model.list", { "location[directory]": directory }), - reply("model.list", { - location: { directory }, - data: [ - catalogModel("big-pickle", "Big Pickle"), - catalogModel("mimo-v2.6-flash-free", "MiMo V2.6 Flash Free"), - ], - }), -]; /** A `/api/model` entry as 2.0.18 lists it; a known window means no re-read after a turn. */ const catalogModel = (id: string, name: string) => ({ id, @@ -381,132 +352,6 @@ describe("OpenCode 2 through the orchestrator", () => { }).pipe(Effect.scoped), ); - it.effect("moves the session to the thread's new worktree before the next prompt", () => - Effect.gen(function* () { - const name = "opencode2-worktree-move"; - const before = yield* checkpointWorkspace(`${name}-before`); - const after = yield* checkpointWorkspace(`${name}-after`); - const thread = threadCommands({ name, worktreePath: before }); - const projection = yield* runScenario({ - name, - threadId: thread.threadId, - entries: [ - ...createdSession(before, name), - ...instructionsWritten, - ...answeredPrompt("FIRST"), - ...directoryModels(after), - out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(before, t3Rules(name), name)), - - ...noOpenRequests, - out("session.move", { sessionID: SESSION, directory: after }), - reply("session.move", null), - - ...instructionsWritten, - ...answeredPrompt("SECOND"), - ], - commands: [ - thread.create, - thread.message("first"), - { - type: "thread.metadata.update", - commandId: thread.command("worktree"), - threadId: thread.threadId, - worktreePath: after, - }, - thread.message("second"), - ], - }); - assert.deepEqual( - projection.runs.map((run) => run.status), - ["completed", "completed"], - ); - assert.lengthOf(projection.providerThreads, 1); - }).pipe(Effect.scoped), - ); - - it.effect("gives a session made with older rules T3's rules before its next prompt", () => - Effect.gen(function* () { - const name = "opencode2-resume-rules"; - const before = yield* checkpointWorkspace(`${name}-before`); - const after = yield* checkpointWorkspace(`${name}-after`); - const thread = threadCommands({ name, worktreePath: before }); - const projection = yield* runScenario({ - name, - threadId: thread.threadId, - entries: [ - ...createdSession(before, name), - ...instructionsWritten, - ...answeredPrompt("FIRST"), - ...directoryModels(after), - - out("session.get", { sessionID: SESSION }), - reply( - "session.get", - sessionInfo( - before, - [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, - ], - name, - ), - ), - ...noOpenRequests, - out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), - reply("session.update", null), - out("session.move", { sessionID: SESSION, directory: after }), - reply("session.move", null), - ...instructionsWritten, - ...answeredPrompt("SECOND"), - ], - commands: [ - thread.create, - thread.message("first"), - { - type: "thread.metadata.update", - commandId: thread.command("worktree"), - threadId: thread.threadId, - worktreePath: after, - }, - thread.message("second"), - ], - }); - assert.deepEqual( - projection.runs.map((run) => run.status), - ["completed", "completed"], - ); - }).pipe(Effect.scoped), - ); - - it.effect( - "creates a Supervised thread's session with rules that ask before shell and edits", - () => - Effect.gen(function* () { - const name = "opencode2-supervised-rules"; - const cwd = yield* checkpointWorkspace(name); - const thread = threadCommands({ - name, - worktreePath: cwd, - runtimeMode: "approval-required", - }); - const projection = yield* runScenario({ - name, - threadId: thread.threadId, - entries: [ - ...createdSession(cwd, name, supervisedRules(name), true), - ...instructionsWritten, - ...answeredPrompt("FIRST"), - ], - commands: [thread.create, thread.message("first")], - }); - assert.deepEqual( - projection.runs.map((run) => run.status), - ["completed"], - ); - }).pipe(Effect.scoped), - ); - it.effect("rewrites the session's rules when the thread's mode changes between turns", () => Effect.gen(function* () { const name = "opencode2-mode-change"; @@ -1103,58 +948,4 @@ describe("OpenCode 2 through the orchestrator", () => { assert.deepEqual(shell?.pendingBackgroundTasks ?? [], []); }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), ); - - it.effect( - "runs plan mode as OpenCode's plan agent and switches back before the next prompt", - () => - Effect.gen(function* () { - const name = "opencode2_switch"; - const cwd = yield* checkpointWorkspace(name); - const thread = threadCommands({ name, worktreePath: cwd }); - - const recorded = yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const text = yield* fs.readFileString( - yield* path.fromFileUrl( - new URL( - "./testkit/fixtures/opencode2_switch/opencode_transcript.ndjson", - import.meta.url, - ), - ), - ); - return yield* decodeProviderReplayNdjson(text.replaceAll("", cwd)); - }).pipe(Effect.provide(NodeServices.layer)); - const projection = yield* runScenario({ - name, - threadId: thread.threadId, - entries: recorded.entries, - commands: [ - thread.create, - thread.interactionMode("mode-plan", "plan"), - thread.message( - "plan", - bigPickle, - "Create a file named plan_probe.txt containing HI using the write tool.", - ), - thread.interactionMode("mode-default", "default"), - thread.message("switched", nemotron, "Reply exactly SWITCHED."), - ], - }); - assert.deepEqual( - projection.runs.map((run) => [run.status, run.modelSelection.model]), - [ - ["completed", bigPickle.model], - ["completed", nemotron.model], - ], - ); - const replies = projection.turnItems.flatMap((item) => - item.type === "assistant_message" ? [item.text] : [], - ); - - assert.include(replies[0], "Plan mode"); - assert.equal(replies[1], "SWITCHED"); - assert.lengthOf(projection.providerThreads, 1); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts deleted file mode 100644 index ee4560d97d73..000000000000 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ /dev/null @@ -1,1116 +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 * as NodeSqlite from "node:sqlite"; -import { assert, it } from "@effect/vitest"; -import { - CommandId, - EnvironmentId, - MessageId, - type ModelSelection, - type OrchestrationV2Command, - type OrchestrationV2ThreadProjection, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; -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 { FetchHttpClient, HttpClient } from "effect/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { describe } from "vite-plus/test"; - -import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; -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 OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; -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 EffectWorker from "./EffectWorker.ts"; -import * as Orchestrator from "./Orchestrator.ts"; -import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; -import * as RuntimeLayer from "./runtimeLayer.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; -import * as ProviderContinuationService from "./ProviderContinuationService.ts"; -import * as ThreadManagementService from "./ThreadManagementService.ts"; -import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; - -const binaryPath = process.env.OPENCODE2_BIN; -const ROOT = process.env.OPENCODE2_LIVE_ROOT ?? ""; -const INSTANCE = ProviderInstanceId.make("opencode"); -const MODEL: ModelSelection = { - instanceId: INSTANCE, - model: process.env.OPENCODE2_MODEL ?? "opencode/big-pickle", -}; -const SWITCHED_MODEL = "opencode/mimo-v2.6-flash-free"; - -const spawnedPids: Array = []; -const layerSpawnedServers = Layer.succeed( - OpenCodeServerLedger.OpenCodeServerLedger, - OpenCodeServerLedger.OpenCodeServerLedger.of({ - track: ({ pid }) => - Effect.sync(() => { - spawnedPids.push(pid); - return Effect.void; - }), - }), -); - -const MCP_URL = process.env.OPENCODE2_MCP_URL ?? "http://127.0.0.1/mcp"; -const layerMcpRegistry = Layer.succeed( - McpSessionRegistry.McpSessionRegistry, - McpSessionRegistry.McpSessionRegistry.of({ - issue: ({ threadId, providerInstanceId }) => - Effect.succeed({ - config: { - environmentId: EnvironmentId.make("environment:opencode2-live"), - threadId, - providerSessionId: `mcp-live:${threadId}`, - providerInstanceId, - endpoint: MCP_URL, - authorizationHeader: `Bearer mcp-live:${threadId}`, - browserToolsAvailable: false, - }, - }), - resolve: () => Effect.succeed(undefined), - touch: () => Effect.void, - revokeProviderSession: () => Effect.void, - revokeThread: () => Effect.void, - revokeAll: Effect.void, - }), -); - -const layerPlatformTest = Layer.merge( - NodeServices.layer, - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("unused title link"), - }), -); -const layerServerConfig = ServerConfig.layerTest(`${ROOT}/work`, { prefix: "t3-opencode2-live-" }); -const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( - Layer.provide(VcsProcess.layer), - Layer.provide(layerServerConfig), - Layer.provide(layerPlatformTest), -); -const layerServerSettings = ServerSettings.layerTest({ - providerInstances: { - [INSTANCE]: { - driver: ProviderDriverKind.make("opencode"), - enabled: true, - environment: [ - { name: "HOME", value: ROOT }, - { name: "XDG_CONFIG_HOME", value: `${ROOT}/config` }, - { name: "XDG_DATA_HOME", value: `${ROOT}/data` }, - { name: "XDG_STATE_HOME", value: `${ROOT}/state` }, - { name: "XDG_CACHE_HOME", value: `${ROOT}/cache` }, - ], - config: { - enabled: true, - binaryPath, - ...(process.env.OPENCODE2_SERVER_URL === undefined - ? {} - : { - serverUrl: process.env.OPENCODE2_SERVER_URL, - serverPassword: process.env.OPENCODE2_SERVER_PASSWORD ?? "", - }), - }, - }, - }, -}); -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(layerSpawnedServers), - 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 layerOrchestration = RuntimeLayer.layer.pipe( - Layer.provide(ProviderTurnStartServiceTestkit.layer), - Layer.provide(layerMcpRegistry), - Layer.provide(SqlitePersistence.layerMemory), - Layer.provide(CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry))), - Layer.provide(layerServerConfig), - Layer.provide(layerServerSettings), - Layer.provideMerge(layerProviderInstanceRegistry), - Layer.provide(ResetCreditCoordinator.layer), - Layer.provide(layerBackgroundPolicy), - Layer.provide(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), -); - -const layerContinuationWorker = ProviderContinuationService.layer.pipe( - Layer.provide( - Layer.unwrap( - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - return Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: orchestrator.dispatch, - getThreadRecords: orchestrator.getThreadRecords, - getThreadProjection: orchestrator.getThreadProjection, - }); - }), - ), - ), - Layer.provide(IdAllocator.layer), - Layer.provide(ProviderContinuationRequests.layer), -); -const layerLive = layerContinuationWorker.pipe(Layer.provideMerge(layerOrchestration)); - -const STEP_WAIT_SECONDS = Number(process.env.OPENCODE2_STEP_WAIT ?? "120"); - -const settled = (projection: OrchestrationV2ThreadProjection) => - projection.runs.length > 0 && - projection.runs.every( - (run) => !["queued", "starting", "running", "waiting"].includes(run.status), - ); - -const waitFor = Effect.fn("OpenCode2Live.waitFor")(function* ( - threadId: ThreadId, - done: (projection: OrchestrationV2ThreadProjection) => boolean, -) { - const orchestrator = yield* Orchestrator.OrchestratorV2; - for (let attempt = 0; attempt < STEP_WAIT_SECONDS * 2; attempt += 1) { - const projection = yield* orchestrator.getThreadProjection(threadId); - if (done(projection)) return projection; - yield* Effect.sleep("500 millis"); - } - const last = yield* orchestrator.getThreadProjection(threadId); - const items = last.turnItems.map((item) => - item.type === "error" ? `error:${item.failure.message}` : `${item.type}:${item.status}`, - ); - return yield* Effect.die( - new Error( - `Timed out waiting on OpenCode 2 thread ${threadId}: runs ${last.runs.map((run) => run.status).join(",")}; items ${items.join(",")}`, - ), - ); -}); - -const send = Effect.fn("OpenCode2Live.send")(function* ( - threadId: ThreadId, - key: string, - text: string, - modelSelection: ModelSelection = MODEL, -) { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:opencode2-live:${key}`), - threadId, - messageId: MessageId.make(`message:opencode2-live:${key}`), - text, - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); -}); - -const AssistantModel = Schema.fromJsonString( - Schema.Struct({ - model: Schema.Struct({ providerID: Schema.String, id: Schema.String }), - agent: Schema.optional(Schema.String), - }), -); -const decodeAssistantModel = Schema.decodeUnknownSync(AssistantModel); - -const assistantModels = (nativeSessionId: string) => - Effect.sync(() => { - const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { readOnly: true }); - try { - return db - .prepare( - "SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq", - ) - .all(nativeSessionId) - .map((row) => decodeAssistantModel(row.data).model) - .map((model) => `${model.providerID}/${model.id}`); - } finally { - db.close(); - } - }); - -const assistantAgents = (nativeSessionId: string) => - Effect.sync(() => { - const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { readOnly: true }); - try { - return db - .prepare( - "SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq", - ) - .all(nativeSessionId) - .map((row) => decodeAssistantModel(row.data).agent); - } finally { - db.close(); - } - }); - -describe.runIf(binaryPath !== undefined && ROOT !== "")("OpenCode 2 live orchestrator", () => { - it.live( - "runs a tool turn and stops a running shell command through the real driver", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.writeFileString(path.join(ROOT, "work", "hello.txt"), "hello from t3 live\n"); - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - - // A status check starts a fresh server, which lists no models for its - // first few hundred milliseconds; the picker must still get them. - const instance = - yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE); - assert.isDefined(instance); - const status = yield* instance!.snapshot.refresh; - assert.equal(status.status, "ready"); - assert.include( - status.models.map((model) => model.slug), - "opencode/big-pickle", - ); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("thread:opencode2-live"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live:create"), - threadId, - projectId: ProjectId.make("project:opencode2-live"), - title: "OpenCode 2 live", - modelSelection: MODEL, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: `${ROOT}/work`, - }); - - yield* send( - threadId, - "tools", - // The shell call outlasts the spawned server's 30 second idle timeout. - "Use the read tool to read hello.txt, then run the shell command `sleep 35 && echo TOOL_OK` with the shell tool in the foreground (not in the background) and wait for it to finish, then reply DONE.", - ); - const first = yield* waitFor(threadId, settled); - assert.deepEqual( - first.runs.map((run) => run.status), - ["completed"], - ); - const shell = first.turnItems.find((item) => item.type === "command_execution"); - assert.deepInclude(shell, { status: "completed", exitCode: 0 }); - assert.include(shell?.type === "command_execution" ? shell.output : "", "TOOL_OK"); - assert.isDefined( - first.turnItems.find((item) => item.type === "dynamic_tool" && item.toolName === "read"), - ); - assert.isAbove(first.providerTurns[0]?.tokenUsage?.maxTokens ?? 0, 0); - - yield* send( - threadId, - "stop", - "Run the shell command `sleep 60 && echo LATE` with the shell tool in the foreground (not in the background) and wait for it to finish, then reply DONE.", - ); - const running = yield* waitFor(threadId, (projection) => - projection.turnItems.some( - (item) => - item.type === "command_execution" && - item.status === "running" && - item.input.includes("sleep 60"), - ), - ); - const secondRun = running.runs.at(-1)!; - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("command:opencode2-live:interrupt"), - threadId, - runId: secondRun.id, - }); - const stopped = yield* waitFor(threadId, settled); - assert.deepEqual( - stopped.runs.map((run) => run.status), - ["completed", "interrupted"], - ); - const sleep = stopped.turnItems.find( - (item) => item.type === "command_execution" && item.input.includes("sleep 60"), - ); - assert.equal(sleep?.status, "interrupted"); - - const switched: ModelSelection = { instanceId: INSTANCE, model: SWITCHED_MODEL }; - yield* send(threadId, "switch", "Reply with exactly: SWITCHED", switched); - const third = yield* waitFor( - threadId, - (projection) => projection.runs.length === 3 && settled(projection), - ); - assert.equal(third.runs.at(-1)?.status, "completed"); - const sessionId = third.providerThreads[0]?.nativeThreadRef?.nativeId; - assert.isDefined(sessionId); - const models = yield* assistantModels(sessionId!); - assert.equal(models.at(-1), SWITCHED_MODEL); - assert.notEqual(models[0], SWITCHED_MODEL); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 360_000, - ); - - it.live( - "asks before each shell command in Supervised, runs the approved one, skips the declined one, and answers a question", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("thread:opencode2-live-supervised"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live:supervised-create"), - threadId, - projectId: ProjectId.make("project:opencode2-live"), - title: "OpenCode 2 live supervised", - modelSelection: MODEL, - runtimeMode: "approval-required", - interactionMode: "default", - branch: null, - worktreePath: `${ROOT}/work`, - }); - const pendingAsk = (projection: OrchestrationV2ThreadProjection) => - projection.runtimeRequests.find((request) => request.status === "pending"); - const answer = ( - key: string, - request: OrchestrationV2ThreadProjection["runtimeRequests"][number], - response: { decision: "accept" | "decline" } | { answers: Record }, - ) => - orchestrator.dispatch({ - type: "runtime-request.respond", - commandId: CommandId.make(`command:opencode2-live:${key}`), - threadId, - requestId: request.id, - ...response, - }); - - yield* send( - threadId, - "approve", - "Run the shell command `touch approved.txt` with the shell tool, then reply DONE.", - ); - const asked = yield* waitFor( - threadId, - (projection) => pendingAsk(projection) !== undefined, - ); - assert.equal(pendingAsk(asked)?.kind, "command"); - yield* answer("approve-answer", pendingAsk(asked)!, { decision: "accept" }); - const approved = yield* waitFor(threadId, settled); - assert.equal(approved.runs.at(-1)?.status, "completed"); - assert.isTrue(yield* fs.exists(path.join(ROOT, "work", "approved.txt"))); - - yield* send( - threadId, - "decline", - "Run the shell command `touch declined.txt` with the shell tool, then reply DONE.", - ); - const second = yield* waitFor( - threadId, - (projection) => projection.runs.length === 2 && pendingAsk(projection) !== undefined, - ); - yield* answer("decline-answer", pendingAsk(second)!, { decision: "decline" }); - let declined = yield* waitFor( - threadId, - (projection) => - (projection.runs.length === 2 && settled(projection)) || - pendingAsk(projection) !== undefined, - ); - for (let retry = 0; pendingAsk(declined) !== undefined && retry < 3; retry += 1) { - yield* answer(`decline-retry-${retry}`, pendingAsk(declined)!, { decision: "decline" }); - declined = yield* waitFor( - threadId, - (projection) => - (projection.runs.length === 2 && settled(projection)) || - pendingAsk(projection) !== undefined, - ); - } - assert.equal(declined.runs.at(-1)?.status, "completed"); - assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "declined.txt"))); - - yield* send( - threadId, - "question", - "Before doing anything, use the question tool to ask me which color I prefer, offering the options red and blue. After I answer, reply with only the chosen color.", - ); - const questioned = yield* waitFor( - threadId, - (projection) => projection.runs.length === 3 && pendingAsk(projection) !== undefined, - ); - const question = pendingAsk(questioned)!; - assert.equal(question.kind, "user_input"); - const form = questioned.turnItems.find( - (item) => item.type === "user_input_request" && item.requestId === question.id, - ); - const firstQuestion = form?.type === "user_input_request" ? form.questions[0] : undefined; - assert.isDefined(firstQuestion); - yield* answer("question-answer", question, { answers: { [firstQuestion!.id]: "Blue" } }); - const answered = yield* waitFor( - threadId, - (projection) => projection.runs.length === 3 && settled(projection), - ); - assert.equal(answered.runs.at(-1)?.status, "completed"); - const reply = answered.messages.findLast((message) => message.role === "assistant"); - assert.match(reply?.text ?? "", /blue/i); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 600_000, - ); - - it.live( - "lets plan mode write only the plan agent's plan directory under Full access", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("thread:opencode2-live-plan"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live:plan-create"), - threadId, - projectId: ProjectId.make("project:opencode2-live"), - title: "OpenCode 2 live plan", - modelSelection: MODEL, - runtimeMode: "full-access", - interactionMode: "plan", - branch: null, - worktreePath: `${ROOT}/work`, - }); - const planDir = path.join(ROOT, ".opencode", "plan"); - yield* send( - threadId, - "plan", - `This is a permission test. Do exactly these two tool calls and nothing else: 1) use the write tool to create ${planDir}/probe-plan.md containing PLAN_OK; 2) use the write tool to create plan_write_probe.txt in the current directory containing NO. Then reply with which calls succeeded.`, - ); - const planned = yield* waitFor(threadId, settled); - assert.equal(planned.runs.at(-1)?.status, "completed"); - assert.isFalse(yield* fs.exists(path.join(ROOT, "work", "plan_write_probe.txt"))); - assert.isTrue(yield* fs.exists(path.join(planDir, "probe-plan.md"))); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 360_000, - ); - - it.live( - "runs subagents, wakes the thread for a background one, and stops one still running", - () => - Effect.gen(function* () { - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - // One thread per case: a model that just answered one prompt may repeat it. - const thread = Effect.fn("OpenCode2Live.subagentThread")(function* (key: string) { - const threadId = ThreadId.make(`thread:opencode2-live-subagents-${key}`); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:opencode2-live:subagents-${key}-create`), - threadId, - projectId: ProjectId.make("project:opencode2-live"), - title: `OpenCode 2 live subagents ${key}`, - modelSelection: MODEL, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: `${ROOT}/work`, - }); - return threadId; - }); - const childOf = (projection: OrchestrationV2ThreadProjection) => - Effect.gen(function* () { - const childThreadId = projection.subagents[0]?.childThreadId; - assert.isDefined(childThreadId ?? undefined); - return yield* orchestrator.getThreadProjection(childThreadId!); - }); - - const fgThread = yield* thread("fg"); - yield* send( - fgThread, - "subagents-fg", - "Use the subagent tool (foreground, not background) to delegate to the general subagent with the prompt: 'Reply exactly FG_CHILD_OK.' Wait for it, then reply exactly FG_PARENT_OK.", - ); - const foreground = yield* waitFor(fgThread, settled); - assert.deepEqual( - foreground.runs.map((run) => run.status), - ["completed"], - ); - assert.equal(foreground.subagents[0]?.status, "completed"); - const fgChild = yield* childOf(foreground); - assert.isTrue( - fgChild.turnItems.some( - (item) => item.type === "assistant_message" && item.text.includes("FG_CHILD_OK"), - ), - ); - - const bgThread = yield* thread("bg"); - yield* send( - bgThread, - "subagents-bg", - "Use the subagent tool with background set to true to delegate to the general subagent with the prompt: 'Run the shell command `sleep 8` with the shell tool, then reply exactly BG_CHILD_OK.' As soon as it is launched, reply exactly BG_LAUNCHED and end your turn without waiting.", - ); - const woke = yield* waitFor( - bgThread, - (projection) => - settled(projection) && - projection.subagents[0]?.status === "completed" && - !projection.turnItems.some( - (item) => item.status === "running" || item.status === "waiting", - ), - ); - assert.isTrue(woke.runs.every((run) => run.status === "completed")); - const answeredIn = woke.runs.at(-1)!; - if (woke.runs.length === 2) { - const wakeMessage = woke.messages.find( - (message) => message.id === answeredIn.userMessageId, - ); - assert.equal( - `${wakeMessage?.createdBy}:${wakeMessage?.creationSource}`, - "agent:provider", - ); - } else { - assert.lengthOf(woke.runs, 1); - } - assert.isTrue( - woke.turnItems.some( - (item) => item.runId === answeredIn.id && item.type === "assistant_message", - ), - "the parent's answer to the report lands in the run that took it", - ); - - const stopThread = yield* thread("stop"); - yield* send( - stopThread, - "subagents-stop", - "Use the subagent tool with background set to true to delegate to the general subagent with the prompt: 'Run the shell command `sleep 60` with the shell tool, then reply exactly LATE.' As soon as it is launched, reply exactly STOP_LAUNCHED and end your turn without waiting.", - ); - const launched = yield* waitFor( - stopThread, - (projection) => - projection.runs[0]?.status !== "running" && - projection.subagents[0]?.status === "running", - ); - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("command:opencode2-live:subagents-stop-interrupt"), - threadId: stopThread, - runId: launched.runs[0]!.id, - }); - yield* waitFor( - stopThread, - (projection) => - settled(projection) && - projection.subagents[0] !== undefined && - projection.subagents[0].status !== "running", - ); - yield* Effect.sleep("5 seconds"); - const final = yield* orchestrator.getThreadProjection(stopThread); - assert.lengthOf(final.runs, 1); - assert.equal(final.subagents[0]?.status, "interrupted"); - assert.isFalse( - final.turnItems.some((item) => item.status === "running" || item.status === "waiting"), - ); - const stoppedChild = yield* childOf(final); - assert.isFalse( - stoppedChild.turnItems.some((item) => item.status === "running"), - "the stopped child shows nothing running", - ); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 480_000, - ); - - it.live( - "steers a running turn, queues two messages, forks an earlier turn and rolls back a write", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const work = path.join(ROOT, "inbox-work"); - yield* fs.makeDirectory(work, { recursive: true }); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const git = (...args: ReadonlyArray) => - spawner.exitCode(ChildProcess.make("git", ["-C", work, ...args])); - yield* git("init", "-q"); - yield* git( - "-c", - "user.name=t3", - "-c", - "user.email=t3@example.com", - "commit", - "-q", - "--allow-empty", - "-m", - "init", - ); - const threadId = ThreadId.make("thread:opencode2-live-inbox"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live-inbox:create"), - threadId, - projectId: ProjectId.make("project:opencode2-live-inbox"), - title: "OpenCode 2 live inbox", - modelSelection: MODEL, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: work, - }); - const dispatch = ( - key: string, - text: string, - dispatchMode: Extract< - OrchestrationV2Command, - { type: "message.dispatch" } - >["dispatchMode"], - ) => - orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:opencode2-live-inbox:${key}`), - threadId, - messageId: MessageId.make(`message:opencode2-live-inbox:${key}`), - text, - attachments: [], - modelSelection: MODEL, - dispatchMode, - }); - const assistantText = (projection: OrchestrationV2ThreadProjection, runId: string) => - projection.turnItems - .flatMap((item) => - item.type === "assistant_message" && item.runId === runId ? [item.text] : [], - ) - .join("\n"); - - yield* dispatch( - "alpha", - "Use the write tool to create reverted.txt containing exactly ALPHA, then reply DONE.", - { type: "start_immediately" }, - ); - const first = yield* waitFor(threadId, settled); - assert.equal(first.runs[0]?.status, "completed"); - assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "ALPHA"); - - yield* dispatch( - "shell", - "Run the shell command `sleep 15 && echo A` with the shell tool in the foreground (not in the background) and wait for it, then use the write tool to overwrite reverted.txt with exactly BETA, then reply DONE_A.", - { type: "start_immediately" }, - ); - const running = yield* waitFor(threadId, (projection) => - projection.turnItems.some( - (item) => item.type === "command_execution" && item.status === "running", - ), - ); - const shellRun = running.runs.at(-1)!; - yield* dispatch("steer", "Also include the word STEERED in your final reply.", { - type: "steer_active", - targetRunId: shellRun.id, - }); - yield* dispatch("queued-b", "Reply with exactly QUEUED_B.", { type: "queue_after_active" }); - yield* dispatch("queued-c", "Reply with exactly QUEUED_C.", { type: "queue_after_active" }); - const drained = yield* waitFor( - threadId, - (projection) => projection.runs.length === 4 && settled(projection), - ); - const [, shell, queuedB, queuedC] = drained.runs; - assert.deepEqual( - drained.runs.map((run) => run.status), - ["completed", "completed", "completed", "completed"], - ); - assert.include(assistantText(drained, shell!.id), "STEERED"); - assert.include(assistantText(drained, queuedB!.id), "QUEUED_B"); - assert.include(assistantText(drained, queuedC!.id), "QUEUED_C"); - assert.lengthOf(drained.providerTurns, 4); - assert.deepEqual( - drained.turnItems.flatMap((item) => - item.type === "user_message" && item.runId === shell!.id ? [item.inputIntent] : [], - ), - ["turn_start", "steer"], - ); - assert.lengthOf( - drained.attempts.filter((attempt) => attempt.runId === shell!.id), - 1, - ); - assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "BETA"); - - const forkId = ThreadId.make("thread:opencode2-live-inbox-fork"); - yield* orchestrator.dispatch({ - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live-inbox:fork"), - sourceThreadId: threadId, - targetThreadId: forkId, - sourcePoint: { type: "run", runId: first.runs[0]!.id }, - }); - const forkWork = path.join(ROOT, "inbox-fork-work"); - yield* fs.makeDirectory(forkWork, { recursive: true }); - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("command:opencode2-live-inbox:fork-worktree"), - threadId: forkId, - worktreePath: forkWork, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live-inbox:fork-ask"), - threadId: forkId, - messageId: MessageId.make("message:opencode2-live-inbox:fork-ask"), - text: "Did I ask you to reply with QUEUED_B in this conversation? Answer only YES or NO.", - attachments: [], - modelSelection: MODEL, - dispatchMode: { type: "start_immediately" }, - }); - const forked = yield* waitFor(forkId, settled); - assert.equal(forked.contextTransfers[0]?.resolution?.strategy, "native_fork"); - assert.equal(forked.runs[0]?.status, "completed"); - assert.include(assistantText(forked, forked.runs[0]!.id).toUpperCase(), "NO"); - - const checkpoint = drained.checkpoints.find( - (candidate) => candidate.appRunOrdinal === 1 && candidate.status === "ready", - ); - assert.isDefined(checkpoint); - yield* orchestrator.dispatch({ - type: "checkpoint.rollback", - commandId: CommandId.make("command:opencode2-live-inbox:rollback"), - threadId, - scopeId: checkpoint!.scopeId, - checkpointId: checkpoint!.id, - }); - const rolledBack = yield* waitFor(threadId, (projection) => - projection.runs.slice(1).every((run) => run.status === "rolled_back"), - ); - assert.deepEqual( - rolledBack.runs.map((run) => run.status), - ["completed", "rolled_back", "rolled_back", "rolled_back"], - ); - assert.equal((yield* fs.readFileString(path.join(work, "reverted.txt"))).trim(), "ALPHA"); - yield* dispatch( - "after", - "Did I ask you to reply with QUEUED_B in this conversation? Answer only YES or NO.", - { type: "start_immediately" }, - ); - const after = yield* waitFor( - threadId, - (projection) => projection.runs.length === 5 && settled(projection), - ); - assert.include(assistantText(after, after.runs[4]!.id).toUpperCase(), "NO"); - const sessionId = after.providerThreads[0]?.nativeThreadRef?.nativeId; - const db = new NodeSqlite.DatabaseSync(`${ROOT}/data/opencode/opencode.db`, { - readOnly: true, - }); - const userMessages = db - .prepare( - "SELECT id FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq", - ) - .all(sessionId!) - .map((row) => String(row.id)); - db.close(); - assert.lengthOf(userMessages, 2); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 600_000, - ); - - it.live( - "runs plan mode, a workspace command and skill, and /compact through the real driver", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const work = path.join(ROOT, "work"); - yield* fs.makeDirectory(path.join(work, ".opencode", "command"), { recursive: true }); - yield* fs.makeDirectory(path.join(work, ".opencode", "skills", "greet"), { - recursive: true, - }); - yield* fs.writeFileString( - path.join(work, ".opencode", "command", "hello.md"), - "---\ndescription: Say hello to the workspace\n---\nReply with exactly: HELLO $ARGUMENTS\n", - ); - yield* fs.writeFileString( - path.join(work, ".opencode", "skills", "greet", "SKILL.md"), - "---\nname: greet\ndescription: Greets the user with the secret word MANGO.\n---\nWhen this skill is active, begin your reply with the exact word MANGO.\n", - ); - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - - const instance = - yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE); - assert.isDefined(instance); - const workspace = yield* instance!.snapshotForCwd!(work); - assert.include( - workspace.skills.map((skill) => skill.name), - "greet", - ); - assert.includeMembers( - workspace.slashCommands.map((command) => command.name), - ["compact", "hello"], - ); - - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("thread:opencode2-live-modes"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live-modes:create"), - threadId, - projectId: ProjectId.make("project:opencode2-live-modes"), - title: "OpenCode 2 live modes", - modelSelection: MODEL, - runtimeMode: "full-access", - interactionMode: "plan", - branch: null, - worktreePath: work, - }); - const runs = (count: number) => (projection: OrchestrationV2ThreadProjection) => - projection.runs.length === count && settled(projection); - const lastReply = (projection: OrchestrationV2ThreadProjection) => - projection.turnItems.findLast((item) => item.type === "assistant_message"); - - yield* send( - threadId, - "modes-plan", - "Plan how to add a --verbose flag to a script named cli.js; the file may not exist yet, so plan it from scratch. Do not ask me anything. Present a short plan; do not implement it.", - ); - const planned = yield* waitFor(threadId, runs(1)); - assert.equal(planned.runs[0]?.status, "completed"); - const sessionId = planned.providerThreads[0]?.nativeThreadRef?.nativeId; - assert.isDefined(sessionId); - const planSteps = yield* assistantAgents(sessionId!); - assert.isAbove(planSteps.length, 0); - assert.isTrue(planSteps.every((agent) => agent === "plan")); - - yield* orchestrator.dispatch({ - type: "thread.interaction-mode.set", - commandId: CommandId.make("command:opencode2-live-modes:default"), - threadId, - interactionMode: "default", - }); - yield* send(threadId, "modes-command", "/hello WORLD"); - const commanded = yield* waitFor(threadId, runs(2)); - assert.equal(commanded.runs[1]?.status, "completed"); - assert.include(lastReply(commanded)?.text ?? "", "HELLO WORLD"); - assert.equal((yield* assistantAgents(sessionId!)).at(-1), "build"); - - yield* send(threadId, "modes-skill", "Use $greet to say hi in three words."); - const skilled = yield* waitFor(threadId, runs(3)); - assert.equal(skilled.runs[2]?.status, "completed"); - assert.include(lastReply(skilled)?.text ?? "", "MANGO"); - - yield* send(threadId, "modes-compact", "/compact"); - const compacted = yield* waitFor(threadId, runs(4)); - assert.equal(compacted.runs[3]?.status, "completed"); - const compaction = compacted.turnItems.find((item) => item.type === "compaction"); - assert.equal(compaction?.status, "completed"); - assert.equal(compaction?.runId, compacted.runs[3]?.id); - assert.isAbove( - compaction?.type === "compaction" ? (compaction.summary ?? "").length : 0, - 0, - ); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 360_000, - ); - - it.live( - "generates a title, calls T3's MCP server, and reconciles a turn cut off by a killed server", - () => - Effect.gen(function* () { - const work = `${ROOT}/work`; - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const instance = - yield* (yield* ProviderInstanceRegistry.ProviderInstanceRegistry).getInstance(INSTANCE); - assert.isDefined(instance); - yield* instance!.snapshot.refresh; - - const title = yield* instance!.textGeneration.generateThreadTitle({ - cwd: work, - message: "fix the login redirect loop after oauth", - modelSelection: MODEL, - }); - assert.isAbove(title.title.length, 0); - - const threadId = ThreadId.make("thread:opencode2-live-restart"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live-restart:create"), - threadId, - projectId: ProjectId.make("project:opencode2-live-restart"), - title: "OpenCode 2 live restart", - modelSelection: MODEL, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: work, - }); - const runs = (count: number) => (projection: OrchestrationV2ThreadProjection) => - projection.runs.length === count && settled(projection); - - if (process.env.OPENCODE2_MCP_URL !== undefined) { - yield* send( - threadId, - "restart-mcp", - "Call the echo_marker tool from the T3 Code MCP server with word 'kiwi', then reply with its exact output and nothing else.", - ); - const called = yield* waitFor(threadId, runs(1)); - assert.equal(called.runs[0]?.status, "completed"); - const reply = called.turnItems.findLast((item) => item.type === "assistant_message"); - assert.include(reply?.type === "assistant_message" ? reply.text : "", "MARKER-KIWI-7Q9"); - } - const before = (yield* orchestrator.getThreadProjection(threadId)).runs.length; - - yield* send( - threadId, - "restart-killed", - "Run the shell command `sleep 60 && echo LATE` with the shell tool in the foreground and wait for it, then reply DONE.", - ); - yield* waitFor(threadId, (projection) => - projection.turnItems.some( - (item) => - item.type === "command_execution" && - item.status === "running" && - item.input.includes("sleep 60"), - ), - ); - const pid = spawnedPids.at(-1); - assert.isDefined(pid); - process.kill(pid!, "SIGKILL"); - const reconciled = yield* waitFor(threadId, runs(before + 1)); - assert.equal(reconciled.runs.at(-1)?.status, "interrupted"); - - yield* send( - threadId, - "restart-next", - "What did I last ask you to run? Answer in one short sentence.", - ); - const next = yield* waitFor(threadId, runs(before + 2)); - assert.equal(next.runs.at(-1)?.status, "completed"); - const answer = next.turnItems.findLast((item) => item.type === "assistant_message"); - assert.include(answer?.type === "assistant_message" ? answer.text : "", "sleep 60"); - assert.lengthOf(next.providerThreads, 1); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 360_000, - ); - - // Needs an external server behind a proxy that cuts its event streams on - // `GET /__drop`, so only the stream drops while the server stays up. - it.live.runIf(process.env.OPENCODE2_DROP_URL !== undefined)( - "picks a turn back up after an external server's event stream drops mid-turn", - () => - Effect.gen(function* () { - const work = `${ROOT}/work`; - yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("thread:opencode2-live-drop"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:opencode2-live-drop:create"), - threadId, - projectId: ProjectId.make("project:opencode2-live-drop"), - title: "OpenCode 2 live drop", - modelSelection: MODEL, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: work, - }); - yield* send( - threadId, - "drop-running", - "Run the shell command `sleep 8 && echo AFTER_DROP` with the shell tool in the foreground and wait for it, then reply with its exact output.", - ); - yield* waitFor(threadId, (projection) => - projection.turnItems.some( - (item) => item.type === "command_execution" && item.status === "running", - ), - ); - yield* HttpClient.get(process.env.OPENCODE2_DROP_URL!).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.orDie, - ); - const done = yield* waitFor(threadId, settled); - assert.equal(done.runs[0]?.status, "completed"); - const shell = done.turnItems.find((item) => item.type === "command_execution"); - assert.equal(shell?.status, "completed"); - const reply = done.turnItems.findLast((item) => item.type === "assistant_message"); - assert.include(reply?.type === "assistant_message" ? reply.text : "", "AFTER_DROP"); - const opencode = yield* OpenCode2Client.make.pipe(Effect.provide(FetchHttpClient.layer)); - const api = yield* opencode.connect({ - baseUrl: process.env.OPENCODE2_SERVER_URL!, - password: process.env.OPENCODE2_SERVER_PASSWORD ?? "", - }); - const servers = yield* api.client.mcp.list({ location: { directory: work } }); - assert.deepEqual( - servers.data.map((server) => server.name), - [], - "an external server gets no T3 MCP server, as with 1.x", - ); - }).pipe(Effect.provide(Layer.merge(layerLive, NodeServices.layer)), Effect.scoped), - 360_000, - ); -}); diff --git a/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts b/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts index 2be1d99da1f4..911c1b8de7df 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts @@ -1,12 +1,6 @@ import { assert, it } from "@effect/vitest"; -import * as Schema from "effect/Schema"; -import { ContextHandoffId, OrchestrationV2Command, ThreadId } from "@t3tools/contracts"; -import { - appendContextHandoffId, - canReplayCommandReceipt, - shouldPrepareLegacyImportHandoff, -} from "./Orchestrator.ts"; +import { shouldPrepareLegacyImportHandoff } from "./Orchestrator.ts"; it("reissues imported context until a V2 run completes", () => { assert.isTrue( @@ -38,59 +32,3 @@ it("reissues imported context until a V2 run completes", () => { }), ); }); - -it("records a reissued legacy handoff on an existing provider thread", () => { - const existingHandoffId = ContextHandoffId.make("handoff:legacy-import:existing"); - const retryHandoffId = ContextHandoffId.make("handoff:legacy-import:retry"); - - assert.deepEqual(appendContextHandoffId([existingHandoffId], retryHandoffId), [ - existingHandoffId, - retryHandoffId, - ]); - assert.deepEqual(appendContextHandoffId([existingHandoffId], existingHandoffId), [ - existingHandoffId, - ]); - assert.deepEqual(appendContextHandoffId([existingHandoffId], null), [existingHandoffId]); -}); - -it("only replays a command receipt for the thread it was recorded against", () => { - const threadA = ThreadId.make("thread-a"); - const threadB = ThreadId.make("thread-b"); - - assert.strictEqual(canReplayCommandReceipt(threadA, threadA), true); - assert.strictEqual(canReplayCommandReceipt(threadA, threadB), false); -}); - -it("links and unlinks a pull request through thread.metadata.update (#8160)", () => { - const decode = Schema.decodeUnknownSync(OrchestrationV2Command); - const linked = decode({ - type: "thread.metadata.update", - commandId: "command-link", - threadId: "thread-1", - linkedPullRequest: { - projectId: "project-1", - repository: "pingdotgg/t3code", - number: 8160, - url: "https://github.com/pingdotgg/t3code/pull/8160", - }, - }); - assert.deepStrictEqual( - (linked as Extract).linkedPullRequest, - { - projectId: "project-1", - repository: "pingdotgg/t3code", - number: 8160, - url: "https://github.com/pingdotgg/t3code/pull/8160", - }, - ); - const unlinked = decode({ - type: "thread.metadata.update", - commandId: "command-unlink", - threadId: "thread-1", - linkedPullRequest: null, - }); - assert.strictEqual( - (unlinked as Extract).linkedPullRequest, - null, - ); -}); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 309837852c24..893cc5338997 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -190,6 +190,8 @@ export class OrchestratorProjectionError extends Schema.TaggedError()( "OrchestratorDomainEventStreamError", { @@ -271,10 +273,7 @@ export class OrchestratorCommandIdConflictError extends Schema.TaggedError candidate.id === command.runId); const subagent = projection.subagents.find((candidate) => candidate.id === command.subagentId); + // A user Stop cancels an app-owned task here when nothing else can settle it: no child run was + // ever bound to it and it is not the child's original task (#19002). This holds whatever else + // the child is doing. A task with a bound run, live or ended, keeps its settlement path, since + // its result may still be on the way, and the child stop the client sends next interrupts the + // run. A task that already ended is written again, so a client holding a stale copy, such as + // one cached before a repair, shows it ended. + if ( + subagent?.origin === "app_owned" && + subagent.childThreadId !== null && + subagent.runId === command.runId + ) { + const childThreadId = subagent.childThreadId; + const child = yield* projectionStore.getThreadRecords(childThreadId, ["runs"]).pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + ), + ); + const active = isOrchestrationV2WorkActive(subagent.status); + if ( + active && + child !== null && + ((child.thread.forkedFrom?.type === "node" && + child.thread.forkedFrom.nodeId === subagent.id) || + child.runs.some((run) => run.delegatedTaskId === subagent.id)) + ) + return; + const records = yield* loadProjectionForCommand(command, ["nodes", "turnItems"], { + turnItemTypes: ["subagent"], + turnItemRunIds: [subagent.runId], + omitSettledLeafNodes: true, + }); + const now = yield* DateTime.now; + return yield* writeAppOwnedTask({ + command, + events, + task: active ? cancelledAppOwnedTask(subagent, now) : subagent, + nodes: records.nodes, + turnItems: records.turnItems, + now, + }); + } if ( subagent === undefined || subagent.runId !== command.runId || @@ -9844,23 +9899,58 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return undefined; }); - const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* ( - command: Extract, - events: Ref.Ref>, - effects: Ref.Ref>, - ) { - const projection = yield* projectionStore - .getThreadRecords(command.threadId, ["runs", "providerThreads", "turnItems", "subagents"], { - turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], - turnItemStatuses: ["pending", "running", "waiting"], - }) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - if (projection.thread.deletedAt !== null) return undefined; - const cancelIdleOrphans = Effect.gen(function* () { + /** Writes a parent's app-owned task, and sets its node and turn items to the task's status. */ + const writeAppOwnedTask = Effect.fn("Orchestrator.writeAppOwnedTask")(function* (input: { + readonly command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "run.interrupt" | "thread.stop" | "subagent.stop" } + >; + readonly events: Ref.Ref>; + readonly task: OrchestrationV2Subagent; + readonly nodes: ReadonlyArray; + readonly turnItems: ReadonlyArray; + readonly now: DateTime.Utc; + }) { + const { command, task, now } = input; + const emitEvent = emit(input.events, command); + yield* emitEvent({ + type: "subagent.updated", + threadId: command.threadId, + nodeId: task.id, + occurredAt: now, + payload: task, + }); + const node = input.nodes.find((candidate) => candidate.id === task.id); + if (node !== undefined) + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + nodeId: node.id, + occurredAt: now, + payload: { ...node, status: task.status, completedAt: task.completedAt }, + }); + for (const item of input.turnItems) { + if (item.type !== "subagent" || item.subagentId !== task.id) continue; + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + nodeId: task.id, + occurredAt: now, + payload: { ...item, status: task.status, completedAt: task.completedAt, updatedAt: now }, + }); + } + }); + + const cancelIdleOrphans = Effect.fn("Orchestrator.cancelIdleOrphans")( + function* (input: { + readonly command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "run.interrupt" | "thread.stop" } + >; + readonly events: Ref.Ref>; + readonly projection: Pick; + }) { + const { command, events, projection } = input; const pending = yield* Ref.get(events); const children = new Map< ThreadId, @@ -9923,48 +10013,43 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (!idle) continue; // Stop can cancel an idle orphan without guessing which completed run owned its result. const now = yield* DateTime.now; - const emitEvent = emit(events, command); - yield* emitEvent({ - type: "subagent.updated", - threadId: command.threadId, - nodeId: task.id, - occurredAt: now, - payload: { - ...task, - status: "cancelled", - completedAt: now, - updatedAt: now, - completionDelivery: { state: "disposed", observedByRunId: null }, - }, - }); nodes ??= (yield* projectionStore.getThreadRecords(command.threadId, ["nodes"])).nodes; - const node = nodes.find((candidate) => candidate.id === task.id); - if (node !== undefined) - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - nodeId: node.id, - occurredAt: now, - payload: { ...node, status: "cancelled", completedAt: now }, - }); - for (const item of projection.turnItems) { - if (item.type !== "subagent" || item.subagentId !== task.id) continue; - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - nodeId: task.id, - occurredAt: now, - payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now }, - }); - } + yield* writeAppOwnedTask({ + command, + events, + task: cancelledAppOwnedTask(task, now), + nodes, + turnItems: projection.turnItems, + now, + }); } - }).pipe( - Effect.mapError((cause) => - Schema.is(OrchestratorProjectionError)(cause) - ? cause - : new OrchestratorProjectionError({ threadId: command.threadId, cause }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isOrchestratorProjectionError(cause) + ? cause + : new OrchestratorProjectionError({ threadId: input.command.threadId, cause }), + ), ), - ); + ); + + const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "providerThreads", "turnItems", "subagents"], { + turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], + turnItemStatuses: ["pending", "running", "waiting"], + }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (projection.thread.deletedAt !== null) return undefined; const latestRun = projection.runs.findLast((run) => run.status !== "queued"); const target = projection.runs.findLast( @@ -10003,7 +10088,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const enqueued = yield* Ref.get(interruptEffects); yield* Ref.update(events, (existing) => [...existing, ...written]); yield* Ref.update(effects, (existing) => [...existing, ...enqueued]); - yield* cancelIdleOrphans; + yield* cancelIdleOrphans({ command, events, projection }); return interrupted.value; } yield* Effect.logWarning("thread.stop could not interrupt the running turn", { @@ -10023,7 +10108,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* markStoppedRun({ command, events, thread: projection.thread, run, now }); } yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now }); - yield* cancelIdleOrphans; + yield* cancelIdleOrphans({ command, events, projection }); return undefined; }); @@ -11303,6 +11388,25 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "run.interrupt": cancelUnsettledEffects = yield* dispatchRunInterrupt(command, events, effects); if (command.holdQueue === true) { + // Stop must still commit when a child can't be read, so a failed repair's + // partial events are dropped. + const repairEvents = yield* Ref.make(yield* Ref.get(events)); + const repaired = yield* Effect.exit( + loadProjectionForCommand(command, ["subagents", "turnItems"], { + turnItemTypes: ["subagent"], + turnItemStatuses: ["pending", "running", "waiting"], + }).pipe( + Effect.flatMap((projection) => + cancelIdleOrphans({ command, events: repairEvents, projection }), + ), + ), + ); + if (Exit.isSuccess(repaired)) yield* Ref.set(events, yield* Ref.get(repairEvents)); + else + yield* Effect.logWarning("Stop could not cancel orphaned delegated follow-ups", { + threadId: command.threadId, + cause: repaired.cause, + }); yield* Ref.update(effects, (existing) => [ ...existing, { @@ -11726,6 +11830,48 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.forkDetach, ); + // A child whose run ends while its background tasks or its own subagents still run has no + // result yet. When that work settles there is no run update to finalize it, so these events + // do it (#17512). A background roster counts once it empties after it was seen non-empty. + const providerThreadsWithBackgroundWork = new Set(); + const settlesChildWork = (stored: OrchestrationV2StoredEvent) => { + const { event } = stored; + if (event.type === "subagent.updated") + return !isOrchestrationV2WorkActive(event.payload.status); + if (event.type !== "provider-thread.updated") return false; + if ((event.payload.pendingBackgroundTasks?.length ?? 0) > 0) { + providerThreadsWithBackgroundWork.add(event.payload.id); + return false; + } + return providerThreadsWithBackgroundWork.delete(event.payload.id); + }; + yield* Stream.merge( + eventSink.stream({ + afterSequence: terminalEventsAfterSequence, + eventType: "provider-thread.updated", + }), + eventSink.stream({ afterSequence: terminalEventsAfterSequence, eventType: "subagent.updated" }), + ).pipe( + Stream.filter(settlesChildWork), + Stream.runForEach((stored) => + Effect.gen(function* () { + const childThreadId = stored.event.threadId; + const parentThreadId = yield* appOwnedSubagentParentThreadId(childThreadId); + if (parentThreadId === undefined) return; + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(childThreadId)); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to finalize a delegated child after its work settled", { + threadId: stored.event.threadId, + sequence: stored.sequence, + cause, + }), + ), + ), + ), + Effect.forkDetach, + ); + const recoverDelegatedTasks = Effect.gen(function* () { yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( Effect.tap((threadIds) => diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index c600706b1a72..608c8b0ddad4 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -1,12 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; -import { - CommandId, - EventId, - ProjectId, - ProviderInstanceId, - type ModelSelection, - type ProjectScript, -} from "@t3tools/contracts"; +import { CommandId, EventId, ProjectId, type ProjectScript } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Result from "effect/Result"; @@ -52,22 +45,6 @@ const plan = (command: ProjectCommand, state: Partial = {}) now, }); -const update = ( - fields: Omit< - Extract, - "type" | "commandId" | "projectId" - >, -) => - plan( - { - type: "project.meta.update", - commandId: CommandId.make("cmd-update"), - projectId, - ...fields, - }, - { project: row() }, - ); - const payloadOf = (result: ReturnType) => { assert.isTrue(Result.isSuccess(result)); return Result.getOrThrow(result).payload as Record; @@ -79,71 +56,6 @@ const failureOf = (result: ReturnType) => { }; describe("planProjectCommand", () => { - it("creates projects with empty scripts and no model default", () => { - const result = plan({ - type: "project.create", - commandId: CommandId.make("cmd-create"), - projectId, - title: "Scripts", - workspaceRoot: "/tmp/scripts", - }); - const event = Result.getOrThrow(result); - assert.equal(event.type, "project.created"); - assert.equal(event.occurredAt, "2026-01-01T00:00:00.000Z"); - assert.deepInclude(event.payload, { scripts: [], defaultModelSelection: null }); - }); - - it("only treats metadata updates as explicit model defaults", () => { - const selection: ModelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.6-sol", - options: [{ id: "reasoningEffort", value: "high" }], - }; - assert.deepEqual( - payloadOf(update({ defaultModelSelection: selection })).defaultModelSelection, - selection, - ); - }); - - it("carries every edited field and omits the rest", () => { - const scripts = [script("lint")]; - const payload = payloadOf( - update({ - scripts, - defaultThreadEnvMode: "worktree", - autoPull: true, - faviconPath: "brand/icon.svg", - projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, - }), - ); - assert.deepInclude(payload, { - scripts, - defaultThreadEnvMode: "worktree", - autoPull: true, - faviconPath: "brand/icon.svg", - projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, - }); - const renamed = payloadOf(update({ title: "Renamed" })); - assert.isFalse("defaultThreadEnvMode" in renamed); - assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode); - }); - - it.each(["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)])( - "rejects a new script ID that cannot have a shortcut: %s", - (id) => { - const failure = failureOf(update({ scripts: [script("lint"), script(id)] })); - assert.equal(failure._tag, "ProjectCommandInvariantError"); - assert.include(failure.message, "Script ID"); - assert.include(failure.message, "24"); - assert.notInclude(failure.message, `'${id}'`); - }, - ); - - it("accepts a script ID at the shortcut length limit", () => { - const scripts = [script("a".repeat(24))]; - assert.deepEqual(payloadOf(update({ scripts })).scripts, scripts); - }); - it("keeps legacy scripts editable and removable while rejecting new invalid ones", () => { const legacy = script("install-javascript-dependencies"); const withLegacy = (scripts: ReadonlyArray) => @@ -165,19 +77,6 @@ describe("planProjectCommand", () => { ); }); - it("limits monograms to two graphemes", () => { - for (const text of ["T3", "é", "किखि", "क्ष्म", "각"]) { - const monogram = { kind: "monogram", text, color: "violet" } as const; - assert.deepEqual(payloadOf(update({ projectIcon: monogram })).projectIcon, monogram); - } - for (const text of ["ABC", "किखिगि"]) { - assert.equal( - failureOf(update({ projectIcon: { kind: "monogram", text, color: "violet" } }))._tag, - "ProjectCommandInvariantError", - ); - } - }); - it("rejects a workspace root held by another active project", () => { const owner = row({ projectId: ProjectId.make("project-existing"), @@ -240,15 +139,4 @@ describe("planProjectCommand", () => { } } }); - - it("deletes with a single project.deleted event", () => { - const event = Result.getOrThrow( - plan( - { type: "project.delete", commandId: CommandId.make("cmd-delete"), projectId }, - { project: row() }, - ), - ); - assert.equal(event.type, "project.deleted"); - assert.deepEqual(event.payload, { projectId, deletedAt: "2026-01-01T00:00:00.000Z" }); - }); }); diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 46579313cdae..d80bc8dd73c1 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -17,9 +17,6 @@ import { 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 SqlClient from "effect/sql/SqlClient"; -import * as Statement from "effect/sql/Statement"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -30,7 +27,6 @@ const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const old = DateTime.subtract(now, { days: 10 }); const providerInstanceId = ProviderInstanceId.make("codex"); const modelSelection = { instanceId: providerInstanceId, model: "test-model" }; -const encodeNumbers = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.Number))); const createThread = Effect.fn(function* ( name: string, @@ -422,104 +418,3 @@ it.effect.each([ assert.equal(all.threads.length, 4); }).pipe(Effect.provide(testLayer)), ); - -it.effect( - "reads settlement candidates and thread metadata without loading historical or archived payloads", - () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const sql = yield* SqlClient.SqlClient; - const source = yield* createThread("historical-source", { archivedAt: old }); - const sourceRun = yield* createRun(source); - const threadId = yield* createThread("fork", { - forkedFrom: { type: "run", threadId: source, runId: sourceRun }, - }); - const runId = yield* createRun(threadId); - yield* createItem(threadId, runId, "completed"); - yield* sql`UPDATE orchestration_v2_projection_turn_items SET payload_json = '{broken' WHERE thread_id = ${threadId}`; - yield* sql`UPDATE orchestration_v2_projection_runs SET payload_json = '{broken' WHERE thread_id = ${source}`; - yield* sql`INSERT INTO orchestration_v2_projection_turn_items - SELECT 'history:' || value, i.thread_id, i.run_id, i.node_id, i.provider_thread_id, i.provider_turn_id, - i.parent_item_id, i.ordinal + value, i.type, i.status, i.updated_at, i.payload_json - FROM orchestration_v2_projection_turn_items i, json_each(${encodeNumbers(Array.from({ length: 2000 }, (_, i) => i + 1))}) - WHERE i.thread_id = ${threadId}`; - const queries: Array]> = []; - const record: Statement.Transformer = (statement) => - Effect.sync(() => { - queries.push(statement.compile()); - return statement; - }); - const candidates = yield* store - .getSettlementCandidates() - .pipe(Effect.provideService(Statement.CurrentTransformer, record)); - assert.deepEqual( - candidates.map((thread) => thread.id), - [threadId], - ); - assert.isTrue(queries.every(([query]) => !query.includes("COUNT("))); - const pendingQuery = queries.find(([query]) => - query.includes("FROM orchestration_v2_projection_turn_items"), - ); - assert.isDefined(pendingQuery); - const metadata = yield* store.getThread(threadId); - assert.equal(metadata.id, threadId); - assert.equal((yield* store.getThreadProjection(threadId).pipe(Effect.exit))._tag, "Failure"); - const plan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${pendingQuery![0]}`, - pendingQuery![1], - ); - assert.isTrue(plan.some((row) => row.detail.includes("turn_items_shell_pending_idx"))); - const countsPlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN - SELECT thread_id, run_id, COUNT(*) FROM orchestration_v2_projection_turn_items - WHERE thread_id = ${threadId} AND run_id IS NOT NULL GROUP BY thread_id, run_id`; - assert.isTrue( - countsPlan.some((row) => - row.detail.includes( - "COVERING INDEX orchestration_v2_projection_turn_items_thread_run_idx", - ), - ), - ); - assert.isFalse(countsPlan.some((row) => row.detail.includes("TEMP B-TREE"))); - const messagePlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN - SELECT updated_at FROM orchestration_v2_projection_messages - WHERE thread_id = ${threadId} AND role = 'user' ORDER BY updated_at DESC, message_id DESC LIMIT 1`; - assert.isTrue(messagePlan.some((row) => row.detail.includes("messages_latest_user_idx"))); - assert.isFalse(messagePlan.some((row) => row.detail.includes("TEMP B-TREE"))); - }).pipe(Effect.provide(layerSql)), -); - -it.effect("shell failure lookups stay on the thread's own turn items", () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const sql = yield* SqlClient.SqlClient; - const failed = yield* createThread("failed-latest"); - yield* createRun(failed, "failed"); - const queries: Array]> = []; - const record: Statement.Transformer = (statement) => - Effect.sync(() => { - queries.push(statement.compile()); - return statement; - }); - const shell = yield* store - .getShellSnapshot() - .pipe(Effect.provideService(Statement.CurrentTransformer, record)); - assert.deepEqual( - shell.threads.map((thread) => thread.id), - [failed], - ); - const shellQuery = queries.find(([query]) => - query.includes("AS blocking_failure_payload_json"), - ); - assert.isDefined(shellQuery); - const plan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${shellQuery![0]}`, - shellQuery![1], - ); - const itemLookups = plan.filter((row) => row.detail.startsWith("SEARCH item ")); - assert.lengthOf(itemLookups, 2); - assert.isTrue(itemLookups.every((row) => row.detail.includes("turn_items_failed_error_idx"))); - const secretLookups = plan.filter((row) => row.detail.startsWith("SEARCH secret ")); - assert.lengthOf(secretLookups, 1); - assert.include(secretLookups[0]!.detail, "turn_items_thread_run_idx"); - }).pipe(Effect.provide(layerSql)), -); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index e14281979e4c..83f33269f399 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1,4 +1,4 @@ -import { assert, it, vi } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import { EnvironmentId, EventId, @@ -32,7 +32,6 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; -import * as Statement from "effect/sql/Statement"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -43,7 +42,6 @@ import { decodeThreadHistoryCursor, selectHistoryPageFromCursor, THREAD_HISTORY_PAGE_POLICY, - THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; const layerTest = Layer.mergeAll( @@ -632,48 +630,6 @@ const addOrphanedRecoveryCandidate = Effect.fn("addOrphanedRecoveryCandidate")(f return threadId; }); -it("includes imported runless history when selecting fork context through a run", () => { - const firstRunId = RunId.make("run:projection-imported-fork:1"); - const secondRunId = RunId.make("run:projection-imported-fork:2"); - const runOrdinalById = new Map([ - [firstRunId, 1], - [secondRunId, 2], - ]); - - assert.isTrue( - ProjectionStore.isTurnItemAtOrBeforeRun({ - historyOrigin: "v1_import", - itemRunId: null, - runOrdinalById, - sourceRunOrdinal: 1, - }), - ); - assert.isFalse( - ProjectionStore.isTurnItemAtOrBeforeRun({ - historyOrigin: undefined, - itemRunId: null, - runOrdinalById, - sourceRunOrdinal: 1, - }), - ); - assert.isTrue( - ProjectionStore.isTurnItemAtOrBeforeRun({ - historyOrigin: "v1_import", - itemRunId: firstRunId, - runOrdinalById, - sourceRunOrdinal: 1, - }), - ); - assert.isFalse( - ProjectionStore.isTurnItemAtOrBeforeRun({ - historyOrigin: "v1_import", - itemRunId: secondRunId, - runOrdinalById, - sourceRunOrdinal: 1, - }), - ); -}); - const restartCancelledWorkSurvivesStaleRunUpdate = Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const threadId = yield* addRolledBackRecoveryCandidate("restart-cancelled-work-stale-update"); @@ -841,20 +797,6 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { ); }); -it.effect("memory command reads skip finished leaf nodes of finished runs", () => - commandReadsSkipSettledLeafNodes.pipe(Effect.provide(ProjectionStore.layerMemory)), -); - -it.effect( - "memory worktree path lookup defaults to active threads and can include archived threads", - () => - threadWorktreePathsIncludeArchivedOnRequest.pipe(Effect.provide(ProjectionStore.layerMemory)), -); - -it.effect("memory projection keeps restart-cancelled work through a stale run.updated", () => - restartCancelledWorkSurvivesStaleRunUpdate.pipe(Effect.provide(ProjectionStore.layerMemory)), -); - const backgroundThreadUpdatesKeepTimestamp = Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const createdAt = DateTime.makeUnsafe("2026-10-06T14:00:00.000Z"); @@ -965,23 +907,10 @@ const backgroundThreadUpdatesKeepTimestamp = Effect.gen(function* () { yield* readActivity; }); -it.effect("memory projection keeps background thread updates out of updatedAt", () => - backgroundThreadUpdatesKeepTimestamp.pipe(Effect.provide(ProjectionStore.layerMemory)), -); - it.effect("SQL projection keeps background thread updates out of updatedAt", () => backgroundThreadUpdatesKeepTimestamp.pipe(Effect.provide(layerTest)), ); -it.effect("memory recovery selection ignores unfinished items from rolled-back runs", () => - Effect.gen(function* () { - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const threadId = yield* addRolledBackRecoveryCandidate("memory-recovery-candidates"); - - assert.notInclude(yield* projectionStore.getRecoveryThreadIds("runtime"), threadId); - }).pipe(Effect.provide(ProjectionStore.layerMemory)), -); - it.effect("memory recovery selection includes unfinished items from missing runs", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -1035,10 +964,6 @@ const runlessNativeSubagentNeedsRecovery = Effect.gen(function* () { } }); -it.effect("memory recovery selects unfinished runless native subagents", () => - runlessNativeSubagentNeedsRecovery.pipe(Effect.provide(ProjectionStore.layerMemory)), -); - const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(function* ( suffix: string, ) { @@ -1180,14 +1105,8 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio return { projectId, activeThreadId, forkThreadId, otherThreadId }; }); -it.effect("memory shell snapshots scope to one project", () => - assertProjectScopedShells("memory-project-shell").pipe( - Effect.asVoid, - Effect.provide(ProjectionStore.layerMemory), - ), -); - it.layer(layerTest)("ProjectionStoreV2", (it) => { + // regression: #21969 keep corrupt plan JSON in the typed read error it.effect("returns a typed read error for malformed stored plan JSON", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -1276,53 +1195,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.deepEqual(yield* store.getTurnStartHistory(threadId, [RunId.make("run:other")]), []); }), ); - it.effect("answers empty record filters without reading the filtered tables", () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const threadId = yield* addRolledBackRecoveryCandidate("empty-record-filters"); - const runId = (yield* store.getThreadProjection(threadId)).runs[0]!.id; - const queries: Array = []; - const recordQueries: Statement.Transformer = (statement) => { - queries.push(statement.compile()[0]); - return Effect.succeed(statement); - }; - const read = (filter: ProjectionStore.ProjectionRecordFilter) => - store - .getThreadRecords(threadId, ["runs", "messages", "turnItems"], filter) - .pipe(Effect.provideService(Statement.CurrentTransformer, recordQueries)); - - const emptyFilters = [ - [{ turnItemTypes: [] }, "turnItems", "turn_items"], - [{ turnItemStatuses: [] }, "turnItems", "turn_items"], - [{ turnItemRunIds: [] }, "turnItems", "turn_items"], - [{ runIds: [] }, "runs", "runs"], - [{ messageIds: [] }, "messages", "messages"], - [{ messageRoles: [] }, "messages", "messages"], - [{ messageRunIds: [] }, "messages", "messages"], - ] as const; - for (const [filter, field, table] of emptyFilters) { - queries.length = 0; - assert.isEmpty((yield* read(filter))[field]); - assert.isFalse( - queries.some((query) => - new RegExp(`FROM orchestration_v2_projection_${table}\\b`).test(query), - ), - `${JSON.stringify(filter)} read ${table}`, - ); - } - - const selected = yield* read({ turnItemTypes: ["command_execution"], runIds: [runId] }); - assert.deepEqual( - selected.turnItems.map((item) => item.type), - ["command_execution"], - ); - assert.deepEqual( - selected.runs.map((run) => run.id), - [runId], - ); - assert.isEmpty((yield* read({ turnItemTypes: ["user_message"] })).turnItems); - }), - ); it.effect("preserves stored provider usage when a terminal update omits it", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -1413,784 +1285,21 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { yield* projectionStore.apply({ id: EventId.make("event:provider-usage-reload:replacement"), type: "provider-turn.updated", - threadId, - nodeId, - driver, - occurredAt: now, - payload: { - ...providerTurn, - status: "completed", - completedAt: now, - tokenUsage: replacementUsage, - }, - }); - - const replaced = yield* projectionStore.getThreadProjection(threadId); - assert.deepEqual(replaced.providerTurns[0]?.tokenUsage, replacementUsage); - }), - ); - - it.effect("pages complete user turns through SQL regardless of tool count or payload size", () => - Effect.gen(function* () { - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const sql = yield* SqlClient.SqlClient; - const now = yield* DateTime.now; - const nowIso = DateTime.formatIso(now); - const threadId = ThreadId.make("thread:user-turn-pages"); - yield* projectionStore.apply({ - id: EventId.make("event:user-turn-pages:thread"), - type: "thread.created", - threadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: threadId, - projectId: ProjectId.make("project:user-turn-pages"), - title: "Bounded SQL history", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); - - const allIds: string[] = []; - let lastToolPayload = ""; - for (let turn = 1; turn <= 45; turn += 1) { - const rows = Array.from({ length: 102 }, (_, offset) => { - const ordinal = (turn - 1) * 102 + offset + 1; - const id = `item:user-turn-pages:${ordinal}`; - allIds.push(id); - const base = { - id, - threadId, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal, - status: "completed", - title: null, - startedAt: nowIso, - completedAt: nowIso, - updatedAt: nowIso, - }; - const item = - offset < 2 - ? { - ...base, - type: "user_message", - createdBy: "user", - creationSource: "web", - messageId: `message:${id}`, - inputIntent: offset === 0 ? "turn_start" : "steer", - text: `Turn ${turn}`, - attachments: [], - } - : { - ...base, - type: "command_execution", - input: "command", - output: "x".repeat(2048), - exitCode: 0, - }; - const payloadJson = encodeUnknownJsonString(item); - if (offset >= 2) lastToolPayload = payloadJson; - return { - turn_item_id: id, - thread_id: threadId, - run_id: null, - node_id: null, - provider_thread_id: null, - provider_turn_id: null, - parent_item_id: null, - ordinal, - type: item.type, - status: "completed", - updated_at: nowIso, - payload_json: payloadJson, - }; - }); - yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`; - } - const { statements, tracer } = traceSqlStatements(); - const initial = yield* Effect.acquireUseRelease( - Effect.sync(() => vi.spyOn(JSON, "parse")), - (parse) => - projectionStore - .getThreadSnapshotWindow(threadId, { rowLimit: 77, userTurnLimit: 10 }) - .pipe( - Effect.withTracer(tracer), - Effect.tap(() => - Effect.sync(() => { - assert.lengthOf( - parse.mock.calls.filter(([input]) => input === lastToolPayload), - 1, - ); - }), - ), - ), - (parse) => Effect.sync(() => parse.mockRestore()), - ); - const windowStatement = statements.find((statement) => statement.includes("turn_anchors")); - assert.isDefined(windowStatement); - const windowPlan = yield* sql.unsafe<{ readonly parent: number; readonly detail: string }>( - `EXPLAIN QUERY PLAN ${windowStatement}`, - ); - assert.match( - windowPlan.map((row) => row.detail).join("\n"), - /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/, - ); - const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail); - assert.include(topLevel, "SEARCH item USING INTEGER PRIMARY KEY (rowid=?)"); - assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY"); - const nodeStatement = statements.find((statement) => - statement.includes("WITH RECURSIVE retained"), - ); - assert.isDefined(nodeStatement); - const nodePlan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${nodeStatement}`, - ); - assert.include( - nodePlan.map((row) => row.detail), - "SEARCH orchestration_v2_projection_nodes USING INDEX orchestration_v2_projection_nodes_live_idx (thread_id=?)", - ); - assert.lengthOf(initial.projection.turnItems, 12 * 102); - const bounded = buildBoundedThreadProjection({ - projection: initial.projection, - snapshotSequence: 0, - }); - assert.lengthOf(bounded.projection.visibleTurnItems, 10 * 102); - assert.strictEqual(bounded.projection.visibleTurnItems[0]?.sourceItemId, allIds[35 * 102]); - const loaded = bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId)); - let cursor = bounded.historyCursor; - for (const turns of [20, 15]) { - assert.isNotNull(cursor); - const anchor = decodeThreadHistoryCursor(cursor!); - const snapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: 77, - userTurnLimit: 20, - anchorItemId: TurnItemId.make(anchor.si), - anchorThreadId: ThreadId.make(anchor.st), - }); - const page = selectHistoryPageFromCursor({ - items: snapshot.projection.visibleTurnItems, - cursor: cursor!, - snapshotSequence: 0, - }); - assert.lengthOf(page.items, turns * 102); - loaded.unshift(...page.items.map((row) => String(row.sourceItemId))); - cursor = page.nextCursor; - } - assert.isNull(cursor); - assert.deepEqual(loaded, allIds); - }), - ); - - it.effect("caps a SQL window by runs that a notification started", () => - Effect.gen(function* () { - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const sql = yield* SqlClient.SqlClient; - const now = yield* DateTime.now; - const nowIso = DateTime.formatIso(now); - const threadId = ThreadId.make("thread:notification-turn-pages"); - const providerInstanceId = ProviderInstanceId.make("claudeAgent"); - const modelSelection = { instanceId: providerInstanceId, model: "claude-sonnet-4-6" }; - yield* projectionStore.apply({ - id: EventId.make("event:notification-turn-pages:thread"), - type: "thread.created", - threadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: threadId, - projectId: ProjectId.make("project:notification-turn-pages"), - title: "Woken by notifications", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); - - const providerThreadId = ProviderThreadId.make("provider-thread:notification-turn-pages"); - yield* projectionStore.apply({ - id: EventId.make("event:notification-turn-pages:provider-thread"), - type: "provider-thread.updated", - threadId, - occurredAt: now, - payload: { - id: providerThreadId, - driver: ProviderDriverKind.make("claudeAgent"), - providerInstanceId, - providerSessionId: null, - appThreadId: threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 412, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }, - }); - const planId = PlanId.make("plan:notification-turn-pages"); - const olderPlanId = PlanId.make("plan:notification-turn-pages:older"); - for (const [id, run] of [ - [olderPlanId, 1], - [planId, 2], - ] as const) { - yield* projectionStore.apply({ - id: EventId.make(`event:notification-turn-pages:plan:${run}`), - type: "plan.updated", - threadId, - occurredAt: now, - payload: { - id, - threadId, - runId: RunId.make(`run:notification-turn-pages:${run}`), - nodeId: NodeId.make(`node:notification-turn-pages:${run}`), - kind: "todo_list", - status: "active", - steps: [{ id: "unfinished", text: "Finish the task", status: "pending" }], - }, - }); - } - - const startedRuns = 412; - const allIds: string[] = []; - const startIds: string[] = []; - let ordinal = 0; - const itemRow = (runId: string, item: Record) => { - ordinal += 1; - const id = `turn-item:notification-turn-pages:${ordinal}`; - allIds.push(id); - return { - turn_item_id: id, - thread_id: threadId, - run_id: runId, - node_id: null, - provider_thread_id: null, - provider_turn_id: null, - parent_item_id: null, - ordinal, - type: String(item.type), - status: "completed", - updated_at: nowIso, - payload_json: encodeUnknownJsonString({ - id, - threadId, - runId, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal, - status: "completed", - title: null, - startedAt: nowIso, - completedAt: nowIso, - updatedAt: nowIso, - ...item, - }), - }; - }; - const command = { type: "command_execution", input: "command", output: "x", exitCode: 0 }; - const notification = (summary: string) => ({ - type: "notification", - source: { kind: "monitor" }, - outcome: "completed", - summary, - }); - for (let run = 1; run <= startedRuns + 1; run += 1) { - const runId = `run:notification-turn-pages:${run}`; - const status = run > startedRuns ? "queued" : "completed"; - yield* sql` - INSERT INTO orchestration_v2_projection_runs ( - run_id, thread_id, ordinal, provider, provider_thread_id, status, - requested_at, completed_at, payload_json - ) VALUES ( - ${runId}, ${threadId}, ${run}, 'claudeAgent', ${providerThreadId}, ${status}, ${nowIso}, ${nowIso}, - ${encodeUnknownJsonString({ - id: runId, - threadId, - ordinal: run, - providerInstanceId, - modelSelection, - providerThreadId, - userMessageId: `message:notification-turn-pages:${run}`, - rootNodeId: `node:notification-turn-pages:${run}`, - activeAttemptId: null, - status, - requestedAt: nowIso, - startedAt: nowIso, - completedAt: nowIso, - checkpointId: null, - contextHandoffId: null, - })} - ) - `; - if (run > startedRuns) continue; - const start = - run <= 12 || run % 50 === 0 - ? { - type: "user_message", - createdBy: "user", - creationSource: "web", - messageId: `message:notification-turn-pages:${run}`, - inputIntent: "turn_start", - text: `Turn ${run}`, - attachments: [], - } - : notification(`Wake ${run}`); - const rows = [ - itemRow(runId, command), - itemRow(runId, start), - itemRow(runId, command), - itemRow(runId, notification(`Steer ${run}`)), - ]; - startIds.push(rows[1]!.turn_item_id); - yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`; - } - - const { statements, tracer } = traceSqlStatements(); - const initial = yield* projectionStore - .getThreadSnapshotWindow(threadId, { rowLimit: 77, userTurnLimit: 10 }) - .pipe(Effect.withTracer(tracer)); - assert.deepEqual( - initial.projection.turnItems.map((item) => String(item.id)), - allIds.slice(allIds.indexOf(startIds[260]!)), - ); - const windowStatement = statements.find((statement) => statement.includes("run_starts")); - assert.isDefined(windowStatement); - const windowPlan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${windowStatement}`, - ); - const windowPlanDetails = windowPlan.map((row) => row.detail); - assert.include( - windowPlanDetails, - "SEARCH recent_run USING INDEX orchestration_v2_projection_runs_thread_ordinal_idx (thread_id=? AND ordinal plan.id), - [planId, olderPlanId], - ); - assert.deepEqual( - { - oldRunIds: bounded.projection.runs - .filter((run) => run.ordinal < 261) - .map((run) => run.id), - providerThreadIds: bounded.projection.providerThreads.map((thread) => thread.id), - }, - { - oldRunIds: [RunId.make("run:notification-turn-pages:2")], - providerThreadIds: [providerThreadId], - }, - ); - const loaded = bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId)); - let cursor = bounded.historyCursor; - for (let pages = 0; cursor !== null; pages += 1) { - assert.isBelow(pages, 5); - const anchor = decodeThreadHistoryCursor(cursor); - const snapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: 77, - userTurnLimit: 20, - anchorItemId: TurnItemId.make(anchor.si), - anchorThreadId: ThreadId.make(anchor.st), - }); - assert.isAtMost(snapshot.projection.turnItems.length, 152 * 4 + 1); - const page = selectHistoryPageFromCursor({ - items: snapshot.projection.visibleTurnItems, - cursor, - snapshotSequence: 0, - }); - loaded.unshift(...page.items.map((row) => String(row.sourceItemId))); - cursor = page.nextCursor; - } - assert.deepEqual(loaded, allIds); - - const completedPlan = { - id: planId, - threadId, - runId: RunId.make("run:notification-turn-pages:2"), - nodeId: NodeId.make("node:notification-turn-pages:2"), - kind: "todo_list" as const, - status: "completed" as const, - explanation: "All tasks complete", - steps: [{ id: "unfinished", text: "Finish the task", status: "completed" as const }], - }; - yield* projectionStore.apply({ - id: EventId.make("event:notification-turn-pages:plan:completed"), - type: "plan.updated", - threadId, - occurredAt: now, - payload: completedPlan, - }); - const afterCompletion = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: 77, - userTurnLimit: 10, - }); - const completedBounded = buildBoundedThreadProjection({ - projection: afterCompletion.projection, - snapshotSequence: 0, - }); - assert.deepEqual( - { - plans: completedBounded.projection.plans, - oldRunIds: completedBounded.projection.runs - .filter((run) => run.ordinal < 261) - .map((run) => run.id), - providerThreadIds: completedBounded.projection.providerThreads.map((thread) => thread.id), - }, - { - plans: [completedPlan, bounded.projection.plans.find((plan) => plan.id === olderPlanId)], - oldRunIds: [completedPlan.runId], - providerThreadIds: [providerThreadId], - }, - ); - - // Older versions left completed predecessors unsuperseded; IDs do not order replacements. - const newerPlan = { - ...completedPlan, - id: PlanId.make("plan:legacy-newer"), - status: "active" as const, - explanation: "More work remains", - steps: [{ id: "new", text: "Finish the new task", status: "pending" as const }], - }; - yield* projectionStore.apply({ - id: EventId.make("event:notification-turn-pages:plan:legacy-newer"), - type: "plan.updated", - threadId, - occurredAt: now, - payload: newerPlan, - }); - for (const [plan, itemOrdinal] of [ - [completedPlan, 7], - [newerPlan, 8], - ] as const) { - yield* projectionStore.apply({ - id: EventId.make(`event:notification-turn-pages:legacy-item:${itemOrdinal}`), - type: "turn-item.updated", - threadId, - runId: plan.runId, - nodeId: plan.nodeId, - occurredAt: now, - payload: { - id: TurnItemId.make(`turn-item:notification-turn-pages:${itemOrdinal}`), - threadId, - runId: plan.runId, - nodeId: plan.nodeId, - providerThreadId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: itemOrdinal, - status: "completed", - title: null, - startedAt: now, - completedAt: now, - updatedAt: now, - type: "todo_list", - planId: plan.id, - steps: plan.steps, - explanation: plan.explanation, - }, - }); - } - const legacySnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: 77, - userTurnLimit: 10, - }); - assert.isTrue(legacySnapshot.projection.turnItems.every((item) => item.ordinal > 8)); - assert.deepEqual( - legacySnapshot.projection.plans.findLast((plan) => plan.runId === newerPlan.runId), - newerPlan, - ); - const legacyBounded = buildBoundedThreadProjection({ - projection: legacySnapshot.projection, - snapshotSequence: 0, - }); - assert.deepEqual( - { - ownerPlans: legacyBounded.projection.plans.filter( - (plan) => plan.runId === newerPlan.runId, - ), - oldRunIds: legacyBounded.projection.runs - .filter((run) => run.ordinal < 261) - .map((run) => run.id), - }, - { ownerPlans: [newerPlan], oldRunIds: [newerPlan.runId] }, - ); - }), - ); - - it.effect( - "bounds completed nodes within one long run while preserving ancestry and live work", - () => - Effect.gen(function* () { - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:bounded-node-history"); - yield* projectionStore.apply({ - id: EventId.make("event:bounded-node-history:thread"), - type: "thread.created", - threadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: threadId, - projectId: ProjectId.make("project:bounded-node-history"), - title: "Bounded SQL history", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); - const runId = RunId.make("run:bounded-node-history"); - const rootNodeId = NodeId.make("node:bounded-node-history:root"); - yield* projectionStore.apply({ - id: EventId.make("event:bounded-node-history:run"), - type: "run.created", - threadId, - runId, - occurredAt: now, - payload: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId, - modelSelection, - providerThreadId: null, - userMessageId: MessageId.make("message:bounded-node-history"), - rootNodeId, - activeAttemptId: null, - status: "running", - requestedAt: now, - startedAt: now, - completedAt: null, - checkpointId: null, - contextHandoffId: null, - }, - }); - const parentNodeId = NodeId.make("node:bounded-node-history:parent"); - const liveNodeId = NodeId.make("node:bounded-node-history:live"); - for (let index = -3; index < 1000; index++) { - const id = - index === -3 - ? rootNodeId - : index === -2 - ? parentNodeId - : index === -1 - ? liveNodeId - : NodeId.make(`node:bounded-node-history:${index}`); - yield* projectionStore.apply({ - id: EventId.make(`event:bounded-node-history:node:${index}`), - type: "node.updated", - threadId, - runId, - nodeId: id, - driver, - occurredAt: now, - payload: { - id, - threadId, - runId, - rootNodeId, - parentNodeId: index === -3 ? null : index === -2 ? rootNodeId : parentNodeId, - kind: index === -3 ? "root_turn" : "assistant_message", - status: index === -1 ? "running" : "completed", - countsForRun: index === -3, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: - index === 998 - ? null - : index === 999 - ? DateTime.makeUnsafe("2020-01-01T00:00:00.000Z") - : now, - completedAt: index === -1 ? null : now, - }, - }); - if (index < 0) continue; - yield* projectionStore.apply({ - id: EventId.make(`event:bounded-node-history:item:${index}`), - type: "turn-item.updated", - threadId, - runId, - nodeId: id, - driver, - occurredAt: now, - payload: { - id: TurnItemId.make(`item:bounded-node-history:${index}`), - threadId, - runId, - nodeId: id, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: index, - status: "completed", - title: null, - startedAt: now, - completedAt: now, - updatedAt: now, - type: "command_execution", - input: `echo ${index}`, - output: "ok", - exitCode: 0, - }, - }); - } - const requestNodeId = NodeId.make("node:bounded-node-history:0"); - yield* projectionStore.apply({ - id: EventId.make("event:bounded-node-history:request"), - type: "runtime-request.updated", - threadId, - runId, - nodeId: requestNodeId, - driver, - occurredAt: now, - payload: { - id: RuntimeRequestId.make("request:bounded-node-history"), - nodeId: requestNodeId, - providerTurnId: ProviderTurnId.make("provider-turn:bounded-node-history"), - nativeRequestRef: null, - kind: "command", - status: "pending", - responseCapability: { - type: "live", - providerSessionId: ProviderSessionId.make("session:bounded-node-history"), - }, - createdAt: now, - resolvedAt: null, - }, - }); - const { statements, tracer } = traceSqlStatements(); - const snapshot = yield* projectionStore - .getThreadSnapshotWindow(threadId, { rowLimit: 75 }) - .pipe(Effect.withTracer(tracer)); - const nodeStatement = statements.find( - (statement) => - statement.includes("payload_json") && - statement.includes("orchestration_v2_projection_nodes") && - !statement.includes("WITH RECURSIVE"), - ); - assert.isDefined(nodeStatement); - const nodePlan = yield* (yield* SqlClient.SqlClient).unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${nodeStatement}`, - ); - const nodeLookups = nodePlan - .map((row) => row.detail) - .filter((detail) => detail.startsWith("SEARCH ")); - assert.deepEqual(nodeLookups, [ - "SEARCH node USING INDEX sqlite_autoindex_orchestration_v2_projection_nodes_1 (node_id=?)", - ]); - assert.notInclude( - nodePlan.map((row) => row.detail), - "USE TEMP B-TREE FOR ORDER BY", - ); - assert.lengthOf(snapshot.projection.visibleTurnItems, 75); - assert.lengthOf(snapshot.projection.nodes, 79); - const retained = new Set(snapshot.projection.nodes.map((node) => node.id)); - assert.isTrue(retained.has(rootNodeId)); - assert.isTrue(retained.has(parentNodeId)); - assert.isTrue(retained.has(liveNodeId)); - assert.isTrue(retained.has(requestNodeId)); - assert.lengthOf(snapshot.projection.runtimeRequests, 1); - assert.isFalse(retained.has(NodeId.make("node:bounded-node-history:1"))); - for (const row of snapshot.projection.visibleTurnItems) - assert.isTrue(retained.has(row.item.nodeId!)); - const older = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: 75, - anchorItemId: TurnItemId.make("item:bounded-node-history:925"), - }); - assert.lengthOf(older.projection.nodes, 79); - assert.isTrue( - older.projection.nodes.some( - (node) => node.id === NodeId.make("node:bounded-node-history:851"), - ), - ); - const full = yield* projectionStore.getThreadSnapshot(threadId); - assert.lengthOf(full.projection.nodes, 1003); - assert.deepEqual( - snapshot.projection.nodes, - full.projection.nodes.filter((node) => retained.has(node.id)), - ); - const sql = yield* SqlClient.SqlClient; - const duplicateItemId = TurnItemId.make("item:bounded-node-history:duplicate"); - yield* sql` - INSERT INTO orchestration_v2_projection_turn_items - SELECT ${duplicateItemId}, thread_id, run_id, node_id, provider_thread_id, - provider_turn_id, parent_item_id, ordinal + 1, type, status, updated_at, - json_set(payload_json, '$.id', ${duplicateItemId}, '$.ordinal', ordinal + 1) - FROM orchestration_v2_projection_turn_items - WHERE turn_item_id = ${TurnItemId.make("item:bounded-node-history:999")} - `; - const sharedNodeSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { - rowLimit: 76, - }); - assert.lengthOf(sharedNodeSnapshot.projection.visibleTurnItems, 76); - assert.deepEqual(sharedNodeSnapshot.projection.nodes, snapshot.projection.nodes); - }), + threadId, + nodeId, + driver, + occurredAt: now, + payload: { + ...providerTurn, + status: "completed", + completedAt: now, + tokenUsage: replacementUsage, + }, + }); + + const replaced = yield* projectionStore.getThreadProjection(threadId); + assert.deepEqual(replaced.providerTurns[0]?.tokenUsage, replacementUsage); + }), ); it.effect("reads a fixed SQL turn-item window for long histories and repeated clients", () => @@ -3167,6 +2276,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + // regression: #17141 shell snapshot decoding blocked other database reads it.effect("reads the shell snapshot first and decodes it in a separate step", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -4702,150 +3812,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); - it.effect( - "reads checkpoint context without decoding transcript or checkpoint file payloads", - () => - Effect.gen(function* () { - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const memoryStore = yield* ProjectionStore.ProjectionStoreV2.pipe( - Effect.provide(ProjectionStore.layerMemory), - ); - const apply = (event: Parameters[0]) => - Effect.all([projectionStore.apply(event), memoryStore.apply(event)]); - const sql = yield* SqlClient.SqlClient; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:checkpoint-context"); - const runId = RunId.make("run:checkpoint-context"); - const nodeId = NodeId.make("node:checkpoint-context"); - const scopeId = CheckpointScopeId.make("scope:checkpoint-context"); - const checkpointId = CheckpointId.make("checkpoint:checkpoint-context"); - const ref = CheckpointRef.make("refs/t3/checkpoint-context/1"); - yield* apply({ - id: EventId.make("event:checkpoint-context:thread"), - type: "thread.created", - threadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: threadId, - projectId: ProjectId.make("project:checkpoint-context"), - title: "Checkpoint context", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: "feature", - worktreePath: "/repo/worktree", - activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); - yield* apply({ - id: EventId.make("event:checkpoint-context:run"), - type: "run.created", - threadId, - occurredAt: now, - payload: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId, - modelSelection, - providerThreadId: null, - userMessageId: MessageId.make("message:checkpoint-context"), - rootNodeId: nodeId, - activeAttemptId: null, - status: "completed", - requestedAt: now, - startedAt: now, - completedAt: now, - checkpointId, - contextHandoffId: null, - }, - }); - yield* apply({ - id: EventId.make("event:checkpoint-context:scope"), - type: "checkpoint-scope.created", - threadId, - occurredAt: now, - payload: { - id: scopeId, - threadId, - runId, - nodeId, - parentScopeId: null, - providerThreadId: null, - kind: "root_run", - ordinalWithinParent: 0, - advancesAppRunCount: true, - cwd: "/repo/worktree", - createdAt: now, - }, - }); - yield* apply({ - id: EventId.make("event:checkpoint-context:checkpoint"), - type: "checkpoint.captured", - threadId, - occurredAt: now, - payload: { - id: checkpointId, - threadId, - scopeId, - runId, - nodeId, - parentCheckpointId: null, - ordinalWithinScope: 1, - appRunOrdinal: 1, - ref, - status: "ready", - files: [], - capturedAt: now, - }, - }); - yield* sql` - INSERT INTO orchestration_v2_projection_turn_items ( - turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, - parent_item_id, ordinal, type, status, updated_at, payload_json - ) VALUES ( - 'turn-item:checkpoint-context:obsolete', ${threadId}, ${runId}, ${nodeId}, NULL, NULL, - NULL, 1, 'assistant_message', 'completed', ${DateTime.formatIso(now)}, - ${encodeUnknownJsonString({ obsolete: "transcript shape" })} - ) - `; - assert.strictEqual( - (yield* Effect.exit(projectionStore.getThreadProjection(threadId)))._tag, - "Failure", - ); - yield* sql` - UPDATE orchestration_v2_projection_checkpoints - SET payload_json = json_set(payload_json, '$.files', 'obsolete file summary') - WHERE checkpoint_id = ${checkpointId} - `; - for (const store of [projectionStore, memoryStore]) { - assert.deepEqual(yield* store.getCheckpointContext(threadId), { - runs: [{ id: runId, ordinal: 1, status: "completed" }], - checkpointScopes: [{ id: scopeId, runId, kind: "root_run", cwd: "/repo/worktree" }], - checkpoints: [ - { scopeId, runId, ordinalWithinScope: 1, appRunOrdinal: 1, status: "ready", ref }, - ], - }); - const missing = yield* store - .getCheckpointContext(ThreadId.make("thread:checkpoint-context:missing")) - .pipe(Effect.flip); - assert.instanceOf(missing, ProjectionStore.ProjectionStoreThreadNotFoundError); - } - }), - ); - it.effect("selects the latest waiting secret only from active runs", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -4959,124 +3925,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); - it.effect("builds shell snapshots without decoding full turn item payloads", () => - Effect.gen(function* () { - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const sql = yield* SqlClient.SqlClient; - const now = yield* DateTime.now; - const nowIso = DateTime.formatIso(now); - const threadId = ThreadId.make("thread:projection-shell-stale-item"); - const projectId = ProjectId.make("project:projection-shell"); - - yield* projectionStore.apply({ - id: EventId.make("event:projection-shell-thread-created"), - type: "thread.created", - threadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: threadId, - projectId, - title: "Projection shell", - providerInstanceId, - modelSelection: modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: threadId, - }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); - - yield* sql` - INSERT INTO orchestration_v2_projection_turn_items ( - turn_item_id, - thread_id, - run_id, - node_id, - provider_thread_id, - provider_turn_id, - parent_item_id, - ordinal, - type, - status, - updated_at, - payload_json - ) - VALUES ( - ${"turn-item:stale-user-message"}, - ${threadId}, - ${null}, - ${null}, - ${null}, - ${null}, - ${null}, - ${0}, - ${"user_message"}, - ${"completed"}, - ${nowIso}, - ${encodeUnknownJsonString({ - id: "turn-item:stale-user-message", - threadId, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 0, - status: "completed", - title: null, - startedAt: nowIso, - completedAt: nowIso, - updatedAt: nowIso, - type: "user_message", - messageId: "message:stale-user-message", - text: "stale user message", - attachments: [], - })} - ) - `; - - const shell = yield* projectionStore.getShellSnapshot(); - const fullProjectionExit = yield* Effect.exit(projectionStore.getThreadProjection(threadId)); - - assert.deepEqual( - shell.threads - .filter((thread) => thread.id === threadId) - .map((thread) => ({ - id: thread.id, - itemCount: thread.itemCount, - visibleItemCount: thread.visibleItemCount, - status: thread.status, - })), - [ - { - id: threadId, - itemCount: 1, - visibleItemCount: 1, - status: "idle", - }, - ], - ); - assert.equal(fullProjectionExit._tag, "Failure"); - }), - ); - it.effect("counts imported runless history inherited by fork shells", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -6530,161 +5378,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); - it.effect("skips parent bodies once a fork's own turns fill its first page", () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const now = DateTime.makeUnsafe("2026-10-01T00:00:00.000Z"); - const parentId = ThreadId.make("thread:filled-fork:parent"); - const forkId = ThreadId.make("thread:filled-fork:fork"); - const parentText = "parent prompt"; - const createThread = ( - threadId: ThreadId, - forkedFrom: { readonly threadId: ThreadId; readonly runId: RunId } | null, - ) => - store.apply({ - id: EventId.make(`event:${threadId}`), - type: "thread.created", - threadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: threadId, - projectId: ProjectId.make("project:filled-fork"), - title: "Filled fork", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { - parentThreadId: forkedFrom?.threadId ?? null, - relationshipToParent: forkedFrom === null ? null : "fork", - rootThreadId: parentId, - }, - forkedFrom: forkedFrom === null ? null : { type: "run", ...forkedFrom }, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); - const addTurn = Effect.fnUntraced(function* (threadId: ThreadId, turn: number, text: string) { - const runId = RunId.make(`run:${threadId}:${turn}`); - const nodeId = NodeId.make(`node:${threadId}:${turn}`); - yield* store.apply({ - id: EventId.make(`event:${runId}`), - type: "run.created", - threadId, - runId, - nodeId, - driver, - occurredAt: now, - payload: { - id: runId, - threadId, - ordinal: turn, - providerInstanceId, - modelSelection, - providerThreadId: null, - userMessageId: MessageId.make(`message:${runId}`), - rootNodeId: nodeId, - activeAttemptId: null, - status: "completed", - requestedAt: now, - startedAt: now, - completedAt: now, - checkpointId: null, - contextHandoffId: null, - }, - }); - yield* store.apply({ - id: EventId.make(`event:${runId}:prompt`), - type: "turn-item.updated", - threadId, - runId, - driver, - occurredAt: now, - payload: { - id: TurnItemId.make(`item:${runId}`), - threadId, - runId, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: turn, - status: "completed", - title: null, - startedAt: now, - completedAt: now, - updatedAt: now, - type: "user_message", - messageId: MessageId.make(`message:${runId}`), - inputIntent: "turn_start", - createdBy: "user", - creationSource: "web", - text, - attachments: [], - }, - }); - }); - const readFirstPage = Effect.fnUntraced(function* ( - threadId: ThreadId, - parsesParent: boolean, - ) { - const full = yield* store.getThreadSnapshot(threadId); - // Measure only the window read; the full snapshot is the pagination oracle. - const parse = yield* Effect.acquireRelease( - Effect.sync(() => vi.spyOn(JSON, "parse")), - (spy) => Effect.sync(() => spy.mockRestore()), - ); - const window = yield* store.getThreadSnapshotWindow(threadId, { - rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, - userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, - }); - assert.equal( - parse.mock.calls.some(([text]) => text.includes(parentText)), - parsesParent, - ); - const page = buildBoundedThreadProjection(window); - assert.deepEqual(page, buildBoundedThreadProjection(full)); - assert.isTrue(page.hasMoreHistory); - assert.isNotNull(page.historyCursor); - return page.projection.visibleTurnItems; - }, Effect.scoped); - - yield* createThread(parentId, null); - for (const turn of [1, 2, 3]) yield* addTurn(parentId, turn, `${parentText} ${turn}`); - // The third parent turn is after the fork boundary and must stay excluded. - yield* createThread(forkId, { threadId: parentId, runId: RunId.make(`run:${parentId}:2`) }); - for (let turn = 1; turn <= 9; turn += 1) yield* addTurn(forkId, turn, "fork prompt"); - const partial = yield* readFirstPage(forkId, true); - assert.include( - partial.map((row) => row.visibility), - "inherited", - ); - - yield* addTurn(forkId, 10, "fork prompt"); - const filled = yield* readFirstPage(forkId, false); - assert.notInclude( - filled.map((row) => row.visibility), - "inherited", - ); - - const nestedId = ThreadId.make("thread:filled-fork:nested"); - yield* createThread(nestedId, { threadId: forkId, runId: RunId.make(`run:${forkId}:10`) }); - for (let turn = 1; turn <= 10; turn += 1) yield* addTurn(nestedId, turn, "nested prompt"); - yield* readFirstPage(nestedId, true); - }), - ); - it.effect("a pull request watch keeps a finished thread working until it ends", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index de5490dae661..82b9457d0be8 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -680,7 +680,7 @@ function upsertById(items: ReadonlyArray, return updated; } -export function upsertProviderTurn( +function upsertProviderTurn( turns: ReadonlyArray, next: OrchestrationV2ProviderTurn, ): Array { @@ -1058,6 +1058,7 @@ type ShellThreadRow = { readonly latest_run_requested_at: string | null; readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; + readonly latest_run_stop_requested: number; readonly active_run_id: string | null; readonly active_run_status: string | null; readonly activity_run_status: string | null; @@ -1068,6 +1069,7 @@ type ShellThreadRow = { readonly blocking_run_requested_at: string | null; readonly blocking_run_started_at: string | null; readonly blocking_run_completed_at: string | null; + readonly blocking_run_stop_requested: number; readonly blocking_failure_payload_json: string | null; readonly pending_request_payload_json: string | null; readonly pending_secret_request_payload_json: string | null; @@ -1374,7 +1376,7 @@ function makeForkMarkerTurnItem(input: { }; } -export function isTurnItemAtOrBeforeRun(input: { +function isTurnItemAtOrBeforeRun(input: { readonly historyOrigin: OrchestrationV2ThreadProjection["thread"]["historyOrigin"]; readonly itemRunId: OrchestrationV2TurnItem["runId"]; readonly runOrdinalById: ReadonlyMap, number>; @@ -1598,6 +1600,11 @@ export function threadShellFromProjection( latestRunRequestedAt: latestRun?.requestedAt ?? null, latestRunStartedAt: latestRun?.startedAt ?? null, latestRunCompletedAt: latestRun?.completedAt ?? null, + latestRunStopRequested: + latestRun != null && + projection.turnItems.some( + (item) => item.runId === latestRun.id && item.type === "run_interrupt_request", + ), activeRunId: activeRun?.id ?? null, blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }), activityRunStatus: activityRun?.status ?? null, @@ -1697,6 +1704,7 @@ type ShellThreadState = { readonly latestRunRequestedAt: DateTime.Utc | null; readonly latestRunStartedAt: DateTime.Utc | null; readonly latestRunCompletedAt: DateTime.Utc | null; + readonly latestRunStopRequested: boolean; readonly activeRunId: RunId | null; readonly blockedOnThreadWait: boolean; readonly activityRunStatus: ShellActivityRunStatus | null; @@ -1850,6 +1858,7 @@ function shellFromState(input: { latestRunRequestedAt: input.state.latestRunRequestedAt, latestRunStartedAt: input.state.latestRunStartedAt, latestRunCompletedAt: input.state.latestRunCompletedAt, + latestRunStopRequested: input.state.latestRunStopRequested, activeRunId: input.state.activeRunId, blockedOnThreadWait: input.state.blockedOnThreadWait, activityRunStatus: input.state.activityRunStatus, @@ -5638,6 +5647,11 @@ export const layer: Layer.Layer = presented.requested_at AS latest_run_requested_at, json_extract(presented.payload_json, '$.startedAt') AS latest_run_started_at, presented.completed_at AS latest_run_completed_at, + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items stop_request + WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id + AND stop_request.type = 'run_interrupt_request' + ) AS latest_run_stop_requested, active.run_id AS active_run_id, active.status AS active_run_status, ( @@ -5688,6 +5702,11 @@ export const layer: Layer.Layer = blocked.requested_at AS blocking_run_requested_at, json_extract(blocked.payload_json, '$.startedAt') AS blocking_run_started_at, blocked.completed_at AS blocking_run_completed_at, + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items stop_request + WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = blocked.run_id + AND stop_request.type = 'run_interrupt_request' + ) AS blocking_run_stop_requested, ( SELECT item.payload_json FROM orchestration_v2_projection_turn_items item @@ -6160,6 +6179,7 @@ export const layer: Layer.Layer = row.latest_run_completed_at === null ? null : DateTime.makeUnsafe(row.latest_run_completed_at); + let latestRunStopRequested = row.latest_run_stop_requested === 1; if (row.blocking_run_id !== null && row.blocking_failure_payload_json !== null) { const blockingFailure = yield* decodeTurnItemPayload( row.blocking_failure_payload_json, @@ -6172,6 +6192,7 @@ export const layer: Layer.Layer = if (blocksQueue && blockingFailure !== null) { terminalFailureItem = blockingFailure; latestRunId = RunId.make(row.blocking_run_id); + latestRunStopRequested = row.blocking_run_stop_requested === 1; latestRunStatus = "failed"; latestRunRequestedAt = row.blocking_run_requested_at === null @@ -6211,6 +6232,7 @@ export const layer: Layer.Layer = latestRunRequestedAt, latestRunStartedAt, latestRunCompletedAt, + latestRunStopRequested, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun: diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts index 2f2c545377eb..b9ce054de8fe 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts @@ -121,53 +121,6 @@ const trackedCreate = ( return yield* effect; }); -it.effect("closes a partially-created adapter scope immediately on typed failure", () => - Effect.gen(function* () { - const releases = yield* Ref.make(0); - const createError = new ProviderAdapterDriverCreateError({ - driver: lifecycleDriver, - instanceId: lifecycleInstanceId, - detail: "expected test failure", - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const exit = yield* ProviderAdapterRegistry.makeRegistryFromConfigMap({ - drivers: [makeLifecycleDriver(trackedCreate(releases, Effect.fail(createError)))], - configMap: lifecycleConfigMap, - }).pipe(Effect.exit); - - assert.isTrue(Exit.isFailure(exit)); - assert.strictEqual(yield* Ref.get(releases), 1); - }), - ); - - assert.strictEqual(yield* Ref.get(releases), 1); - }), -); - -it.effect("closes a partially-created adapter scope immediately on defect", () => - Effect.gen(function* () { - const releases = yield* Ref.make(0); - - yield* Effect.scoped( - Effect.gen(function* () { - const exit = yield* ProviderAdapterRegistry.makeRegistryFromConfigMap({ - drivers: [ - makeLifecycleDriver(trackedCreate(releases, Effect.die("expected test defect"))), - ], - configMap: lifecycleConfigMap, - }).pipe(Effect.exit); - - assert.isTrue(Exit.hasDies(exit)); - assert.strictEqual(yield* Ref.get(releases), 1); - }), - ); - - assert.strictEqual(yield* Ref.get(releases), 1); - }), -); - it.effect("closes a partially-created adapter scope immediately on interruption", () => Effect.gen(function* () { const releases = yield* Ref.make(0); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index a1a720afd710..6e2abc0c1d6b 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -11,7 +11,6 @@ import { type OrchestrationV2Notification, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -157,49 +156,6 @@ describe("ProviderContinuationService", () => { }), ); - it.effect("marks an adapter-buffered wake with the provider creation source", () => { - return Effect.gen(function* () { - const dispatched = yield* Queue.unbounded(); - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer(request()); - const command = (yield* Queue.take(dispatched)) as { - readonly creationSource: string; - readonly notification: OrchestrationV2Notification; - }; - assert.equal(command.creationSource, "provider"); - assert.deepEqual(command.notification, { - source: { kind: "background_task" }, - outcome: "updated", - summary: "Background activity updated", - }); - }).pipe( - Effect.provide( - layerTest({ dispatched, getThreadRecords: () => Effect.succeed(projection) }), - ), - Effect.scoped, - ); - }); - }); - - it.effect("dispatches a wake under the message its adapter named", () => { - return Effect.gen(function* () { - const dispatched = yield* Queue.unbounded(); - const messageId = MessageId.make("message-provider-continuation-adapter-named"); - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer({ ...request(), messageId }); - const command = (yield* Queue.take(dispatched)) as { readonly messageId: MessageId }; - assert.equal(command.messageId, messageId); - }).pipe( - Effect.provide( - layerTest({ dispatched, getThreadRecords: () => Effect.succeed(projection) }), - ), - Effect.scoped, - ); - }); - }); - it.effect("delivers a message_text wake as a real prompt, not a buffered wake", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); @@ -294,242 +250,6 @@ describe("ProviderContinuationService", () => { }); }); - it.effect("retries a delegated completion after a transient dispatch failure", () => { - return Effect.gen(function* () { - const attempts = yield* Ref.make(0); - const dispatched = yield* Queue.unbounded(); - const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(delegatedProjection()), - dispatch: (command) => - Ref.getAndUpdate(attempts, (count) => count + 1).pipe( - Effect.flatMap((attempt) => - attempt === 0 - ? Effect.fail(new Error("simulated transient dispatch failure") as never) - : Queue.offer(dispatched, command).pipe(Effect.as({} as never)), - ), - ), - }); - const layerWorker = ProviderContinuationService.layer.pipe( - Layer.provide( - Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), - ), - ); - - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer({ - threadId, - providerThreadId, - driver, - detail: null, - delivery: "message_text", - delegatedCompletion: { - parentRunId, - generation: 1, - messageId: delegatedMessageId, - }, - }); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 1); - assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); - - yield* TestClock.adjust("100 millis"); - const command = (yield* Queue.take(dispatched)) as { - readonly messageId: MessageId; - }; - assert.equal(command.messageId, delegatedMessageId); - assert.equal(yield* Ref.get(attempts), 2); - }).pipe( - Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), - Effect.scoped, - ); - }); - }); - - it.effect("backs off repeated delegated completion dispatch failures", () => { - return Effect.gen(function* () { - const attempts = yield* Ref.make(0); - const disposition = yield* Ref.make<"open" | "disposed">("open"); - const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => - Ref.get(disposition).pipe(Effect.map((state) => delegatedProjection(state))), - dispatch: () => - Ref.update(attempts, (count) => count + 1).pipe( - Effect.andThen(Effect.fail(new Error("simulated persistent failure") as never)), - ), - }); - const layerWorker = ProviderContinuationService.layer.pipe( - Layer.provide( - Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), - ), - ); - - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer({ - threadId, - providerThreadId, - driver, - detail: null, - delivery: "message_text", - delegatedCompletion: { - parentRunId, - generation: 1, - messageId: delegatedMessageId, - }, - }); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 1); - - yield* TestClock.adjust("100 millis"); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 2); - yield* TestClock.adjust("199 millis"); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 2); - yield* TestClock.adjust("1 millis"); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 3); - - yield* Ref.set(disposition, "disposed"); - yield* TestClock.adjust("400 millis"); - }).pipe( - Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), - Effect.scoped, - ); - }); - }); - - it.effect("resets delegated completion retry backoff after archive or deletion", () => { - return Effect.gen(function* () { - for (const barrier of ["archivedAt", "deletedAt"] as const) { - const attempts = yield* Ref.make(0); - const blockedProjectionReads = yield* Ref.make(0); - const blockedRequestDropped = yield* Deferred.make(); - const state = yield* Ref.make<"open" | "blocked" | "disposed">("open"); - const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => - Effect.gen(function* () { - const currentState = yield* Ref.get(state); - if (currentState === "blocked") { - const reads = yield* Ref.updateAndGet(blockedProjectionReads, (count) => count + 1); - if (reads === 2) { - yield* Deferred.succeed(blockedRequestDropped, undefined); - } - } - return { - ...delegatedProjection(currentState === "disposed" ? "disposed" : "open"), - thread: { - ...projection.thread, - [barrier]: - currentState === "blocked" - ? DateTime.makeUnsafe("2026-08-05T00:00:00.000Z") - : null, - }, - }; - }), - dispatch: () => - Ref.update(attempts, (count) => count + 1).pipe( - Effect.andThen(Effect.fail(new Error("simulated persistent failure") as never)), - ), - }); - const layerWorker = ProviderContinuationService.layer.pipe( - Layer.provide( - Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), - ), - ); - const completionRequest = { - threadId, - providerThreadId, - driver, - detail: null, - delivery: "message_text" as const, - delegatedCompletion: { - parentRunId, - generation: 1, - messageId: delegatedMessageId, - }, - }; - - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer(completionRequest); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 1); - - yield* Ref.set(state, "blocked"); - yield* TestClock.adjust("100 millis"); - yield* Deferred.await(blockedRequestDropped); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 1); - - yield* Ref.set(state, "open"); - yield* requests.offer(completionRequest); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 2); - yield* TestClock.adjust("99 millis"); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 2); - yield* TestClock.adjust("1 millis"); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 3); - - yield* Ref.set(state, "disposed"); - yield* TestClock.adjust("200 millis"); - }).pipe( - Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), - Effect.scoped, - ); - } - }); - }); - - it.effect("drops a delegated completion retry after its delivery closes", () => { - return Effect.gen(function* () { - const attempts = yield* Ref.make(0); - const disposition = yield* Ref.make<"open" | "disposed">("open"); - const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => - Ref.get(disposition).pipe(Effect.map((state) => delegatedProjection(state))), - dispatch: () => - Ref.update(attempts, (count) => count + 1).pipe( - Effect.andThen(Effect.fail(new Error("simulated dispatch failure") as never)), - ), - }); - const layerWorker = ProviderContinuationService.layer.pipe( - Layer.provide( - Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), - ), - ); - - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer({ - threadId, - providerThreadId, - driver, - detail: null, - delivery: "message_text", - delegatedCompletion: { - parentRunId, - generation: 1, - messageId: delegatedMessageId, - }, - }); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 1); - - yield* Ref.set(disposition, "disposed"); - yield* TestClock.adjust("100 millis"); - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(attempts), 1); - }).pipe( - Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), - Effect.scoped, - ); - }); - }); - it.effect( "a project-disabled instance refuses a delegated-completion wake exactly once, acknowledges it, and never retries", () => { @@ -711,39 +431,6 @@ describe("ProviderContinuationService", () => { }, ); - it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => { - return Effect.gen(function* () { - const dispatched = yield* Queue.unbounded(); - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer({ - threadId, - providerThreadId, - driver: ProviderDriverKind.make("grok"), - detail: null, - delivery: "message_text", - delegatedCompletion: { - parentRunId, - generation: 1, - messageId: delegatedMessageId, - }, - }); - const command = (yield* Queue.take(dispatched)) as { - readonly dispatchMode: { readonly type: string }; - }; - assert.deepEqual(command.dispatchMode, { type: "queue_after_active" }); - }).pipe( - Effect.provide( - layerTest({ - dispatched, - getThreadRecords: () => Effect.succeed(delegatedProjection()), - }), - ), - Effect.scoped, - ); - }); - }); - it.effect( "drops stopped and disposed delegated completions instead of reviving them after recovery", () => { @@ -817,47 +504,6 @@ describe("ProviderContinuationService", () => { }); }); - it.effect("drops archived and deleted delegated completions during recovery", () => { - return Effect.gen(function* () { - for (const barrier of ["archivedAt", "deletedAt"] as const) { - const dispatched = yield* Queue.unbounded(); - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer({ - threadId, - providerThreadId, - driver, - detail: null, - delivery: "message_text", - delegatedCompletion: { - parentRunId, - generation: 1, - messageId: delegatedMessageId, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); - }).pipe( - Effect.provide( - layerTest({ - dispatched, - getThreadRecords: () => - Effect.succeed({ - ...delegatedProjection(), - thread: { - ...projection.thread, - [barrier]: DateTime.makeUnsafe("2026-08-03T00:00:00.000Z"), - }, - } as unknown as OrchestrationV2ThreadProjection), - }), - ), - Effect.scoped, - ); - } - }); - }); - it.effect("dispatches a current request exactly once", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); @@ -876,46 +522,6 @@ describe("ProviderContinuationService", () => { }); }); - it.effect("queues a continuation behind an active user run", () => { - return Effect.gen(function* () { - const dispatched = yield* Queue.unbounded(); - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer(request()); - const command = yield* Queue.take(dispatched); - assert.deepEqual((command as { readonly dispatchMode?: unknown }).dispatchMode, { - type: "queue_after_active", - }); - }).pipe( - Effect.provide( - layerTest({ dispatched, getThreadRecords: () => Effect.succeed(projection) }), - ), - Effect.scoped, - ); - }); - }); - - it.effect("drops a request invalidated before dispatch", () => { - return Effect.gen(function* () { - const dispatched = yield* Queue.unbounded(); - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const guard = yield* makeGuard(); - const dispatchIfCurrent = yield* guard.capture; - yield* guard.invalidate; - yield* requests.offer(request(dispatchIfCurrent)); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); - }).pipe( - Effect.provide( - layerTest({ dispatched, getThreadRecords: () => Effect.succeed(projection) }), - ), - Effect.scoped, - ); - }); - }); - it.effect("drops a request invalidated while projection is blocked", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); @@ -986,38 +592,4 @@ describe("ProviderContinuationService", () => { ); }); }); - - it.effect("clears a sticky continuation offer when its thread is archived", () => { - return Effect.gen(function* () { - const dispatched = yield* Queue.unbounded(); - const cleared = yield* Ref.make(false); - yield* Effect.gen(function* () { - const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - yield* requests.offer({ - ...request(), - clearIfCurrent: () => Ref.set(cleared, true), - }); - for (let attempt = 0; attempt < 20 && !(yield* Ref.get(cleared)); attempt += 1) { - yield* Effect.yieldNow; - } - assert.isTrue(yield* Ref.get(cleared)); - assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); - }).pipe( - Effect.provide( - layerTest({ - dispatched, - getThreadRecords: () => - Effect.succeed({ - ...projection, - thread: { - ...projection.thread, - archivedAt: DateTime.makeUnsafe("2026-07-20T00:00:00.000Z"), - }, - }), - }), - ), - Effect.scoped, - ); - }); - }); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 54c4a22d8b0d..849cedded929 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -11,14 +11,12 @@ import { type OrchestrationV2AppThread, type OrchestrationV2DomainEvent, type OrchestrationV2ExecutionNode, - type OrchestrationV2PlanArtifact, type OrchestrationV2RuntimeRequest, type OrchestrationV2ProviderThread, type OrchestrationV2Run, type OrchestrationV2TurnItem, ProviderDriverKind, ProviderInstanceId, - PlanId, type ProviderThreadId, ProviderTurnId, RunAttemptId, @@ -34,7 +32,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -666,122 +663,6 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect("carries plan-step durations through consecutive and restarted ingestion", () => - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse("2026-09-07T00:00:00.000Z")); - const now = yield* DateTime.now; - const eventSink = yield* EventSink.EventSinkV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const threadEvent = yield* threadCreatedEvent(now); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId: threadEvent.threadId, - }); - const planId = PlanId.make("plan:provider-event-duration"); - const nodeId = NodeId.make("node:provider-event-duration"); - type TodoListPlan = Extract; - const plan = (steps: TodoListPlan["steps"]): TodoListPlan => ({ - id: planId, - threadId: threadEvent.threadId, - runId: null, - nodeId, - kind: "todo_list", - status: "active", - steps, - }); - const ingest = ( - service: ProviderEventIngestor.ProviderEventIngestorV2["Service"], - steps: TodoListPlan["steps"], - ) => - service.ingestNormalized({ - providerSessionId, - providerInstanceId: modelSelection.instanceId, - threadId: threadEvent.threadId, - event: { type: "plan.updated", driver: CODEX_DRIVER, plan: plan(steps) }, - }); - - yield* eventSink.write({ events: [threadEvent] }); - yield* ingest(ingestor, [ - { id: "duplicate-a", text: "Verify", status: "running" }, - { id: "duplicate-b", text: "Verify", status: "pending" }, - { id: "fallback", text: "Report", status: "pending" }, - ]); - yield* TestClock.adjust("3 seconds"); - yield* ingest(ingestor, [ - { id: "duplicate-a", text: "Verify", status: "completed" }, - { id: "duplicate-b", text: "Verify", status: "pending" }, - { id: "fallback", text: "Report", status: "pending" }, - ]); - - const restartedIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2.pipe( - Effect.provide( - Layer.fresh(ProviderEventIngestor.layer).pipe( - Layer.provide( - Layer.succeed(ProjectionStore.ProjectionStoreV2, { - ...projectionStore, - getThreadProjection: () => Effect.die("Plan timing must not load thread history"), - }), - ), - ), - ), - ); - yield* TestClock.adjust("4 seconds"); - yield* ingest(restartedIngestor, [ - { id: "duplicate-a", text: "Verify", status: "completed" }, - { id: "duplicate-b", text: "Verify", status: "completed" }, - { id: "fallback", text: "Report", status: "pending" }, - ]); - yield* TestClock.adjust("5 seconds"); - yield* ingest(restartedIngestor, [ - { id: "duplicate-a", text: "Verify", status: "completed" }, - { id: "duplicate-b", text: "Verify", status: "completed" }, - { id: "fallback", text: "Report", status: "completed" }, - ]); - - const projection = yield* projectionStore.getThreadProjection(threadEvent.threadId); - const persisted = projection.plans.find( - (candidate): candidate is TodoListPlan => - candidate.kind === "todo_list" && candidate.id === planId, - ); - assert.deepEqual( - persisted?.steps.map(({ id, text, status, durationMs }) => ({ - id, - text, - status, - durationMs, - })), - [ - { id: "duplicate-a", text: "Verify", status: "completed", durationMs: 3_000 }, - { id: "duplicate-b", text: "Verify", status: "completed", durationMs: 4_000 }, - { id: "fallback", text: "Report", status: "completed", durationMs: 5_000 }, - ], - ); - - yield* ingest(restartedIngestor, [ - { id: "duplicate-a", text: "Inserted task", status: "running" }, - { id: "duplicate-b", text: "Verify", status: "completed" }, - { id: "fallback", text: "Different completed task", status: "completed" }, - ]); - yield* TestClock.adjust("2 seconds"); - yield* ingest(restartedIngestor, [ - { id: "duplicate-a", text: "Inserted task", status: "completed" }, - { id: "duplicate-b", text: "Verify", status: "completed" }, - { id: "fallback", text: "Different completed task", status: "completed" }, - ]); - const updated = yield* projectionStore.getThreadProjection(threadEvent.threadId); - const changedPlan = updated.plans.find( - (candidate): candidate is TodoListPlan => - candidate.kind === "todo_list" && candidate.id === planId, - ); - assert.deepEqual( - changedPlan?.steps.map((step) => step.durationMs), - [2_000, 4_000, undefined], - ); - }), - ); - it.effect( "treats successful provider terminal markers as non-persisted orchestration control signals", () => @@ -1082,7 +963,7 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect.each(["completed", "interrupted", "failed", "cancelled", "control"] as const)( + it.effect.each(["interrupted", "control"] as const)( "dismisses only native questions when a provider turn ends with %s", (terminal) => Effect.gen(function* () { @@ -1896,20 +1777,15 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect.each(["explicit", "detached", "unchanged", "legacy"])( + // regression: #20079 the renamed-branch correction must not rewrite a child that needs none + it.effect.each(["explicit", "detached"])( "preserves a native child's branch when the checkout needs no correction: %s", (scenario) => Effect.gen(function* () { const { worktreePath, command } = yield* makeSharedWorktree; const git = yield* GitVcsDriver.GitVcsDriver; - const branch = - scenario === "explicit" - ? "child-feature" - : scenario === "legacy" - ? "t3-abcd1234" - : "t3/abcd1234"; - if (scenario === "explicit" || scenario === "legacy") - yield* command(worktreePath, ["branch", "-m", branch]); + const branch = scenario === "explicit" ? "child-feature" : "t3/abcd1234"; + if (scenario === "explicit") yield* command(worktreePath, ["branch", "-m", branch]); if (scenario === "detached") yield* command(worktreePath, ["checkout", "--detach"]); const now = yield* DateTime.now; const sink = yield* EventSink.EventSinkV2; @@ -1966,76 +1842,6 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect.each( - ["codex", "claude"].flatMap((driver) => - ["/repo/shared", "/repo/isolated", null].map((worktreePath) => ({ driver, worktreePath })), - ), - )("records a native child with current shared workspace: %j", ({ driver, worktreePath }) => - Effect.gen(function* () { - const { repo, worktreePath: sharedPath, command } = yield* makeSharedWorktree; - const isolatedPath = `${sharedPath}-isolated`; - yield* command(sharedPath, ["branch", "-m", "generated-branch"]); - yield* command(repo, ["worktree", "add", "-b", "t3/abcd1234", isolatedPath]); - const actualPath = - worktreePath === "/repo/shared" ? sharedPath : worktreePath === null ? null : isolatedPath; - const now = yield* DateTime.now; - const eventSink = yield* EventSink.EventSinkV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const rootEvent = yield* threadCreatedEvent(now); - if (rootEvent.type !== "thread.created") throw new Error("Expected thread fixture"); - const childThreadId = idAllocator.derive.threadFromProviderThread({ - driver: ProviderDriverKind.make(driver), - nativeThreadId: `late-native-subagent:${worktreePath}`, - }); - const offered: OrchestrationV2AppThread = { - ...rootEvent.payload, - id: childThreadId, - branch: "t3/abcd1234", - worktreePath: actualPath, - lineage: { - parentThreadId: rootEvent.threadId, - relationshipToParent: "subagent", - rootThreadId: rootEvent.threadId, - }, - }; - yield* eventSink.write({ - events: [ - { - ...rootEvent, - payload: { - ...rootEvent.payload, - branch: "generated-branch", - worktreePath: sharedPath, - }, - }, - ], - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId: rootEvent.threadId, - }); - yield* ingestor.ingestNormalized({ - providerSessionId, - providerInstanceId: modelSelection.instanceId, - threadId: rootEvent.threadId, - event: { - type: "app_thread.created", - driver: ProviderDriverKind.make(driver), - appThread: offered, - }, - }); - const child = yield* projectionStore.getThread(childThreadId); - assert.equal( - child.branch, - worktreePath === "/repo/shared" ? "generated-branch" : offered.branch, - ); - assert.equal(child.worktreePath, actualPath); - assert.deepEqual(child.lineage, offered.lineage); - }), - ); - it.effect("updates provider child metadata without recreating its saved thread", () => Effect.gen(function* () { const now = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts deleted file mode 100644 index 23d0dfefbde6..000000000000 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { - CheckpointScopeId, - CommandId, - EventId, - MessageId, - NodeId, - type OrchestrationV2AppThread, - type OrchestrationV2DomainEvent, - type OrchestrationV2Run, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, - RunAttemptId, - RunId, - ThreadId, - TurnItemId, -} from "@t3tools/contracts"; -import * as Console from "effect/Console"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; -import * as EffectOutbox from "./EffectOutbox.ts"; -import * as EventSink from "./EventSink.ts"; -import * as EventStore from "./EventStore.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProjectionStore from "./ProjectionStore.ts"; -import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; - -// `it.live` keeps a real clock: each reconcile gets a fresh command id, so the -// second recover cannot hide behind command receipt dedup. - -const layerStores = Layer.mergeAll( - EventStore.layer, - ProjectionStore.layer, - EffectOutbox.layer, - IdAllocator.layer, -).pipe(Layer.provideMerge(SqlitePersistence.layerMemory)); -const layerTest = ProviderRuntimeRecovery.layer.pipe( - Layer.provideMerge(EventSink.layer.pipe(Layer.provideMerge(layerStores))), - Layer.provideMerge(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true })), -); - -const providerInstanceId = ProviderInstanceId.make("codex"); -const driver = ProviderDriverKind.make("codex"); -const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; -const projectId = ProjectId.make("project:recovery-bench"); -const ITEMS_PER_SETTLED_THREAD = 10; -const OPEN_ITEMS_PER_ACTIVE_THREAD = 3; -const WAITING_THREADS = 3; -const SEED_BATCH = 500; - -type UnstampedEvent = OrchestrationV2DomainEvent extends infer Event - ? Event extends OrchestrationV2DomainEvent - ? Omit - : never - : never; - -interface Scenario { - readonly settled: number; - readonly active: number; -} - -interface Seeded { - readonly unheldQueuedThreads: number; - readonly waitingThreads: number; -} - -const seedScenario = Effect.fn(function* (scenario: Scenario) { - const projections = yield* ProjectionStore.ProjectionStoreV2; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const sql = yield* SqlClient.SqlClient; - const now = yield* DateTime.now; - let eventIndex = 0; - const apply = (event: UnstampedEvent) => - projections.apply({ - ...event, - id: EventId.make(`event:bench:${eventIndex++}`), - occurredAt: now, - } as OrchestrationV2DomainEvent); - - const thread = (id: ThreadId, overrides: Partial = {}) => - apply({ - type: "thread.created", - threadId: id, - payload: { - createdBy: "user", - creationSource: "web", - id, - projectId, - title: id, - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - ...overrides, - }, - }); - - const run = ( - threadId: ThreadId, - ordinal: number, - status: OrchestrationV2Run["status"], - overrides: Partial = {}, - ) => { - const runId = RunId.make(`run:${threadId}:${ordinal}`); - return apply({ - type: "run.created", - threadId, - runId, - payload: { - id: runId, - threadId, - ordinal, - providerInstanceId, - modelSelection, - providerThreadId: null, - userMessageId: MessageId.make(`message:${runId}`), - rootNodeId: null, - activeAttemptId: null, - status, - requestedAt: now, - startedAt: status === "queued" ? null : now, - completedAt: status === "completed" ? now : null, - checkpointId: null, - contextHandoffId: null, - ...overrides, - }, - }).pipe(Effect.as(runId)); - }; - - const commandItem = (input: { - readonly threadId: ThreadId; - readonly runId: RunId; - readonly ordinal: number; - readonly status: "running" | "completed"; - readonly nodeId?: NodeId; - readonly providerThreadId?: ProviderThreadId; - readonly providerTurnId?: ProviderTurnId; - }) => - apply({ - type: "turn-item.updated", - threadId: input.threadId, - runId: input.runId, - payload: { - id: TurnItemId.make(`item:${input.runId}:${input.ordinal}`), - threadId: input.threadId, - runId: input.runId, - nodeId: input.nodeId ?? null, - providerThreadId: input.providerThreadId ?? null, - providerTurnId: input.providerTurnId ?? null, - nativeItemRef: null, - parentItemId: null, - ordinal: input.ordinal, - type: "command_execution", - status: input.status, - title: `Command ${input.ordinal}`, - input: `echo ${input.ordinal}`, - output: input.status === "completed" ? `${input.ordinal}\n` : "", - ...(input.status === "completed" ? { exitCode: 0 } : {}), - startedAt: now, - completedAt: input.status === "completed" ? now : null, - updatedAt: now, - }, - }); - - const settledThread = Effect.fn(function* (index: number) { - const threadId = ThreadId.make(`thread:bench:settled:${index}`); - yield* thread(threadId); - const runId = yield* run(threadId, 1, "completed"); - for (let ordinal = 1; ordinal <= ITEMS_PER_SETTLED_THREAD; ordinal += 1) { - yield* commandItem({ threadId, runId, ordinal, status: "completed" }); - } - if (index % 20 === 0) { - const queueHeld = index % 40 === 0; - yield* run(threadId, 2, "queued", { queueHeld }); - return !queueHeld; - } - return false; - }); - - const activeThread = Effect.fn(function* (index: number) { - const threadId = ThreadId.make(`thread:bench:active:${index}`); - const providerSessionId = ProviderSessionId.make(`session:bench:${index}`); - const providerThreadId = ProviderThreadId.make(`provider-thread:bench:${index}`); - const runId = RunId.make(`run:${threadId}:1`); - const attemptId = RunAttemptId.make(`attempt:bench:${index}`); - const nodeId = NodeId.make(`node:bench:${index}`); - const providerTurnId = ProviderTurnId.make(`provider-turn:bench:${index}`); - yield* thread(threadId, { activeProviderThreadId: providerThreadId }); - yield* apply({ - type: "provider-session.attached", - threadId, - driver, - providerInstanceId, - payload: { - id: providerSessionId, - driver, - providerInstanceId, - status: "ready", - cwd: "/workspace", - model: modelSelection.model, - capabilities: CodexProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }, - }); - yield* apply({ - type: "provider-thread.updated", - threadId, - driver, - providerInstanceId, - payload: { - id: providerThreadId, - appThreadId: threadId, - ownerNodeId: null, - driver, - providerInstanceId, - providerSessionId, - nativeThreadRef: { driver, nativeId: `native:bench:${index}`, strength: "strong" }, - nativeConversationHeadRef: null, - status: "active", - firstRunOrdinal: 1, - lastRunOrdinal: 1, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - pendingBackgroundTasks: [], - }, - }); - yield* run(threadId, 1, "running", { - providerThreadId, - rootNodeId: nodeId, - activeAttemptId: attemptId, - }); - yield* apply({ - type: "run-attempt.created", - threadId, - runId, - payload: { - id: attemptId, - runId, - attemptOrdinal: 1, - rootNodeId: nodeId, - providerInstanceId, - providerThreadId, - providerTurnId, - reason: "initial", - status: "running", - startedAt: now, - completedAt: null, - }, - }); - yield* apply({ - type: "node.updated", - threadId, - runId, - nodeId, - providerInstanceId, - payload: { - id: nodeId, - threadId, - runId, - parentNodeId: null, - rootNodeId: nodeId, - kind: "root_turn", - status: "running", - countsForRun: true, - providerThreadId, - providerTurnId, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }, - }); - yield* apply({ - type: "provider-turn.updated", - threadId, - runId, - nodeId, - providerInstanceId, - payload: { - id: providerTurnId, - providerThreadId, - nodeId, - runAttemptId: attemptId, - nativeTurnRef: { driver, nativeId: `native-turn:bench:${index}`, strength: "strong" }, - ordinal: 1, - status: "running", - startedAt: now, - completedAt: null, - }, - }); - for (let ordinal = 1; ordinal <= OPEN_ITEMS_PER_ACTIVE_THREAD; ordinal += 1) { - yield* commandItem({ - threadId, - runId, - ordinal, - status: "running", - nodeId, - providerThreadId, - providerTurnId, - }); - } - }); - - const waitingThread = Effect.fn(function* (index: number) { - const threadId = ThreadId.make(`thread:bench:waiting:${index}`); - yield* thread(threadId); - yield* run(threadId, 1, "completed"); - const runId = yield* run(threadId, 2, "waiting"); - yield* outbox.enqueue([ - { - id: `effect:bench:checkpoint:${runId}`, - commandId: CommandId.make(`command:effect:checkpoint.capture:${runId}`), - threadId, - request: { - type: "checkpoint.capture", - runId, - scopeId: CheckpointScopeId.make(`scope:bench:${index}`), - }, - }, - ]); - }); - - let unheldQueuedThreads = 0; - for (let start = 0; start < scenario.settled; start += SEED_BATCH) { - const end = Math.min(scenario.settled, start + SEED_BATCH); - yield* sql.withTransaction( - Effect.gen(function* () { - for (let index = start; index < end; index += 1) { - if (yield* settledThread(index)) unheldQueuedThreads += 1; - } - }), - ); - } - yield* sql.withTransaction( - Effect.gen(function* () { - for (let index = 0; index < scenario.active; index += 1) yield* activeThread(index); - for (let index = 0; index < WAITING_THREADS; index += 1) yield* waitingThread(index); - }), - ); - return { unheldQueuedThreads, waitingThreads: WAITING_THREADS } satisfies Seeded; -}); - -const timed = (effect: Effect.Effect) => - Effect.gen(function* () { - const start = performance.now(); - const value = yield* effect; - return [value, performance.now() - start] as const; - }); - -const continuationEffectCount = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const rows = yield* sql<{ readonly count: number }>` - SELECT COUNT(*) AS count FROM orchestration_v2_effect_outbox - WHERE effect_id LIKE 'effect:restart-continuation:%' AND status = 'pending' - `; - return Number(rows[0]?.count ?? 0); -}); - -interface Measurement { - readonly settled: number; - readonly active: number; - readonly seedMs: number; - readonly candidates: number; - readonly selectMs: number; - readonly prepareMs: number; - readonly shutdownMs: number; - readonly startupAfterShutdownMs: number; - readonly crashRecoverMs: number; - readonly secondRecoverMs: number; -} - -const measureGraceful = Effect.fn(function* (scenario: Scenario) { - const [seeded, seedMs] = yield* timed(seedScenario(scenario)); - const projections = yield* ProjectionStore.ProjectionStoreV2; - const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; - const [candidates, selectMs] = yield* timed(projections.getRecoveryThreadIds("runtime")); - assert.equal( - candidates.length, - scenario.active + seeded.unheldQueuedThreads + seeded.waitingThreads, - "runtime recovery candidates", - ); - const [, prepareMs] = yield* timed(recovery.prepareForShutdown); - assert.equal(yield* continuationEffectCount, scenario.active, "prepared continuations"); - const [, shutdownMs] = yield* timed(recovery.reconcile("shutdown")); - const [, startupAfterShutdownMs] = yield* timed(recovery.recover); - assert.equal(yield* continuationEffectCount, scenario.active, "continuations after boot"); - return { - seedMs, - candidates: candidates.length, - selectMs, - prepareMs, - shutdownMs, - startupAfterShutdownMs, - }; -}); - -const measureCrash = Effect.fn(function* (scenario: Scenario) { - yield* seedScenario(scenario); - const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; - const [summary, crashRecoverMs] = yield* timed(recovery.recover); - assert.equal(summary.terminalizedRuns, scenario.active, "terminalized active runs"); - assert.equal(summary.stoppedSessions, scenario.active, "stopped active sessions"); - assert.equal(yield* continuationEffectCount, scenario.active, "recorded continuations"); - const [, secondRecoverMs] = yield* timed(recovery.recover); - assert.equal(yield* continuationEffectCount, scenario.active, "second recover is idempotent"); - return { crashRecoverMs, secondRecoverMs }; -}); - -const measure = Effect.fn(function* (scenario: Scenario) { - const graceful = yield* measureGraceful(scenario).pipe(Effect.provide(Layer.fresh(layerTest))); - const crash = yield* measureCrash(scenario).pipe(Effect.provide(Layer.fresh(layerTest))); - return { ...scenario, ...graceful, ...crash } satisfies Measurement; -}); - -const formatTable = (rows: ReadonlyArray) => { - const ms = (value: number) => value.toFixed(1); - const perActive = (value: number, active: number) => (value / Math.max(active, 1)).toFixed(2); - const header = [ - "N", - "A", - "seed", - "cand", - "select", - "prepare", - "prep/A", - "shutdown", - "boot-after", - "crash-recover", - "recover/A", - "recover-2", - ]; - const lines = rows.map((row) => [ - String(row.settled), - String(row.active), - ms(row.seedMs), - String(row.candidates), - ms(row.selectMs), - ms(row.prepareMs), - perActive(row.prepareMs, row.active), - ms(row.shutdownMs), - ms(row.startupAfterShutdownMs), - ms(row.crashRecoverMs), - perActive(row.crashRecoverMs, row.active), - ms(row.secondRecoverMs), - ]); - const widths = header.map((cell, column) => - Math.max(cell.length, ...lines.map((line) => line[column]!.length)), - ); - return [header, ...lines] - .map((line) => line.map((cell, column) => cell.padStart(widths[column]!)).join(" ")) - .join("\n"); -}; - -it.live( - "restart recovery records one continuation per active thread and is idempotent", - () => - Effect.gen(function* () { - const row = yield* measure({ settled: 500, active: 20 }); - yield* Console.log(`provider runtime recovery (ms)\n${formatTable([row])}`); - }), - 60_000, -); - -it.live.skipIf(process.env.T3_BENCH_RECOVERY !== "1")( - "restart recovery scales with active threads, not settled history", - () => - Effect.gen(function* () { - const rows: Array = []; - for (const settled of [5_000, 50_000]) { - for (const active of [10, 100, 1_000]) { - rows.push(yield* measure({ settled, active })); - yield* Console.log(`provider runtime recovery (ms)\n${formatTable(rows)}`); - } - } - }), - 3_600_000, -); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index e02a65a6a556..d0a97adb106f 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -16,7 +16,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as EffectWorker from "./EffectWorker.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; @@ -26,103 +25,6 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; import * as ServerSettings from "../serverSettings.ts"; -it.effect("leaves durable effects for the worker after runtime reconciliation", () => - Effect.gen(function* () { - const runs = yield* Ref.make(0); - const layer = ProviderRuntimeRecovery.layer.pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRecoveryThreadIds: () => Effect.succeed([]), - }), - Layer.mock(EventSink.EventSinkV2)({}), - IdAllocator.layer, - Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ - runRecoveryOnce: Ref.getAndUpdate(runs, (count) => count + 1).pipe( - Effect.map((count) => count < 2), - ), - }), - Layer.mock(EffectOutbox.EffectOutboxV2)({ - reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), - }), - ), - ), - ); - const summary = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService.pipe( - Effect.flatMap((recovery) => recovery.recover), - Effect.provide(layer), - ); - assert.deepEqual(summary, { - terminalizedRuns: 0, - stoppedSessions: 0, - closedRequests: 0, - retiredEffects: 0, - requeuedEffects: 0, - }); - assert.equal(yield* Ref.get(runs), 0); - }), -); - -it.effect("reads recovery projections only for threads that need runtime recovery", () => { - const settledThreadIds = Array.from({ length: 1_000 }, (_, index) => - ThreadId.make(`thread_recovery_settled_${index}`), - ); - const recoveryThreadId = ThreadId.make("thread_recovery_candidate"); - const projectionReads = vi.fn<(threadId: ThreadId) => void>(); - const layer = ProviderRuntimeRecovery.layer.pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 2, - snapshotSequence: 0, - threads: [...settledThreadIds, recoveryThreadId].map((id) => ({ id })), - archivedThreads: [], - } as never), - getRecoveryThreadIds: () => Effect.succeed([recoveryThreadId]), - getRuntimeRecoveryProjection: (threadId) => { - projectionReads(threadId); - return Effect.succeed({ - thread: { id: threadId }, - runtimeRequests: [], - providerSessions: [], - providerThreads: [], - providerTurns: [], - runs: [], - attempts: [], - nodes: [], - subagents: [], - messages: [], - turnItems: [], - } as unknown as OrchestrationV2ThreadProjection); - }, - }), - Layer.mock(EventSink.EventSinkV2)({}), - IdAllocator.layer, - Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ - runRecoveryOnce: Effect.succeed(false), - }), - Layer.mock(EffectOutbox.EffectOutboxV2)({ - cancelUnsettled: () => Effect.succeed([]), - signalCancellations: () => Effect.void, - reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), - }), - ), - ), - ); - - return Effect.gen(function* () { - yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).recover; - assert.deepEqual( - projectionReads.mock.calls.map(([threadId]) => threadId), - [recoveryThreadId], - ); - }).pipe(Effect.provide(layer)); -}); - it.effect("expires orphaned runtime requests before command readiness", () => { const threadId = ThreadId.make("thread_recovery_requests"); let committedInput: Parameters[0] | null = @@ -229,67 +131,6 @@ it.effect("preserves async questions across startup and shutdown", () => { }).pipe(Effect.provide(layer)); }); -it.effect("uses the same reconciliation path to cancel runtime requests during shutdown", () => { - const threadId = ThreadId.make("thread_shutdown_requests"); - let committedInput: Parameters[0] | null = - null; - const projection = { - thread: { id: threadId }, - runtimeRequests: [ - { - id: RuntimeRequestId.make("request_shutdown"), - nodeId: NodeId.make("node_shutdown"), - status: "pending", - responseCapability: { type: "live" }, - }, - ], - providerSessions: [], - providerThreads: [], - runs: [], - nodes: [], - } as unknown as OrchestrationV2ThreadProjection; - const layer = ProviderRuntimeRecovery.layer.pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRecoveryThreadIds: () => Effect.succeed([threadId]), - getRuntimeRecoveryProjection: () => Effect.succeed(projection), - }), - Layer.mock(EventSink.EventSinkV2)({ - commitCommand: (input) => { - committedInput = input; - return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never); - }, - }), - IdAllocator.layer, - Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ - runRecoveryOnce: Effect.succeed(false), - }), - Layer.mock(EffectOutbox.EffectOutboxV2)({ - reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), - }), - ), - ), - ); - - return Effect.gen(function* () { - const summary = - yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("shutdown"); - assert.equal(summary.closedRequests, 1); - assert.equal(summary.retiredEffects, 1); - const requestEvent = committedInput?.events[0]; - assert.equal(requestEvent?.type, "runtime-request.updated"); - if (requestEvent?.type === "runtime-request.updated") { - assert.equal(requestEvent.payload.status, "cancelled"); - assert.equal(requestEvent.payload.responseCapability.type, "not_resumable"); - if (requestEvent.payload.responseCapability.type === "not_resumable") { - assert.match(requestEvent.payload.responseCapability.reason, /shut down/); - } - } - }).pipe(Effect.provide(layer)); -}); - it.effect( "preserves a replayable waiting run while cancelling its process-bound background work", () => { @@ -577,71 +418,6 @@ it.effect("cancels a stale waiting run when no checkpoint capture can finish it" }).pipe(Effect.provide(layer)); }); -it.effect("closes a secret request card's form when its run is recovered", () => { - const threadId = ThreadId.make("thread_secret_recovery"); - const runId = RunId.make("run_secret_recovery"); - let committedInput: Parameters[0] | null = - null; - const projection = { - thread: { id: threadId }, - runtimeRequests: [], - providerSessions: [], - providerThreads: [], - providerTurns: [], - runs: [{ id: runId, status: "running", providerInstanceId: ProviderInstanceId.make("codex") }], - attempts: [], - nodes: [], - subagents: [], - messages: [], - turnItems: [ - { - id: "turn-item:secret-request:recovery", - threadId, - runId, - nodeId: null, - type: "secret_request", - status: "waiting", - secretStatus: "pending", - label: "GitHub token", - reason: "Used as GH_TOKEN.", - }, - ], - } as unknown as OrchestrationV2ThreadProjection; - const layer = ProviderRuntimeRecovery.layer.pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRecoveryThreadIds: () => Effect.succeed([threadId]), - getRuntimeRecoveryProjection: () => Effect.succeed(projection), - }), - Layer.mock(EventSink.EventSinkV2)({ - commitCommand: (input) => { - committedInput = input; - return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); - }, - }), - IdAllocator.layer, - Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ - runRecoveryOnce: Effect.succeed(false), - }), - Layer.mock(EffectOutbox.EffectOutboxV2)({ - listByCommandId: () => Effect.succeed([]), - reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), - }), - ), - ), - ); - - return Effect.gen(function* () { - yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); - const itemEvent = committedInput?.events.find((event) => event.type === "turn-item.updated"); - const card = itemEvent?.type === "turn-item.updated" ? itemEvent.payload : null; - assert.equal(card?.status, "cancelled"); - assert.equal(card?.type === "secret_request" ? card.secretStatus : null, "cancelled"); - }).pipe(Effect.provide(layer)); -}); - it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart", (status) => { const threadId = ThreadId.make("thread_queued_restart"); const runId = RunId.make("run_queued_restart"); diff --git a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts deleted file mode 100644 index adc4ae1543a4..000000000000 --- a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - type ModelSelection, - type OrchestrationV2ProviderCapabilities, - ProviderInstanceId, -} from "@t3tools/contracts"; - -import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; -import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; - -const selection = (model: string, effort = "medium"): ModelSelection => ({ - instanceId: ProviderInstanceId.make("acp_test"), - model, - options: [{ id: "effort", value: effort }], -}); - -describe("acpSelectionTransition", () => { - it("rejects model changes when the negotiated session cannot apply them", () => { - expect( - acpSelectionTransition({ - current: selection("old"), - target: selection("new"), - sessionCapabilities: AcpProviderCapabilitiesV2, - }).type, - ).toBe("reject"); - }); - - it("allows model changes when the negotiated session can apply them", () => { - const sessionCapabilities: OrchestrationV2ProviderCapabilities = { - ...AcpProviderCapabilitiesV2, - sessions: { - ...AcpProviderCapabilitiesV2.sessions, - supportsModelSwitchInSession: true, - }, - }; - expect( - acpSelectionTransition({ - current: selection("old"), - target: selection("new"), - sessionCapabilities, - }), - ).toEqual({ type: "apply_on_next_turn" }); - }); - - it("allows option-only changes to be applied through ACP config options", () => { - expect( - acpSelectionTransition({ - current: selection("same", "medium"), - target: selection("same", "high"), - sessionCapabilities: AcpProviderCapabilitiesV2, - }), - ).toEqual({ type: "apply_on_next_turn" }); - }); -}); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 3104d49ff621..b60758e80c72 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -4,7 +4,6 @@ import { assert, it } from "@effect/vitest"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { EnvironmentId, - CodexSettings, type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2DomainEvent, @@ -22,25 +21,18 @@ 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 Crypto from "effect/Crypto"; 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 Metric from "effect/Metric"; import * as Option from "effect/Option"; 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 { TestClock } from "effect/testing"; import { HttpServer } from "effect/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import * as CodexClient from "effect-codex-app-server/client"; -import * as CodexErrors from "effect-codex-app-server/errors"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; -import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; @@ -48,7 +40,7 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; -import * as CodexAdapterV2 from "./Adapters/CodexAdapterV2.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -167,8 +159,7 @@ const layerBeforeUnchangedWriteEventSink = (before: Ref.Ref> }), ).pipe(Layer.provide(layerTestEventSink)); -const CodexCapabilities: OrchestrationV2ProviderCapabilities = - CodexAdapterV2.CodexProviderCapabilitiesV2; +const CodexCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const ExclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexCapabilities, sessions: { @@ -203,7 +194,6 @@ const modelSelection = { model: "gpt-5.4", } satisfies ModelSelection; const CODEX_DRIVER = ProviderDriverKind.make("codex"); -const codexSettings = Schema.decodeSync(CodexSettings)({}); const runtimePolicy = { runtimeMode: "full-access", @@ -467,7 +457,6 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( }); function layerTest(input: { - readonly adapter?: ReturnType; readonly state: Ref.Ref; readonly idleTimeoutMs: number; readonly maxIdlePinMs?: number; @@ -510,35 +499,30 @@ function layerTest(input: { ? layerFailingReleaseEventSink : layerTestEventSink; const layerRegistry = Layer.unwrap( - (input.adapter === undefined - ? makeProviderAdapter(input.state, { - failEventStream: input.failEventStream ?? false, - ...(input.capabilities === undefined ? {} : { capabilities: input.capabilities }), - ...(input.mcpConfigs === undefined ? {} : { mcpConfigs: input.mcpConfigs }), - ...(input.beforeOpen === undefined ? {} : { beforeOpen: input.beforeOpen }), - ...(input.hasPendingBackgroundWork === undefined - ? {} - : { hasPendingBackgroundWork: input.hasPendingBackgroundWork }), - ...(input.hasPendingBackgroundWorkForThread === undefined - ? {} - : { hasPendingBackgroundWorkForThread: input.hasPendingBackgroundWorkForThread }), - ...(input.backgroundTasksEndWithProcess === undefined - ? {} - : { backgroundTasksEndWithProcess: input.backgroundTasksEndWithProcess }), - ...(input.hangSessionScopeClose === undefined - ? {} - : { hangSessionScopeClose: input.hangSessionScopeClose }), - ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), - ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), - ...(input.spawnBeforeOpen === undefined - ? {} - : { spawnBeforeOpen: input.spawnBeforeOpen }), - ...(input.scopeCloseReached === undefined - ? {} - : { scopeCloseReached: input.scopeCloseReached }), - }) - : input.adapter - ).pipe(Effect.map(ProviderAdapterRegistry.layerSingle)), + makeProviderAdapter(input.state, { + failEventStream: input.failEventStream ?? false, + ...(input.capabilities === undefined ? {} : { capabilities: input.capabilities }), + ...(input.mcpConfigs === undefined ? {} : { mcpConfigs: input.mcpConfigs }), + ...(input.beforeOpen === undefined ? {} : { beforeOpen: input.beforeOpen }), + ...(input.hasPendingBackgroundWork === undefined + ? {} + : { hasPendingBackgroundWork: input.hasPendingBackgroundWork }), + ...(input.hasPendingBackgroundWorkForThread === undefined + ? {} + : { hasPendingBackgroundWorkForThread: input.hasPendingBackgroundWorkForThread }), + ...(input.backgroundTasksEndWithProcess === undefined + ? {} + : { backgroundTasksEndWithProcess: input.backgroundTasksEndWithProcess }), + ...(input.hangSessionScopeClose === undefined + ? {} + : { hangSessionScopeClose: input.hangSessionScopeClose }), + ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), + ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), + ...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), + ...(input.scopeCloseReached === undefined + ? {} + : { scopeCloseReached: input.scopeCloseReached }), + }).pipe(Effect.map(ProviderAdapterRegistry.layerSingle)), ); const layerConfiguredMcpRegistry = input.pauseResolve === undefined @@ -971,109 +955,6 @@ it.effect("ProviderSessionManagerV2 closes every live session for a provider ins }), ); -it.effect("ProviderSessionManagerV2 records provider session and turn metrics", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-provider-session-manager-metrics"); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThread = makeProviderThread({ idAllocator, threadId, providerSessionId, now }); - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - - const runtime = yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn({ - appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, - threadId, - runId, - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), - rootNodeId: idAllocator.derive.rootNode({ runId }), - providerThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), - text: "hello", - attachments: [], - }, - modelSelection, - runtimePolicy, - }); - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: "native-turn", - }), - }); - yield* manager.close(providerSessionId); - - const snapshots = yield* Metric.snapshot; - const has = (id: string, attributes: Readonly>) => - snapshots.some( - (snapshot) => - snapshot.id === id && - Object.entries(attributes).every( - ([key, value]) => snapshot.attributes?.[key] === value, - ), - ); - assert.isTrue( - has("t3_provider_sessions_total", { - provider: "codex", - operation: "open", - outcome: "success", - }), - ); - assert.isTrue( - has("t3_provider_sessions_total", { - provider: "codex", - operation: "release", - reason: "manual_shutdown", - outcome: "success", - }), - ); - assert.isTrue( - has("t3_provider_turns_total", { - provider: "codex", - operation: "send", - modelFamily: "gpt", - outcome: "success", - }), - ); - assert.isTrue(has("t3_provider_turn_duration", { provider: "codex", operation: "send" })); - assert.isTrue( - has("t3_provider_turns_total", { - provider: "codex", - operation: "interrupt", - outcome: "success", - }), - ); - }); - - yield* effect.pipe( - Effect.provide(layerTest({ state, idleTimeoutMs: 60_000 })), - Effect.provideService(Metric.MetricRegistry, new Map()), - ); - }), -); - it.effect("ProviderSessionManagerV2 opens a duplicate session only once", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -1257,147 +1138,6 @@ it.effect("ProviderSessionManagerV2 cleans up an open interrupted mid-handshake" }), ); -it.effect("ProviderSessionManagerV2 cleans up an interrupted open whose scope close hangs", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const mcpConfigs = yield* Ref.make< - ReadonlyArray - >([]); - const handshakeStarted = yield* Deferred.make(); - const scopeCloseReached = yield* Deferred.make(); - const releaseRetry = yield* Deferred.make(); - yield* Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const registry = yield* McpSessionRegistry.McpSessionRegistry; - const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-provider-session-manager-interrupted-hung-open"); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - - const opening = yield* manager - .open({ threadId, providerSessionId, modelSelection, runtimePolicy }) - .pipe(Effect.forkChild); - yield* Deferred.await(handshakeStarted); - const issued = (yield* Ref.get(mcpConfigs)).at(-1); - const token = issued?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(token); - - const interrupter = yield* Fiber.interrupt(opening).pipe(Effect.forkChild); - yield* Deferred.await(scopeCloseReached); - - assert.isUndefined(yield* registry.resolve(token!)); - assert.isUndefined(yield* mcpSessions.read(threadId)); - assert.equal((yield* Ref.get(state)).closeCount, 0); - - yield* TestClock.adjust("30 seconds"); - yield* Fiber.join(interrupter); - yield* Deferred.succeed(releaseRetry, undefined); - yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); - assert.equal((yield* Ref.get(state)).openCount, 1); - - const stopping = yield* manager.shutdown.pipe(Effect.forkChild); - yield* TestClock.adjust("30 seconds"); - yield* Fiber.join(stopping); - }).pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 60_000, - mcpConfigs, - beforeOpen: () => - Deferred.isDone(handshakeStarted).pipe( - Effect.flatMap((retry) => - retry - ? Deferred.await(releaseRetry) - : Deferred.succeed(handshakeStarted, undefined).pipe( - Effect.andThen(Effect.never), - ), - ), - ), - spawnBeforeOpen: true, - hangSessionScopeClose: true, - scopeCloseReached, - }), - ), - ); - }), -); - -it.effect("ProviderSessionManagerV2 releases an idle session whose turn start was stopped", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const startTurnReached = yield* Deferred.make(); - yield* Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-provider-session-manager-stopped-turn-start"); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - const runtime = yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); - const starting = yield* runtime - .startTurn({ - appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, - threadId, - runId, - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), - rootNodeId: idAllocator.derive.rootNode({ runId }), - providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), - message: { - createdBy: "user", - creationSource: "web", - messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), - text: "stopped before the provider accepted it", - attachments: [], - }, - modelSelection, - runtimePolicy, - }) - .pipe(Effect.forkChild); - yield* Deferred.await(startTurnReached); - - yield* Fiber.interrupt(starting); - yield* TestClock.adjust("2 seconds"); - yield* Effect.yieldNow; - assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); - assert.equal((yield* Ref.get(state)).closeCount, 1); - }).pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - startTurn: Deferred.succeed(startTurnReached, undefined).pipe( - Effect.andThen(Effect.never), - ), - }), - ), - ); - }), -); - it.effect( "ProviderSessionManagerV2 keeps a shared session busy when another thread's start is stopped early", () => @@ -1496,6 +1236,7 @@ it.effect( }), ); +// regression: #15571 an interrupted session start did not clean up it.effect( "ProviderSessionManagerV2 keeps a shared session busy when a stopped start still ends its turn", () => @@ -1649,82 +1390,6 @@ it.effect("ProviderSessionManagerV2 stops a session still opening when its layer }), ); -it.effect("ProviderSessionManagerV2 releases live sessions when its layer shuts down", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-provider-session-manager-shutdown"); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - const liveState = yield* Ref.get(state); - assert.equal(liveState.openCount, 1); - assert.equal(liveState.closeCount, 0); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 60_000, - }), - ), - ); - - assert.equal((yield* Ref.get(state)).closeCount, 1); - }), -); - -it.effect("ProviderSessionManagerV2 releases sessions in parallel on server shutdown", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - yield* Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - for (const name of ["a", "b", "c"]) { - const threadId = ThreadId.make(`thread-provider-session-manager-parallel-shutdown-${name}`); - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId: yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }), - modelSelection, - runtimePolicy, - }); - } - - const stopping = yield* manager.shutdown.pipe(Effect.forkChild); - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - assert.isDefined(stopping.pollUnsafe()); - }).pipe( - Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, hangSessionScopeClose: true })), - ); - }), -); - it.effect("ProviderSessionManagerV2 interrupts event subscriptions on server shutdown", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -1967,17 +1632,6 @@ it.effect("ProviderSessionManagerV2 honors a project browser-access opt-out", () }), ); -it.effect("ProviderSessionManagerV2 honors a project browser-access opt-in", () => - Effect.gen(function* () { - const captured = yield* runBrowserAccessScenario({ - enableAgentBrowserAccess: false, - projectOverride: true, - }); - assert.isDefined(captured); - assert.equal(captured?.browserToolsAvailable, true); - }), -); - it.effect("ProviderSessionManagerV2 fails browser access closed for a missing project", () => Effect.gen(function* () { const captured = yield* runBrowserAccessScenario({ @@ -1990,18 +1644,6 @@ it.effect("ProviderSessionManagerV2 fails browser access closed for a missing pr }), ); -it.effect("ProviderSessionManagerV2 fails browser access closed for a missing thread", () => - Effect.gen(function* () { - const captured = yield* runBrowserAccessScenario({ - enableAgentBrowserAccess: true, - projectOverride: true, - createThread: false, - }); - assert.isDefined(captured); - assert.equal(captured?.browserToolsAvailable, false); - }), -); - it.effect("ProviderSessionManagerV2 revokes MCP credentials when release persistence fails", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -2116,6 +1758,7 @@ it.effect( }), ); +// regression: #15571 an interrupted session start did not clean up it.effect( "ProviderSessionManagerV2 keeps a replacement session's attachment when a stale attach is interrupted", () => @@ -2208,6 +1851,7 @@ it.effect( }), ); +// regression: #15571 an interrupted session start did not clean up it.effect( "ProviderSessionManagerV2 revokes a reused credential after a resume stopped while checking it", () => @@ -2342,7 +1986,7 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c ); it.effect( - "ProviderSessionManagerV2 detach of a superseded live session preserves replacement MCP credentials", + "ProviderSessionManagerV2 keeps a thread's MCP credential stable across detach and re-attach on a shared session", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -2356,12 +2000,8 @@ it.effect( const registry = yield* McpSessionRegistry.McpSessionRegistry; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-provider-session-manager-superseded-mcp"); - const oldSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const replacementSessionId = yield* idAllocator.allocate.providerSession({ + const threadId = ThreadId.make("thread-provider-session-manager-stable-mcp"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ providerInstanceId: modelSelection.instanceId, threadId, }); @@ -2371,83 +2011,15 @@ it.effect( }); yield* manager.open({ threadId, - providerSessionId: oldSessionId, - modelSelection, - runtimePolicy, - }); - yield* manager.open({ - threadId, - providerSessionId: replacementSessionId, + providerSessionId, modelSelection, runtimePolicy, }); - const replacement = (yield* Ref.get(mcpConfigs)).at(-1); - assert.isDefined(replacement); - const replacementToken = replacement?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(replacementToken); - assert.equal( - (yield* mcpSessions.read(threadId))?.providerSessionId, - replacement?.providerSessionId, - ); - - yield* manager.detach({ providerSessionId: oldSessionId, threadId }); - - assert.equal( - (yield* mcpSessions.read(threadId))?.providerSessionId, - replacement?.providerSessionId, - ); - assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1_000, - capabilities: ExclusiveCapabilities, - mcpConfigs, - }), - ), - ); - }), -); - -it.effect( - "ProviderSessionManagerV2 keeps a thread's MCP credential stable across detach and re-attach on a shared session", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const mcpConfigs = yield* Ref.make< - ReadonlyArray - >([]); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const registry = yield* McpSessionRegistry.McpSessionRegistry; - const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-provider-session-manager-stable-mcp"); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - const original = (yield* Ref.get(mcpConfigs)).at(-1); - assert.isDefined(original); - const originalToken = original?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(originalToken); + const original = (yield* Ref.get(mcpConfigs)).at(-1); + assert.isDefined(original); + const originalToken = original?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(originalToken); // Workspace-change handoff on a shared multi-thread session (codex): // the thread detaches while the provider process keeps running, and the @@ -2491,56 +2063,6 @@ it.effect( }), ); -it.effect( - "ProviderSessionManagerV2 revokes a rotated credential despite a stale record on another live session", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const mcpConfigs = yield* Ref.make< - ReadonlyArray - >([]); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const registry = yield* McpSessionRegistry.McpSessionRegistry; - const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-provider-session-manager-stale-record"); - const s1 = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const s2 = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ threadId, providerSessionId: s1, modelSelection, runtimePolicy }); - yield* manager.detach({ providerSessionId: s1, threadId }); - - yield* registry.revokeThread(threadId); - yield* manager.open({ threadId, providerSessionId: s2, modelSelection, runtimePolicy }); - const rotated = yield* mcpSessions.read(threadId); - assert.isDefined(rotated); - const rotatedToken = rotated?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(yield* registry.resolve(rotatedToken!)); - - yield* manager.close(s2); - assert.isUndefined( - yield* registry.resolve(rotatedToken!), - "stale record on S1 must not veto revoking S2's rotated credential", - ); - yield* manager.close(s1); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs }))); - }), -); - it.effect( "ProviderSessionManagerV2 protects a reused credential from a predecessor release during open", () => @@ -2658,101 +2180,6 @@ it.effect("ProviderSessionManagerV2 terminal detach revokes the thread's MCP cre }), ); -it.effect("ProviderSessionManagerV2 releases idle sessions without sweeping all sessions", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-idle", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-idle", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - - const liveSession = yield* manager.get(providerSessionId); - const runtimeState = yield* Ref.get(state); - const projection = yield* projectionStore.getThreadProjection(threadId); - - assert.isTrue(Option.isNone(liveSession)); - assert.equal(runtimeState.openCount, 1); - assert.equal(runtimeState.closeCount, 1); - assert.equal(projection.providerSessions.at(-1)?.status, "stopped"); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), -); - -it.effect("ProviderSessionManagerV2 persists release when session scope close hangs", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-hung-close", - projectId: yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-hung-close", - }), - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); - - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - const projection = yield* projectionStore.getThreadProjection(threadId); - assert.equal(projection.providerSessions.at(-1)?.status, "stopped"); - assert.equal((yield* Ref.get(state)).closeCount, 0); - }); - - yield* effect.pipe( - Effect.provide(layerTest({ state, idleTimeoutMs: 1000, hangSessionScopeClose: true })), - ); - }), -); - const openSessionWithBackgroundCommand = (fixtureName: string) => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; @@ -2849,46 +2276,6 @@ it.effect( }), ); -it.effect("ProviderSessionManagerV2 checks liveness without delaying idle release", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { providerSessionId } = yield* openSessionWithBackgroundCommand( - "provider-session-manager-is-live", - ); - yield* TestClock.adjust("600 millis"); - assert.isTrue(yield* manager.isLive(providerSessionId)); - yield* TestClock.adjust("600 millis"); - yield* Effect.yieldNow; - assert.isFalse(yield* manager.isLive(providerSessionId)); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), -); - -it.effect("ProviderSessionManagerV2 clears the background roster of an idle-released session", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { providerSessionId, rosters } = yield* openSessionWithBackgroundCommand( - "provider-session-manager-idle-roster", - ); - assert.deepStrictEqual(yield* rosters, [["dev-server"], ["dev-server"]]); - - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - - assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); - assert.deepStrictEqual(yield* rosters, [[], ["dev-server"]]); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), -); - it.effect("ProviderSessionManagerV2 keeps the roster of a runtime whose tasks can outlive it", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -2911,45 +2298,7 @@ it.effect("ProviderSessionManagerV2 keeps the roster of a runtime whose tasks ca }), ); -it.effect( - "ProviderSessionManagerV2 keeps background tasks a replacement reports during release", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const scopeCloseReached = yield* Deferred.make(); - const effect = Effect.gen(function* () { - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { providerSessionId, providerThreads, writeProviderThread, rosters } = - yield* openSessionWithBackgroundCommand("provider-session-manager-replacement-roster"); - const release = yield* manager.close(providerSessionId).pipe(Effect.forkChild); - - yield* Deferred.await(scopeCloseReached); - yield* writeProviderThread({ - ...providerThreads[0]!, - pendingBackgroundTasks: [ - ...providerThreads[0]!.pendingBackgroundTasks, - { taskId: "replacement-task", kind: "command" }, - ], - }); - yield* TestClock.adjust("30 seconds"); - yield* Fiber.join(release); - - assert.deepStrictEqual(yield* rosters, [["replacement-task"], ["dev-server"]]); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 60_000, - hangSessionScopeClose: true, - scopeCloseReached, - }), - ), - ); - }), -); - +// regression: #19355 settled and idle-released threads stayed Waiting it.effect("ProviderSessionManagerV2 keeps a provider update that lands while release clears", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -2981,177 +2330,6 @@ it.effect("ProviderSessionManagerV2 keeps a provider update that lands while rel }), ); -it.effect("ProviderSessionManagerV2 defers idle release while background work is pending", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const pendingWork = yield* Ref.make(true); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-idle-pin", - projectId: yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-idle-pin", - }), - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - yield* TestClock.adjust("3 seconds"); - yield* Effect.yieldNow; - assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); - assert.equal((yield* Ref.get(state)).closeCount, 0); - - yield* Ref.set(pendingWork, false); - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); - assert.equal((yield* Ref.get(state)).closeCount, 1); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - hasPendingBackgroundWork: Ref.get(pendingWork), - }), - ), - ); - }), -); - -it.effect("ProviderSessionManagerV2 releases pinned idle sessions once the pin cap expires", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-pin-cap", - projectId: yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-pin-cap", - }), - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - yield* TestClock.adjust("3 seconds"); - yield* Effect.yieldNow; - assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); - - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); - assert.equal((yield* Ref.get(state)).closeCount, 1); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - maxIdlePinMs: 3000, - hasPendingBackgroundWork: Effect.succeed(true), - }), - ), - ); - }), -); - -it.effect("ProviderSessionManagerV2 keeps pinned work that reports progress", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-pin-progress", - projectId: yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-pin-progress", - }), - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - const runtime = yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - const adapterQueue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); - assert.isDefined(adapterQueue); - const step = (duration: Parameters[0]) => - Effect.gen(function* () { - yield* TestClock.adjust(duration); - for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow; - }); - - for (let tick = 0; tick < 12; tick += 1) { - yield* step("1500 millis"); - yield* Queue.offer(adapterQueue!, { - type: "provider_session.updated", - driver: CODEX_DRIVER, - providerSession: runtime.providerSession, - }); - for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow; - } - assert.equal((yield* Ref.get(state)).closeCount, 0); - - for (let tick = 0; tick < 6; tick += 1) { - yield* step("1 second"); - } - assert.equal((yield* Ref.get(state)).closeCount, 1); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - maxIdlePinMs: 3000, - hasPendingBackgroundWork: Effect.succeed(true), - }), - ), - ); - }), -); - it.effect( "ProviderSessionManagerV2 does not idle-release a session that turns busy during the pending-work check", () => @@ -3275,22 +2453,21 @@ it.effect( }), ); -it.effect("ProviderSessionManagerV2 does not apply a stale idle pin to a replacement session", () => +it.effect("ProviderSessionManagerV2 releases sessions when provider event streams fail", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); - const firstCheck = yield* Ref.make(true); - const checkEntered = yield* Deferred.make(); - const checkGate = yield* Deferred.make(); const effect = Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; + const projectId = yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-stream-error", + }); const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-stale-pin", - projectId: yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-stale-pin", - }), + fixtureName: "provider-session-manager-stream-error", + projectId, }); const providerSessionId = yield* idAllocator.allocate.providerSession({ providerInstanceId: modelSelection.instanceId, @@ -3300,41 +2477,22 @@ it.effect("ProviderSessionManagerV2 does not apply a stale idle pin to a replace yield* eventSink.write({ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - yield* TestClock.adjust("1 second"); - yield* Deferred.await(checkEntered); - - const closeFiber = yield* manager.close(providerSessionId).pipe(Effect.forkDetach); - for (let i = 0; i < 20; i += 1) { - yield* Effect.yieldNow; - } - yield* manager.open({ + const runtime = yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy, }); - assert.equal((yield* Ref.get(state)).openCount, 2); - - yield* Deferred.succeed(checkGate, undefined); - yield* Fiber.join(closeFiber); - for (let i = 0; i < 10; i += 1) { - yield* Effect.yieldNow; - } + yield* runtime.events.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped); + yield* Effect.yieldNow; - assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); - assert.equal((yield* Ref.get(state)).closeCount, 1); + const liveSession = yield* manager.get(providerSessionId); + const runtimeState = yield* Ref.get(state); + const projection = yield* projectionStore.getThreadProjection(threadId); - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); - assert.equal((yield* Ref.get(state)).closeCount, 2); + assert.isTrue(Option.isNone(liveSession)); + assert.equal(runtimeState.closeCount, 1); + assert.equal(projection.providerSessions.at(-1)?.status, "error"); }); yield* effect.pipe( @@ -3342,121 +2500,14 @@ it.effect("ProviderSessionManagerV2 does not apply a stale idle pin to a replace layerTest({ state, idleTimeoutMs: 1000, - maxIdlePinMs: 60_000, - hasPendingBackgroundWork: Effect.uninterruptible( - Effect.gen(function* () { - if (yield* Ref.getAndSet(firstCheck, false)) { - yield* Deferred.succeed(checkEntered, undefined); - yield* Deferred.await(checkGate); - return true; - } - return false; - }), - ), + failEventStream: true, }), ), ); }), ); -it.effect( - "ProviderSessionManagerV2 keeps active sessions alive until the provider turn terminates", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-active", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-active", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - }); - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); - const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); - const rootNodeId = idAllocator.derive.rootNode({ runId }); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: "native-turn", - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - const runtime = yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped); - const appThread = (yield* projectionStore.getThreadProjection(threadId)).thread; - yield* runtime.startTurn({ - appThread, - threadId, - runId, - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId, - rootNodeId, - providerThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), - text: "hello", - attachments: [], - }, - modelSelection, - runtimePolicy, - }); - - yield* TestClock.adjust("2 seconds"); - yield* Effect.yieldNow; - assert.equal((yield* Ref.get(state)).closeCount, 0); - - const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); - assert.isDefined(queue); - yield* Queue.offer(queue!, { - type: "turn.terminal", - driver: CODEX_DRIVER, - providerThreadId: providerThread.id, - providerTurnId, - runOrdinal: 1, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - - const liveSession = yield* manager.get(providerSessionId); - const projection = yield* projectionStore.getThreadProjection(threadId); - assert.isTrue(Option.isNone(liveSession)); - assert.equal((yield* Ref.get(state)).closeCount, 1); - assert.equal(projection.providerSessions.at(-1)?.status, "stopped"); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), -); - -it.effect("ProviderSessionManagerV2 uses the same release path for runtime failures", () => +it.effect("ProviderSessionManagerV2 marks pending runtime requests non-live on release", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); const effect = Effect.gen(function* () { @@ -3466,20 +2517,34 @@ it.effect("ProviderSessionManagerV2 uses the same release path for runtime failu const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-runtime-error", + fixtureName: "provider-session-manager-request-expire", }); const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-runtime-error", + fixtureName: "provider-session-manager-request-expire", projectId, }); const providerSessionId = yield* idAllocator.allocate.providerSession({ providerInstanceId: modelSelection.instanceId, threadId, }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + }); yield* eventSink.write({ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + yield* eventSink.write({ events: pendingRequest.events }); yield* manager.open({ threadId, providerSessionId, @@ -3492,242 +2557,12 @@ it.effect("ProviderSessionManagerV2 uses the same release path for runtime failu detail: "process exited", }); - const liveSession = yield* manager.get(providerSessionId); - const runtimeState = yield* Ref.get(state); const projection = yield* projectionStore.getThreadProjection(threadId); - - assert.isTrue(Option.isNone(liveSession)); - assert.equal(runtimeState.closeCount, 1); - assert.equal(projection.providerSessions.at(-1)?.status, "error"); - assert.equal(projection.providerSessions.at(-1)?.lastError, "process exited"); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), -); - -it.effect("ProviderSessionManagerV2 reopens Codex after its shared child exits cleanly", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const handles: Array = []; - const adapter = CodexAdapterV2.makeCodexAdapterV2({ - crypto: yield* Crypto.Crypto, - instanceId: modelSelection.instanceId, - settings: codexSettings, - environment: {}, - fileSystem, - idAllocator, - serverConfig, - clientFactory: { - open: () => - Effect.gen(function* () { - const handle = yield* spawner - .spawn( - ChildProcess.make(process.execPath, [ - "-e", - ` - process.on("SIGTERM", () => process.exit(0)); - require("node:readline").createInterface({ input: process.stdin }).on("line", (line) => { - const { id } = JSON.parse(line); - process.stdout.write(JSON.stringify({ id, result: null }) + "\\n"); - }); - `, - ]), - ) - .pipe(Effect.orDie); - handles.push(handle); - const context = yield* Layer.build(CodexClient.layerChildProcess(handle)); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(context), - ); - // A response proves the child has installed its clean-exit handler. - yield* client.raw.request("test/ready").pipe(Effect.orDie); - return client; - }), - }, - }); - - yield* Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const threadId = ThreadId.make("thread-codex-clean-exit"); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - yield* eventSink.write({ - events: [ - yield* makeThreadCreatedEvent({ idAllocator, threadId, now: yield* DateTime.now }), - ], - }); - const input = { threadId, providerSessionId, modelSelection, runtimePolicy }; - const runtime = yield* manager.open(input); - assert.strictEqual(yield* manager.open(input), runtime); - assert.lengthOf(handles, 1); - const subscriptions = yield* Effect.all([runtime.subscribeEvents!, runtime.subscribeEvents!]); - const afterSequence = yield* eventSink.latestSequence({ threadId }); - const released = yield* eventSink.stream({ threadId, afterSequence }).pipe( - Stream.filter( - (stored) => - stored.event.type === "provider-session.updated" && - stored.event.payload.status === "error", - ), - Stream.runHead, - Effect.forkScoped, - ); - - yield* handles[0]!.kill(); - yield* Fiber.join(released); - for (const subscription of subscriptions) { - const error = yield* subscription.events.pipe(Stream.runDrain, Effect.flip); - assert.instanceOf(error, ProviderAdapter.ProviderAdapterEventStreamError); - assert.instanceOf(error.cause, CodexErrors.CodexAppServerProcessExitedError); - assert.equal(error.cause.code, 0); - assert.equal(error.cause.pid, handles[0]!.pid); - } - assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); - assert.equal( - (yield* projectionStore.getThreadProjection(threadId)).providerSessions.at(-1)?.status, - "error", - ); - - const replacement = yield* manager.open(input); - assert.notStrictEqual(replacement, runtime); - assert.lengthOf(handles, 2); - assert.notEqual(handles[1]!.pid, handles[0]!.pid); - assert.isTrue(yield* handles[1]!.isRunning); - assert.strictEqual(yield* manager.open(input), replacement); - assert.lengthOf(handles, 2); - assert.equal( - (yield* projectionStore.getThreadProjection(threadId)).providerSessions.at(-1)?.status, - "ready", - ); - }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, adapter }))); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-clean-exit-" }), - ).pipe(Layer.provideMerge(NodeServices.layer)), - ), - ), -); - -it.effect("ProviderSessionManagerV2 releases sessions when provider event streams fail", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-stream-error", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-stream-error", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - const runtime = yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* runtime.events.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped); - yield* Effect.yieldNow; - - const liveSession = yield* manager.get(providerSessionId); - const runtimeState = yield* Ref.get(state); - const projection = yield* projectionStore.getThreadProjection(threadId); - - assert.isTrue(Option.isNone(liveSession)); - assert.equal(runtimeState.closeCount, 1); - assert.equal(projection.providerSessions.at(-1)?.status, "error"); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - failEventStream: true, - }), - ), - ); - }), -); - -it.effect("ProviderSessionManagerV2 marks pending runtime requests non-live on release", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-request-expire", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-request-expire", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - const pendingRequest = yield* makePendingRuntimeRequestEvents({ - idAllocator, - threadId, - providerSessionId, - providerThread, - now, - }); - yield* eventSink.write({ events: pendingRequest.events }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* manager.release({ - providerSessionId, - reason: "runtime_error", - detail: "process exited", - }); - - const projection = yield* projectionStore.getThreadProjection(threadId); - const request = projection.runtimeRequests.at(-1); - const requestNode = projection.nodes.find((node) => node.id === request?.nodeId); - const requestTurnItem = projection.turnItems.find( - (item) => item.type === "approval_request" && item.requestId === request?.id, - ); + const request = projection.runtimeRequests.at(-1); + const requestNode = projection.nodes.find((node) => node.id === request?.nodeId); + const requestTurnItem = projection.turnItems.find( + (item) => item.type === "approval_request" && item.requestId === request?.id, + ); assert.equal(request?.status, "expired"); assert.equal(request?.responseCapability.type, "not_resumable"); @@ -3799,6 +2634,7 @@ it.effect("ProviderSessionManagerV2 retries release records that failed to persi }), ); +// regression: #15048 runs got stuck after a failed session release it.effect("ProviderSessionManagerV2 release retries leave a replacement session alone", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -3871,6 +2707,7 @@ it.effect("ProviderSessionManagerV2 release retries leave a replacement session }), ); +// regression: #15048 runs got stuck after a failed session release it.effect("ProviderSessionManagerV2 keeps each failed release's cleanup", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -3984,431 +2821,128 @@ it.effect("ProviderSessionManagerV2 settles a request the event pump persists du }); yield* eventSink.write({ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); - const createdAt = DateTime.add(now, { seconds: 1 }); - const pendingRequest = yield* makePendingRuntimeRequestEvents({ - idAllocator, - threadId, - providerSessionId, - providerThread: makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now: createdAt, - }), - now: createdAt, - }); - const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); - assert.isDefined(adapterEvents); - yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); - yield* Deferred.await(pause.paused); - const closed = yield* manager - .close(providerSessionId) - .pipe(Effect.forkScoped({ startImmediately: true })); - yield* TestClock.adjust("1 second"); - yield* Deferred.succeed(pause.resume, undefined); - yield* Fiber.join(closed); - - const projection = yield* projectionStore.getThreadProjection(threadId); - const request = projection.runtimeRequests.find( - (candidate) => candidate.id === pendingRequest.requestId, - ); - assert.equal(request?.responseCapability.type, "not_resumable"); - }); - - yield* effect.pipe( - Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), - ); - }), -); - -it.effect("ProviderSessionManagerV2 terminalizes a pending input transcript item on release", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-request-expire", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-request-expire", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - const pendingRequest = yield* makePendingRuntimeRequestEvents({ - idAllocator, - threadId, - providerSessionId, - providerThread, - now, - }); - yield* eventSink.write({ - events: pendingRequest.events.map((event) => - event.type === "turn-item.updated" - ? { ...event, payload: { ...event.payload, type: "user_input_request", questions: [] } } - : event, - ), - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* manager.release({ - providerSessionId, - reason: "runtime_error", - detail: "process exited", - }); - - const projection = yield* projectionStore.getThreadProjection(threadId); - const request = projection.runtimeRequests.at(-1); - const requestNode = projection.nodes.find((node) => node.id === request?.nodeId); - const requestTurnItem = projection.turnItems.find( - (item) => item.type === "user_input_request" && item.requestId === request?.id, - ); - - assert.equal(request?.status, "expired"); - assert.equal(request?.responseCapability.type, "not_resumable"); - assert.equal(requestNode?.status, "failed"); - assert.equal(requestTurnItem?.status, "failed"); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), -); - -it.effect("ProviderSessionManagerV2 persists session-scoped runtime requests without a run", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-session-request", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-session-request", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - const pendingRequest = yield* makePendingRuntimeRequestEvents({ - idAllocator, - threadId, - providerSessionId, - providerThread, - now, - }); - const afterSequence = yield* eventSink.latestSequence({ threadId }); - const persistedFiber = yield* eventSink.stream({ threadId, afterSequence }).pipe( - Stream.filter( - (stored) => - stored.event.type === "runtime-request.updated" || - stored.event.type === "node.updated" || - stored.event.type === "turn-item.updated", - ), - Stream.take(3), - Stream.runCollect, - Effect.forkScoped, - ); - const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); - assert.isDefined(adapterEvents); - yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); - const persisted = Array.from(yield* Fiber.join(persistedFiber)); - - assert.sameMembers( - persisted.map((stored) => stored.event.type), - ["runtime-request.updated", "node.updated", "turn-item.updated"], - ); - const projection = yield* projectionStore.getThreadProjection(threadId); - const request = projection.runtimeRequests.find( - (candidate) => candidate.id === pendingRequest.requestId, - ); - const node = projection.nodes.find((candidate) => candidate.id === pendingRequest.nodeId); - const turnItem = projection.turnItems.find( - (candidate) => - candidate.type === "approval_request" && candidate.requestId === pendingRequest.requestId, - ); - assert.equal(request?.status, "pending"); - assert.equal(request?.providerTurnId, null); - assert.equal(node?.runId, null); - assert.equal(node?.status, "waiting"); - assert.equal(turnItem?.runId, null); - assert.equal(turnItem?.status, "waiting"); - }); - - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), -); - -it.effect( - "ProviderSessionManagerV2 preserves item identity during eager native session activation", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-request-expire", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-request-expire", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* eventSink.write({ - events: (yield* makePendingRuntimeRequestEvents({ - idAllocator, - threadId, - providerSessionId, - providerThread, - now, - })).events, - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - initialNativeThreadId: "native-import", - initialProviderItemIdentityVersion: 2, - }); - yield* manager.release({ - providerSessionId, - reason: "runtime_error", - detail: "process exited", - }); - - const projection = yield* projectionStore.getThreadProjection(threadId); - const request = projection.runtimeRequests.at(-1); - const requestNode = projection.nodes.find((node) => node.id === request?.nodeId); - const requestTurnItem = projection.turnItems.find( - (item) => item.type === "approval_request" && item.requestId === request?.id, - ); - - assert.equal(request?.status, "expired"); - assert.equal(request?.responseCapability.type, "not_resumable"); - assert.equal(requestNode?.status, "failed"); - assert.equal(requestTurnItem?.status, "failed"); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - beforeOpen: (input) => - Effect.sync(() => assert.equal(input.initialProviderItemIdentityVersion, 2)), - }), - ), - ); - }), -); - -it.effect( - "ProviderSessionManagerV2 keeps a multi-thread session alive until all turns finish", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-multi-thread-active", - }); - const firstThreadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-multi-thread-active-a", - projectId, - }); - const secondThreadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-multi-thread-active-b", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId: firstThreadId, - }); - const firstProviderThread = makeProviderThread({ - idAllocator, - threadId: firstThreadId, - providerSessionId, - now, - nativeThreadId: "native-thread-a", - }); - const secondProviderThread = makeProviderThread({ - idAllocator, - threadId: secondThreadId, - providerSessionId, - now, - nativeThreadId: "native-thread-b", - }); - const firstRunId = idAllocator.derive.run({ threadId: firstThreadId, ordinal: 1 }); - const secondRunId = idAllocator.derive.run({ threadId: secondThreadId, ordinal: 1 }); - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: "native-turn-a", - }); - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: "native-turn-b", - }); - - yield* eventSink.write({ - events: [ - yield* makeThreadCreatedEvent({ idAllocator, threadId: firstThreadId, now }), - yield* makeThreadCreatedEvent({ idAllocator, threadId: secondThreadId, now }), - ], - }); - const runtime = yield* manager.open({ - threadId: firstThreadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* manager.open({ - threadId: secondThreadId, + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const createdAt = DateTime.add(now, { seconds: 1 }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread: makeProviderThread({ + idAllocator, + threadId, providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped); - const firstAppThread = (yield* projectionStore.getThreadProjection(firstThreadId)).thread; - const secondAppThread = (yield* projectionStore.getThreadProjection(secondThreadId)).thread; - yield* runtime.startTurn({ - appThread: firstAppThread, - threadId: firstThreadId, - runId: firstRunId, - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: idAllocator.derive.runAttempt({ runId: firstRunId, attemptOrdinal: 1 }), - rootNodeId: idAllocator.derive.rootNode({ runId: firstRunId }), - providerThread: firstProviderThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: yield* idAllocator.allocate.message({ threadId: firstThreadId, ordinal: 1 }), - text: "first", - attachments: [], - }, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn({ - appThread: secondAppThread, - threadId: secondThreadId, - runId: secondRunId, - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: idAllocator.derive.runAttempt({ runId: secondRunId, attemptOrdinal: 1 }), - rootNodeId: idAllocator.derive.rootNode({ runId: secondRunId }), - providerThread: secondProviderThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: yield* idAllocator.allocate.message({ - threadId: secondThreadId, - ordinal: 1, - }), - text: "second", - attachments: [], - }, - modelSelection, - runtimePolicy, - }); + now: createdAt, + }), + now: createdAt, + }); + const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterEvents); + yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); + yield* Deferred.await(pause.paused); + const closed = yield* manager + .close(providerSessionId) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(pause.resume, undefined); + yield* Fiber.join(closed); - const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); - assert.isDefined(queue); - yield* Queue.offer(queue!, { - type: "turn.terminal", - driver: CODEX_DRIVER, - providerThreadId: firstProviderThread.id, - providerTurnId: firstProviderTurnId, - runOrdinal: 1, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* TestClock.adjust("2 seconds"); - yield* Effect.yieldNow; - assert.equal((yield* Ref.get(state)).closeCount, 0); + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + assert.equal(request?.responseCapability.type, "not_resumable"); + }); - yield* Queue.offer(queue!, { - type: "turn.terminal", - driver: CODEX_DRIVER, - providerThreadId: secondProviderThread.id, - providerTurnId: secondProviderTurnId, - runOrdinal: 1, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - assert.equal((yield* Ref.get(state)).closeCount, 1); + yield* effect.pipe( + Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + +it.effect("ProviderSessionManagerV2 persists session-scoped runtime requests without a run", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-session-request", + }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-session-request", + projectId, + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, }); - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + const afterSequence = yield* eventSink.latestSequence({ threadId }); + const persistedFiber = yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.filter( + (stored) => + stored.event.type === "runtime-request.updated" || + stored.event.type === "node.updated" || + stored.event.type === "turn-item.updated", + ), + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterEvents); + yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); + const persisted = Array.from(yield* Fiber.join(persistedFiber)); + + assert.sameMembers( + persisted.map((stored) => stored.event.type), + ["runtime-request.updated", "node.updated", "turn-item.updated"], + ); + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + const node = projection.nodes.find((candidate) => candidate.id === pendingRequest.nodeId); + const turnItem = projection.turnItems.find( + (candidate) => + candidate.type === "approval_request" && candidate.requestId === pendingRequest.requestId, + ); + assert.equal(request?.status, "pending"); + assert.equal(request?.providerTurnId, null); + assert.equal(node?.runId, null); + assert.equal(node?.status, "waiting"); + assert.equal(turnItem?.runId, null); + assert.equal(turnItem?.status, "waiting"); + }); + + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); + }), ); it.effect( @@ -4790,240 +3324,6 @@ it.effect( }), ); -function runIdleThreadUnloadScenario( - name: string, - scenario: (input: { - readonly state: Ref.Ref; - readonly manager: ProviderSessionManager.ProviderSessionManagerV2Shape; - readonly providerSessionId: ProviderSessionId; - readonly threadA: ThreadId; - readonly replaceRuntime: () => Effect.Effect; - /** Starts run `ordinal` on a thread and returns once the provider accepted it. */ - readonly startTurn: (threadId: ThreadId, ordinal: number) => Effect.Effect; - readonly endTurn: (threadId: ThreadId, ordinal: number) => Effect.Effect; - readonly resume: (threadId: ThreadId) => Effect.Effect; - }) => Effect.Effect, - options: { readonly hasPendingBackgroundWorkForThread?: Effect.Effect } = {}, -) { - return Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - yield* Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const threadA = ThreadId.make(`thread-provider-session-manager-${name}-a`); - const threadB = ThreadId.make(`thread-provider-session-manager-${name}-b`); - const providerSessionId = idAllocator.derive.providerSession({ - providerInstanceId: modelSelection.instanceId, - }); - yield* eventSink.write({ - events: [ - yield* makeThreadCreatedEvent({ idAllocator, threadId: threadA, now }), - yield* makeThreadCreatedEvent({ idAllocator, threadId: threadB, now }), - ], - }); - let runtime = yield* manager.open({ - threadId: threadA, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* manager.open({ threadId: threadB, providerSessionId, modelSelection, runtimePolicy }); - const providerThreadOf = (threadId: ThreadId) => - makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - nativeThreadId: `native-${threadId}`, - }); - const resume = (threadId: ThreadId) => - runtime - .resumeThread({ - providerThread: providerThreadOf(threadId), - threadId, - modelSelection, - runtimePolicy, - }) - .pipe(Effect.asVoid, Effect.orDie); - const startTurn = (threadId: ThreadId, ordinal: number) => - Effect.gen(function* () { - yield* resume(threadId); - const runId = idAllocator.derive.run({ threadId, ordinal }); - yield* runtime.startTurn({ - appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, - threadId, - runId, - runOrdinal: ordinal, - providerTurnOrdinal: ordinal, - attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), - rootNodeId: idAllocator.derive.rootNode({ runId }), - providerThread: providerThreadOf(threadId), - message: { - createdBy: "user", - creationSource: "web", - messageId: yield* idAllocator.allocate.message({ threadId, ordinal }), - text: "turn", - attachments: [], - }, - modelSelection, - runtimePolicy, - }); - }).pipe(Effect.orDie); - const endTurn = (threadId: ThreadId, ordinal: number) => - Effect.scoped( - Effect.gen(function* () { - // The pump hands an event to subscribers only after the session - // has processed it, so receiving it is the receipt. - const subscribe = runtime.subscribeEvents; - assert.isDefined(subscribe); - const subscription = yield* Effect.acquireRelease(subscribe!, (sub) => sub.close); - const received = yield* subscription.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - Effect.forkScoped, - ); - const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); - assert.isDefined(queue); - yield* Queue.offer(queue!, { - type: "turn.terminal", - driver: CODEX_DRIVER, - providerThreadId: providerThreadOf(threadId).id, - providerTurnId: idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: `native-turn-${threadId}-${ordinal}`, - }), - runOrdinal: ordinal, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* Fiber.join(received); - }), - ).pipe(Effect.orDie); - - const replaceRuntime = () => - Effect.gen(function* () { - yield* manager.release({ providerSessionId, reason: "runtime_error" }); - runtime = yield* manager.open({ - threadId: threadA, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* manager.open({ - threadId: threadB, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* startTurn(threadB, 2); - }).pipe(Effect.orDie); - - // B's running turn keeps the shared runtime itself busy throughout. - yield* startTurn(threadB, 1); - yield* scenario({ - state, - manager, - providerSessionId, - threadA, - replaceRuntime, - startTurn, - endTurn, - resume, - }); - }).pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - ...(options.hasPendingBackgroundWorkForThread === undefined - ? {} - : { hasPendingBackgroundWorkForThread: options.hasPendingBackgroundWorkForThread }), - }), - ), - ); - }); -} - -it.effect( - "ProviderSessionManagerV2 unloads a shared-runtime thread left idle, and reloads it on its next turn", - () => - runIdleThreadUnloadScenario( - "idle-unload", - ({ state, manager, providerSessionId, threadA, startTurn, endTurn, resume }) => - Effect.gen(function* () { - yield* startTurn(threadA, 1); - assert.equal((yield* Ref.get(state)).resumeCount, 2); - yield* endTurn(threadA, 1); - - yield* TestClock.adjust("500 millis"); - yield* startTurn(threadA, 2); - yield* TestClock.adjust("1 second"); - assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []); - - yield* endTurn(threadA, 2); - yield* TestClock.adjust("1 second"); - assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, [`native-${threadA}`]); - assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); - assert.equal((yield* Ref.get(state)).closeCount, 0); - - const resumes = (yield* Ref.get(state)).resumeCount; - yield* resume(threadA); - assert.equal((yield* Ref.get(state)).resumeCount, resumes + 1); - }), - ), -); - -it.effect( - "ProviderSessionManagerV2 keeps an idle shared-runtime thread loaded while its background work runs", - () => - Effect.gen(function* () { - const pendingWork = yield* Ref.make(true); - yield* runIdleThreadUnloadScenario( - "idle-unload-pinned", - ({ state, threadA, startTurn, endTurn }) => - Effect.gen(function* () { - yield* startTurn(threadA, 1); - yield* endTurn(threadA, 1); - yield* TestClock.adjust("3 seconds"); - assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []); - - yield* Ref.set(pendingWork, false); - yield* TestClock.adjust("1 second"); - assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, [ - `native-${threadA}`, - ]); - }), - { hasPendingBackgroundWorkForThread: Ref.get(pendingWork) }, - ); - }), -); - -it.effect( - "ProviderSessionManagerV2 gives replacement sessions their own full thread idle window", - () => - runIdleThreadUnloadScenario( - "idle-unload-replacement", - ({ state, threadA, startTurn, endTurn, replaceRuntime }) => - Effect.gen(function* () { - yield* startTurn(threadA, 1); - yield* endTurn(threadA, 1); - yield* TestClock.adjust("500 millis"); - yield* replaceRuntime(); - yield* startTurn(threadA, 2); - yield* endTurn(threadA, 2); - // The old session's deadline must not unload the replacement. - yield* TestClock.adjust("500 millis"); - assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []); - yield* TestClock.adjust("500 millis"); - assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, [`native-${threadA}`]); - }), - ), -); - it.effect( "ProviderSessionManagerV2 rejects a second thread when the provider runtime is exclusive", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts index 80071a8cd2a7..63945207e008 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts @@ -63,30 +63,6 @@ it("uses the adapter's selection transition classification", () => { ); }); -it("treats provider option changes as selection changes", () => { - assert.deepEqual( - decideProviderSessionTransition({ - current: { - ...base, - modelSelection: { - ...base.modelSelection, - options: [{ id: "reasoningEffort", value: "medium" }], - }, - }, - target: { - ...base, - modelSelection: { - ...base.modelSelection, - options: [{ id: "reasoningEffort", value: "high" }], - }, - available: true, - }, - selectionTransition: { type: "apply_on_next_turn" }, - }), - { type: "switch_model_in_session" }, - ); -}); - it("restarts compatible instances for workspace or runtime changes", () => { assert.deepEqual( decideProviderSessionTransition({ @@ -97,25 +73,6 @@ it("restarts compatible instances for workspace or runtime changes", () => { ); }); -it("preserves a rejected selection when the workspace also changes", () => { - assert.deepEqual( - decideProviderSessionTransition({ - current: base, - target: { - ...base, - modelSelection: { ...base.modelSelection, model: "gpt-5.2-codex" }, - workspace: "/other", - available: true, - }, - selectionTransition: { - type: "reject", - reason: "The active session cannot apply that model.", - }, - }), - { type: "reject", reason: "The active session cannot apply that model." }, - ); -}); - it("preserves handoff and missing-classification outcomes across workspace changes", () => { const target = { ...base, @@ -154,23 +111,3 @@ it("uses portable handoff for incompatible continuation identities", () => { { type: "create_with_handoff" }, ); }); - -it("uses portable handoff for cross-driver transitions", () => { - const claudeDriver = ProviderDriverKind.make("claude"); - assert.deepEqual( - decideProviderSessionTransition({ - current: base, - target: { - ...base, - driver: claudeDriver, - continuationIdentity: { driverKind: claudeDriver, continuationKey: "claude:account:one" }, - modelSelection: { - instanceId: ProviderInstanceId.make("claude"), - model: "claude-opus-4-1", - }, - available: true, - }, - }), - { type: "create_with_handoff" }, - ); -}); diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index d9b1de61a571..f44ee0187507 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -104,49 +104,6 @@ function layerTest( return ProviderSwitch.layer.pipe(Layer.provide(layerRegistry)); } -it.effect( - "restarts and releases the current session for unsupported in-session model changes", - () => - Effect.gen(function* () { - const service = yield* ProviderSwitch.ProviderSwitchServiceV2; - const result = yield* service.plan({ - projection: projection(), - targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, - }); - assert.equal(result.transition.type, "restart_and_resume"); - assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); - }).pipe( - Effect.provide( - layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), - ), - ), -); - -it.effect.each(["stopped", "error"] as const)( - "restarts and releases the live session when a newer %s session exists", - (deadStatus) => - Effect.gen(function* () { - const service = yield* ProviderSwitch.ProviderSwitchServiceV2; - const thread = projection(); - const result = yield* service.plan({ - projection: { - ...thread, - providerSessions: [ - ...thread.providerSessions, - deadSessionRecord("dead_session", deadStatus), - ], - }, - targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, - }); - assert.equal(result.transition.type, "restart_and_resume"); - assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); - }).pipe( - Effect.provide( - layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), - ), - ), -); - it.effect("releases the newest live session, not the newest record overall", () => Effect.gen(function* () { const service = yield* ProviderSwitch.ProviderSwitchServiceV2; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.memory.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.memory.test.ts deleted file mode 100644 index 91f36959e76b..000000000000 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.memory.test.ts +++ /dev/null @@ -1,25 +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(["regular", "handoff", "handoff-compact", "handoff-failure", "subagent-result"])( - "releases startup history while %s run workers remain alive", - async (mode) => { - const root = NodeURL.fileURLToPath(new URL("../../../../", import.meta.url)); - const fixture = NodeURL.fileURLToPath( - new URL("./testkit/ProviderTurnStartMemory.fixture.mjs", import.meta.url), - ); - const { stdout } = await execFile(process.execPath, ["--expose-gc", fixture, root, mode]); - const result = JSON.parse(stdout) as { - checkpoints: ReadonlyArray<{ runs: number; retained: number }>; - }; - expect(result.checkpoints.map(({ runs, retained }) => ({ runs, retained }))).toEqual([ - { runs: 2, retained: 0 }, - { runs: 4, retained: 0 }, - ]); - }, -); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index ee9ea40c4090..0e7ecfa9f014 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -3,7 +3,6 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { CheckpointScopeId, - ContextTransferId, MessageId, NodeId, ProviderSessionId, @@ -202,23 +201,6 @@ function startWithMissingWorktree( }; } -it("does not commit running state when inherited background routing cannot be read", async () => { - const worktreePath = `${managedWorktreesDir}/project/feature-restore`; - const harness = startWithMissingWorktree(worktreePath); - const error = await harness.start; - - expect(error._tag).toBe("ProviderTurnStartError"); - expect(harness.projectionReads()).toBe(3); - expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-turn-start-project" }); - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-turn-start-project", - refName: "feature/restore", - path: worktreePath, - }); - expect(harness.writeIfRunCurrent).not.toHaveBeenCalled(); - expect(harness.startRootRun).not.toHaveBeenCalled(); -}); - it.each([ { label: "//", @@ -784,19 +766,6 @@ effectIt.effect.each(["cleanup-first", "startup-first"])( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -effectIt.effect("reads only thread state before taking the startup lease", () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ text: "/logout", previousNativeSession: true }); - yield* harness.start; - expect(harness.getThreadRecords).toHaveBeenCalledExactlyOnceWith( - harness.projection().thread.id, - [], - ); - expect(harness.getTurnStartContext).toHaveBeenCalledOnce(); - expect(harness.projection().runs.at(-1)?.status).toBe("completed"); - }), -); - effectIt.effect("reads current startup state after waiting for the workspace lease", () => Effect.gen(function* () { const worktreePath = "/tmp/provider-start-fresh-state"; @@ -829,123 +798,66 @@ effectIt.effect("reads current startup state after waiting for the workspace lea }), ); +// regression: #15784 superseded provider startups kept running effectIt.effect.each([ - { boundary: "policy resolution", nativeMode: "ensure", loss: "replaced" }, - { boundary: "policy resolution", nativeMode: "ensure", loss: "interrupted" }, - { boundary: "session opening", nativeMode: "ensure", loss: "replaced" }, - { boundary: "session opening", nativeMode: "ensure", loss: "interrupted" }, - { boundary: "session opening", nativeMode: "resume", loss: "replaced" }, - { boundary: "session opening", nativeMode: "fork", loss: "replaced" }, -])( - "does not continue $nativeMode startup when $loss during $boundary", - ({ boundary, nativeMode, loss }) => - Effect.gen(function* () { - const entered = yield* Deferred.make(); - const release = yield* Deferred.make(); - const gate = Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(release)), - ); - const harness = makeLocalCommandHarness({ text: "Continue" }); - const projection = harness.projection(); - const run = projection.runs[0]!; - const providerThread = projection.providerThreads[0]!; - if (nativeMode === "resume") { - harness.setProjection({ - ...projection, - providerThreads: [ - { - ...providerThread, - nativeThreadRef: { - driver: providerThread.driver, - nativeId: "existing", - strength: "strong", - }, - }, - ], - }); - } else if (nativeMode === "fork") { - const sourceThreadId = ThreadId.make("fork-source"); - harness.getThreadRecords.mockReturnValue( - Effect.succeed({ - ...projection, - thread: { ...projection.thread, id: sourceThreadId }, - }), - ); - harness.setProjection({ - ...projection, - contextTransfers: [ - { - id: ContextTransferId.make("pending-fork"), - type: "fork", - sourceThreadId, - targetThreadId: projection.thread.id, - sourcePoint: { threadId: sourceThreadId, runId: run.id }, - basePoint: null, - sourceProviderInstanceId: run.providerInstanceId, - targetProviderInstanceId: run.providerInstanceId, - targetRunId: run.id, - status: "pending", - resolution: null, - createdBy: "user", - error: null, - createdAt: projection.updatedAt, - updatedAt: projection.updatedAt, - consumedAt: null, - }, - ], - }); - } - const ensureThread = vi.fn(() => Effect.succeed(providerThread)); - const resumeThread = vi.fn(() => Effect.succeed(providerThread)); - const forkThread = vi.fn(() => Effect.succeed(providerThread)); - harness.open.mockReturnValue( - (boundary === "session opening" ? gate : Effect.void).pipe( - Effect.as({ - driver: providerThread.driver, - ensureThread, - resumeThread, - forkThread, - } as never), - ), + { boundary: "policy resolution", loss: "replaced" }, + { boundary: "session opening", loss: "interrupted" }, +])("does not continue startup when $loss during $boundary", ({ boundary, loss }) => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const gate = Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))); + const harness = makeLocalCommandHarness({ text: "Continue" }); + const projection = harness.projection(); + const providerThread = projection.providerThreads[0]!; + const ensureThread = vi.fn(() => Effect.succeed(providerThread)); + const resumeThread = vi.fn(() => Effect.succeed(providerThread)); + const forkThread = vi.fn(() => Effect.succeed(providerThread)); + harness.open.mockReturnValue( + (boundary === "session opening" ? gate : Effect.void).pipe( + Effect.as({ + driver: providerThread.driver, + ensureThread, + resumeThread, + forkThread, + } as never), + ), + ); + if (boundary === "policy resolution") { + harness.resolveRuntimePolicy.mockReturnValue( + gate.pipe(Effect.as({ cwd: "/tmp/native-account-command" } as never)), ); - if (boundary === "policy resolution") { - harness.resolveRuntimePolicy.mockReturnValue( - gate.pipe(Effect.as({ cwd: "/tmp/native-account-command" } as never)), - ); - } + } - const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(entered); - const current = harness.projection(); - const replacementId = RunAttemptId.make("replacement-attempt"); - const updated = { - ...current, - runs: current.runs.map((candidate) => - loss === "replaced" - ? { ...candidate, activeAttemptId: replacementId } - : { ...candidate, status: "interrupted" as const, completedAt: current.updatedAt }, - ), - attempts: - loss === "replaced" - ? [ - ...current.attempts, - { ...current.attempts[0]!, id: replacementId, attemptOrdinal: 2 }, - ] - : current.attempts, - }; - harness.setProjection(updated); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(startup); + const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(entered); + const current = harness.projection(); + const replacementId = RunAttemptId.make("replacement-attempt"); + const updated = { + ...current, + runs: current.runs.map((candidate) => + loss === "replaced" + ? { ...candidate, activeAttemptId: replacementId } + : { ...candidate, status: "interrupted" as const, completedAt: current.updatedAt }, + ), + attempts: + loss === "replaced" + ? [...current.attempts, { ...current.attempts[0]!, id: replacementId, attemptOrdinal: 2 }] + : current.attempts, + }; + harness.setProjection(updated); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(startup); - expect(harness.open).toHaveBeenCalledTimes(boundary === "session opening" ? 1 : 0); - expect(ensureThread).not.toHaveBeenCalled(); - expect(resumeThread).not.toHaveBeenCalled(); - expect(forkThread).not.toHaveBeenCalled(); - expect(harness.startRootRun).not.toHaveBeenCalled(); - expect(harness.writeIfRunCurrent).not.toHaveBeenCalled(); - expect(harness.events).toEqual([]); - expect(harness.projection()).toEqual(updated); - }), + expect(harness.open).toHaveBeenCalledTimes(boundary === "session opening" ? 1 : 0); + expect(ensureThread).not.toHaveBeenCalled(); + expect(resumeThread).not.toHaveBeenCalled(); + expect(forkThread).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + expect(harness.writeIfRunCurrent).not.toHaveBeenCalled(); + expect(harness.events).toEqual([]); + expect(harness.projection()).toEqual(updated); + }), ); effectIt.effect("starts another provider in the same checkout while a turn is pending", () => @@ -1025,24 +937,6 @@ effectIt.effect("leaves the run starting when a session-open failure will be ret }), ); -effectIt.effect("keeps a session-open failure retryable when terminal persistence fails", () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: "Continue", - openFailure: new Error("provider session rejected"), - writeFailure: new Error("database unavailable"), - }); - - const error = yield* harness.start.pipe(Effect.flip); - - expect(error._tag).toBe("ProviderTurnStartError"); - expect(harness.writeIfRunCurrent).toHaveBeenCalledOnce(); - expect(harness.startRootRun).not.toHaveBeenCalled(); - expect(harness.projection().runs.at(-1)?.status).toBe("starting"); - expect(harness.events).toEqual([]); - }), -); - effectIt.effect("does not terminalize a provider-session open interruption", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ text: "Continue", interruptOpen: true }); @@ -1081,83 +975,6 @@ effectIt.effect("does not overwrite a run interrupted while its provider session }), ); -effectIt.effect("fails a starting run when its last start attempt cannot load the thread", () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: "Continue", - ensureThreadFailure: new Error("Pi RPC read failed: pi process exited with code 1."), - }); - - yield* harness.start; - - expect(harness.startRootRun).not.toHaveBeenCalled(); - const projection = harness.projection(); - expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null }); - expect(projection.attempts[0]).toMatchObject({ status: "failed", startedAt: null }); - expect(projection.nodes[0]).toMatchObject({ status: "failed", startedAt: null }); - expect(projection.turnItems).toMatchObject([ - { - type: "error", - title: "Provider turn failed to start", - failure: { - class: "provider_error", - message: "Pi RPC read failed: pi process exited with code 1.", - }, - }, - ]); - }), -); - -effectIt.effect("leaves the run starting when a thread-load failure will be retried", () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: "Continue", - ensureThreadFailure: new Error("pi process exited with code 1"), - }); - - const error = yield* harness.startWithRetry.pipe(Effect.flip); - - expect(error._tag).toBe("ProviderTurnStartError"); - expect(harness.writeIfRunCurrent).not.toHaveBeenCalled(); - expect(harness.projection().runs.at(-1)?.status).toBe("starting"); - }), -); - -effectIt.effect("keeps a thread-load failure retryable when terminal persistence fails", () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: "Continue", - ensureThreadFailure: new Error("pi process exited with code 1"), - writeFailure: new Error("database unavailable"), - }); - - const error = yield* harness.start.pipe(Effect.flip); - - expect(error._tag).toBe("ProviderTurnStartError"); - expect(harness.projection().runs.at(-1)?.status).toBe("starting"); - expect(harness.events).toEqual([]); - }), -); - -effectIt.effect( - "keeps a store failure after the provider loaded the thread typed and retryable", - () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: "Continue", - historyReadFailureAfterFallback: new Error("database unavailable"), - }); - - const error = yield* harness.start.pipe(Effect.flip); - - expect(error._tag).toBe("ProviderTurnStartError"); - expect((error.cause as { _tag?: string } | undefined)?._tag).toBe("ProjectionStoreReadError"); - expect(harness.writeIfRunCurrent).not.toHaveBeenCalled(); - expect(harness.projection().runs.at(-1)?.status).toBe("starting"); - expect(harness.events).toEqual([]); - }), -); - effectIt.effect("does not mistake a failed state read for a superseded run", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ text: "Continue", failReadsAfterRunning: true }); @@ -1175,22 +992,6 @@ effectIt.effect("does not mistake a failed state read for a superseded run", () }), ); -effectIt.effect("does not overwrite a run interrupted while its thread loads", () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: "Continue", - ensureThreadFailure: new Error("pi process exited with code 1"), - interruptRunBeforeOpenFailure: true, - }); - - yield* harness.start; - - expect(harness.projection().runs.at(-1)?.status).toBe("interrupted"); - expect(harness.projection().turnItems).toEqual([]); - expect(harness.events).toEqual([]); - }), -); - effectIt.effect( "signs out the existing native provider before opening the newly selected provider", () => @@ -1224,31 +1025,6 @@ effectIt.effect( }), ); -effectIt.effect("persists a failed sign-out without starting a provider turn", () => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: "/logout", - logoutFailure: "Could not stop all sessions for this provider. Try again.", - }); - - yield* harness.start; - - expect(harness.open).not.toHaveBeenCalled(); - expect(harness.startRootRun).not.toHaveBeenCalled(); - expect(harness.projection().runs.at(-1)?.status).toBe("failed"); - expect(harness.projection().turnItems).toMatchObject([ - { - type: "error", - title: "Provider sign-out failed", - failure: { - class: "permission_error", - message: "Could not stop all sessions for this provider. Try again.", - }, - }, - ]); - }), -); - for (const previousMessages of [[], ["/compact", " /COMPACT "]]) { effectIt.effect( `rejects compaction without conversation context after ${previousMessages.length} prior compactions`, diff --git a/apps/server/src/orchestration-v2/ProviderTurnTokenUsage.test.ts b/apps/server/src/orchestration-v2/ProviderTurnTokenUsage.test.ts deleted file mode 100644 index c8fd51902fda..000000000000 --- a/apps/server/src/orchestration-v2/ProviderTurnTokenUsage.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - NodeId, - type OrchestrationV2ProviderTurn, - ProviderThreadId, - ProviderTurnId, -} from "@t3tools/contracts"; -import { assert, describe, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; - -import { upsertProviderTurn } from "./ProjectionStore.ts"; - -describe("provider turn token usage projection", () => { - it("keeps live usage when a terminal update omits it", () => { - const now = DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"); - const running = { - id: ProviderTurnId.make("provider-turn-usage"), - providerThreadId: ProviderThreadId.make("provider-thread-usage"), - nodeId: NodeId.make("node-usage"), - runAttemptId: null, - nativeTurnRef: null, - ordinal: 1, - status: "running", - startedAt: now, - completedAt: null, - tokenUsage: { - usedTokens: 50_000, - maxTokens: 200_000, - updatedAt: "2026-08-29T00:00:00.000Z", - }, - } satisfies OrchestrationV2ProviderTurn; - const completed = { - ...running, - status: "completed", - completedAt: now, - tokenUsage: undefined, - } satisfies OrchestrationV2ProviderTurn; - - const projected = upsertProviderTurn([running], completed); - - assert.deepEqual(projected[0]?.tokenUsage, running.tokenUsage); - assert.equal(projected[0]?.status, "completed"); - }); -}); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 9a470f0958e8..b5058898783d 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -27,12 +27,10 @@ import * as DateTime from "effect/DateTime"; 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 Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; -import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as GitManager from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerActivation from "../serverActivation.ts"; @@ -747,86 +745,6 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect.each([ - { - change: "ghe.com token rotation", - host: "avatars.example.ghe.com", - nextPath: "/u/1?token=new&v=4&s=40&format=png", - expectedSyncs: 1, - }, - { - change: "avatar version", - host: "avatars.example.ghe.com", - nextPath: "/u/1?token=old&v=5&s=40&format=png", - expectedSyncs: 2, - }, - { - change: "avatar size", - host: "avatars.example.ghe.com", - nextPath: "/u/1?token=old&v=4&s=80&format=png", - expectedSyncs: 2, - }, - { - change: "another avatar query parameter", - host: "avatars.example.ghe.com", - nextPath: "/u/1?token=old&v=4&s=40&format=webp", - expectedSyncs: 2, - }, - { - change: "avatar path", - host: "avatars.example.ghe.com", - nextPath: "/u/2?token=old&v=4&s=40&format=png", - expectedSyncs: 2, - }, - { - change: "token outside ghe.com", - host: "avatars.example.com", - nextPath: "/u/1?token=new&v=4&s=40&format=png", - expectedSyncs: 2, - }, - ])("syncs avatar changes selectively: $change", ({ host, nextPath, expectedSyncs }) => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const avatarUrl = yield* Ref.make(`https://${host}/u/1?token=old&v=4&s=40&format=png`); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), - summary: (input) => - Ref.get(avatarUrl).pipe( - Effect.map((avatarUrl) => - makeSummary(input, { - updatedAt: NOW, - author: { login: "octocat", name: null, avatarUrl }, - }), - ), - ), - }); - - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - const firstSweep = yield* Ref.get(fixture.syncCommands); - assert.strictEqual(firstSweep.length, 1); - assert.strictEqual(firstSweep[0]?.snapshot.author?.avatarUrl, yield* Ref.get(avatarUrl)); - yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep)); - - yield* Ref.set(avatarUrl, `https://${host}${nextPath}`); - yield* sweepAgain(fixture, reactor); - - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); - assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, expectedSyncs); - const commands = yield* Ref.get(fixture.syncCommands); - assert.strictEqual(commands.length, expectedSyncs); - if (expectedSyncs === 2) { - assert.strictEqual( - commands.at(-1)?.snapshot.author?.avatarUrl, - yield* Ref.get(avatarUrl), - ); - } - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - it.effect("refreshes closed links through each project after reopening elsewhere", () => Effect.scoped( Effect.gen(function* () { @@ -995,65 +913,6 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("discovers externally reopened pull requests after fifteen minutes", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const state = yield* Ref.make<"closed" | "open">("closed"); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }), - ]), - summary: (input) => - Ref.get(state).pipe(Effect.map((state) => makeSummary(input, { state }))), - }); - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); - yield* Ref.set(state, "open"); - for (let index = 0; index < 14; index += 1) yield* sweepAgain(fixture, reactor); - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); - yield* sweepAgain(fixture, reactor); - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); - assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open"); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - - it.effect("reads a quiet open pull request every five minutes until it changes", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const updatedAt = yield* Ref.make("2026-08-27T00:00:00.000Z"); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("quiet", { pullRequests: [makeLink(5)] })]), - summary: (input) => - Ref.get(updatedAt).pipe(Effect.map((updatedAt) => makeSummary(input, { updatedAt }))), - }); - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - const applySyncs = Effect.flatMap(Ref.get(fixture.syncCommands), (commands) => - Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)), - ); - yield* applySyncs; - const reads = Ref.get(fixture.summaryCalls).pipe(Effect.map((calls) => calls.length)); - assert.strictEqual(yield* reads, 1); - for (let index = 0; index < 4; index += 1) yield* sweepAgain(fixture, reactor); - assert.strictEqual(yield* reads, 1); - - // Someone comments: the five-minute read sees it, and the next sweep reads it again. - yield* Ref.set(updatedAt, "2026-08-28T12:04:00.000Z"); - yield* sweepAgain(fixture, reactor); - assert.strictEqual(yield* reads, 2); - yield* applySyncs; - yield* sweepAgain(fixture, reactor); - assert.strictEqual(yield* reads, 3); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - it.effect("preserves a refresh requested while an older host read is in flight", () => Effect.scoped( Effect.gen(function* () { @@ -1325,49 +1184,6 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("reads a flagged member's layers past a cache entry a requested sweep left live", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const stack = makeGrowingStack(); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("one", { pullRequests: [makeLink(18), makeLink(20)] }), - ]), - invalidate: stack.invalidate, - stack: stack.cachedRead, - }); - - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - yield* applyRecordedSyncs(fixture); - - // 30 s later a state change refreshes #18, which caches its two-layer read until 90 s. - yield* TestClock.adjust("30 seconds"); - yield* requestAndApply(fixture, reactor, 18); - - // At 40 s #83 is pushed on top and linked. Its requested sweep flags #18 and #20. - yield* TestClock.adjust("10 seconds"); - stack.grow(); - yield* linkPullRequest83(fixture); - yield* requestAndApply(fixture, reactor, 83); - - // The scheduled sweep at 60 s: #20's entry has expired, #18's is still live. - const invalidatedBefore = stack.invalidated.length; - yield* sweepAgain(fixture, reactor).pipe(Effect.andThen(applyRecordedSyncs(fixture))); - - assert.deepStrictEqual(yield* storedStackLayers(fixture), [ - [18, [18, 20, 83]], - [20, [18, 20, 83]], - [83, [18, 20, 83]], - ]); - // Only the two flagged members invalidate, once each. - assert.deepStrictEqual(stack.invalidated.slice(invalidatedBefore).toSorted(), [18, 20]); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - it.effect("reads past the cache when a sibling flags a member during its summary read", () => Effect.scoped( Effect.gen(function* () { @@ -1417,62 +1233,6 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("re-reads a member a sibling flags while its own stack read is in flight", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const stackOf = (numbers: ReadonlyArray) => ({ - id: "stack-80", - number: 80, - url: "https://github.com/owner/repository/stack/80", - base: "main", - layers: numbers.map(stackLayer), - }); - const updated = { updatedAt: "2026-08-28T00:00:00.000Z" }; - const synced83 = yield* Deferred.make(); - let staleReads = 1; - const fixture = yield* makeHarness({ - // #18 still holds the layers from before #83 was pushed; #83 already has all three. - snapshot: makeSnapshot([ - makeThread("one", { - pullRequests: [ - makeLink(18, {}, { stack: { kind: "native", ...stackOf([18, 20]) } }), - makeLink(83, {}, { stack: { kind: "native", ...stackOf([18, 20, 83]) } }), - ], - }), - ]), - summary: (input) => Effect.succeed(makeSummary(input, updated)), - // #18's first read answers the old two layers, and only after #83 has read its stack, - // flagged #18 and synced. - stack: (input) => - input.number === 18 && staleReads-- > 0 - ? Deferred.await(synced83).pipe(Effect.as(stackOf([18, 20]))) - : Effect.succeed(stackOf([18, 20, 83])), - onSync: (command) => - command.number === 83 ? Deferred.succeed(synced83, undefined) : Effect.void, - }); - - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - yield* applyRecordedSyncs(fixture); - - // Neither summary changes again, so only #83's flag can make #18 read its stack. - // #18's stale answer also flags #83, whose fresh read in this sweep would flag #18 - // back for a third sweep. The assertion sees the regression only because #18, the - // earlier group, finishes before #83 runs; the mocks answer synchronously, so it does. - // With the flag kept, #18 reads fresh here regardless of that order. - yield* sweepAgain(fixture, reactor); - yield* applyRecordedSyncs(fixture); - - assert.deepStrictEqual(yield* storedStackLayers(fixture), [ - [18, [18, 20, 83]], - [83, [18, 20, 83]], - ]); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - it.effect("leaves stacks in other repositories alone when their ids match", () => Effect.scoped( Effect.gen(function* () { @@ -1540,132 +1300,6 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("leaves a rate limited host unread until its pause ends", () => { - const skips: Array> = []; - const logger = Logger.make(({ logLevel, message }) => { - const parts = Array.isArray(message) ? message : [message]; - if (logLevel === "Warn" && parts[0] === "pull request sync skipped") skips.push(parts); - }); - const retryAt = Date.parse(NOW) + 3 * 60_000; - return Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("first", { pullRequests: [makeLink(7, { state: "open", updatedAt: NOW })] }), - makeThread("second", { - pullRequests: [makeLink(8, { state: "open", updatedAt: NOW })], - }), - makeThread("third", { - pullRequests: [ - makeLink( - 9, - { state: "open", updatedAt: NOW }, - { host: "forge.example", url: "https://forge.example/owner/repository/pulls/9" }, - ), - ], - }), - ]), - summary: (input) => - Effect.gen(function* () { - if (input.host === "github.com" && (yield* Clock.currentTimeMillis) < retryAt) { - const paused = new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestSummary", - reason: "rate-limited", - detail: "paused", - retryAt, - }); - return yield* new PullRequestOperationError({ - operation: "summary", - detail: "paused", - cause: paused, - }); - } - return makeSummary(input, { state: "open" }); - }), - }); - const githubReads = Ref.get(fixture.summaryCalls).pipe( - Effect.map((calls) => - calls.filter((call) => call.host === "github.com").map((call) => call.number), - ), - ); - - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - assert.deepStrictEqual(yield* githubReads, [7]); - assert.strictEqual(skips.length, 1); - assert.deepInclude(skips[0]![1], { count: 1 }); - - yield* reactor.requestSync({ - host: "github.com", - repository: "owner/repository", - number: 7, - }); - yield* Queue.take(fixture.snapshotReads); - yield* reactor.drain; - yield* sweepAgain(fixture, reactor); - yield* sweepAgain(fixture, reactor); - assert.deepStrictEqual(yield* githubReads, [7]); - assert.strictEqual(skips.length, 1); - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 4); - - yield* sweepAgain(fixture, reactor); - assert.sameMembers((yield* githubReads).slice(1), [7, 8]); - }).pipe( - // The reactor forks its worker while its layer builds, so the logger must reach it there. - Effect.provide( - fixture.layer.pipe(Layer.provide(Logger.layer([logger], { mergeWithExisting: false }))), - ), - ); - }), - ); - }); - - it.effect("pauses the host when only its stack read is rate limited", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const retryAt = Date.parse(NOW) + 3 * 60_000; - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7, null)] })]), - summary: (input) => Effect.succeed(makeSummary(input, { state: "open" })), - stack: () => - Effect.gen(function* () { - if ((yield* Clock.currentTimeMillis) >= retryAt) return null; - return yield* new PullRequestOperationError({ - operation: "stack", - detail: "paused", - cause: new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestStack", - reason: "rate-limited", - detail: "paused", - retryAt, - }), - }); - }), - }); - const reads = Effect.all([ - Ref.get(fixture.summaryCalls).pipe(Effect.map((calls) => calls.length)), - Ref.get(fixture.stackCalls).pipe(Effect.map((calls) => calls.length)), - ]); - - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - assert.deepStrictEqual(yield* reads, [1, 1]); - yield* sweepAgain(fixture, reactor); - yield* sweepAgain(fixture, reactor); - assert.deepStrictEqual(yield* reads, [1, 1]); - - yield* sweepAgain(fixture, reactor); - assert.deepStrictEqual(yield* reads, [2, 2]); - assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 1); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - it.effect("reads open links fresh only when a run that ran a merge command ends", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts index 5b6be79b6390..df184a610822 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts @@ -14,7 +14,6 @@ import { isAwaitedWork, isRestartNoteContinuation, pendingRestartCancelledBackgroundWork, - restartCancelledBackgroundWorkNote, restartContinuationNote, mergeRestartCancelledBackgroundWork, } from "./RestartBackgroundNote.ts"; @@ -86,34 +85,7 @@ it("delivers a resumed queued run's note even after a higher-ordinal run", () => ); }); -it("bounds the note so it cannot crowd out the turn's context", () => { - const work = Array.from({ length: 25 }, (_, index) => ({ - kind: "shell" as const, - label: `sleep ${index}`, - })); - const note = restartCancelledBackgroundWorkNote(work); - assert.lengthOf(note.split("\n"), 12); - assert.isTrue(note.endsWith("- and 15 more")); - const command = cancelledTurnItemWork({ - type: "command_execution", - input: "x".repeat(10_000), - title: null, - } as never); - assert.isAtMost(command?.label.length ?? 0, 160); -}); - -it("bounds roster task labels including a long task id", () => { - const work = cancelledRosterTaskWork({ - kind: "command", - taskId: "t".repeat(400), - description: "d".repeat(400), - }); - assert.equal(work.kind, "shell"); - assert.lengthOf(work.label, 160); - const idOnly = cancelledRosterTaskWork({ kind: "background_task", taskId: "t".repeat(400) }); - assert.lengthOf(idOnly.label, 160); -}); - +// regression: #19750 wake threads whose subagents' background work a restart cut it("names a lost subagent by the id the agent resumes it with", () => { const ownShell = cancelledRosterTaskWork({ kind: "command", diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 80d9aadeced0..2483534ed75d 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -190,39 +190,6 @@ it("does not continue settled root runs with restart-cancelled background work", ); }); -it.effect.each(["completed", "waiting", "cancelled"] as const)( - "ignores a pending restart continuation for a %s run whose provider turn settled", - (status) => - Effect.gen(function* () { - const base = makeProjection(); - const work = [{ kind: "shell" as const, label: "sleep 25 && echo DONE" }]; - const projection = { - ...base, - runs: [{ ...base.runs[0]!, status, restartCancelledBackgroundWork: work }], - providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }], - } as unknown as OrchestrationV2ThreadProjection; - const commands: Parameters< - ThreadManagementService.ThreadManagementService["Service"]["dispatch"] - >[0][] = []; - yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( - Effect.provide( - Layer.merge( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(projection), - recoverDelegatedTask: () => Effect.void, - dispatch: (command) => { - commands.push(command); - return Effect.succeed({} as never); - }, - }), - ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), - ), - ), - ); - assert.lengthOf(commands, 0); - }), -); - it.effect.each(["completed", "waiting", "cancelled"] as const)( "wakes a %s run whose monitor the restart cancelled, with the note as its prompt", (status) => @@ -265,42 +232,6 @@ it.effect.each(["completed", "waiting", "cancelled"] as const)( }), ); -it.effect("does not continue a failed run that lost background work", () => - Effect.gen(function* () { - const base = makeProjection(); - const projection = { - ...base, - runs: [ - { - ...base.runs[0]!, - status: "failed", - restartCancelledBackgroundWork: [{ kind: "shell" as const, label: "sleep 25" }], - }, - ], - providerTurns: [{ ...base.providerTurns[0]!, status: "failed" }], - } as unknown as OrchestrationV2ThreadProjection; - const commands: Parameters< - ThreadManagementService.ThreadManagementService["Service"]["dispatch"] - >[0][] = []; - yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( - Effect.provide( - Layer.merge( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(projection), - recoverDelegatedTask: () => Effect.void, - dispatch: (command) => { - commands.push(command); - return Effect.succeed({} as never); - }, - }), - ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), - ), - ), - ); - assert.lengthOf(commands, 0); - }), -); - it.effect.each([ [false, undefined], [true, undefined], @@ -1044,31 +975,6 @@ it.effect("does not continue a cut /compact or /logout turn", () => }), ); -it.effect("tells a turn cut mid-way about the background work it lost", () => - Effect.gen(function* () { - const texts = yield* continuationTexts( - cutMidTurn({ - restartCancelledBackgroundWork: [{ kind: "subagent", label: "Background reviewer" }], - }), - ); - assert.lengthOf(texts, 1); - assert.include(texts[0]!, "Background reviewer"); - assert.isTrue(texts[0]!.endsWith("Continue where you left off.")); - }), -); - -it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () => - Effect.gen(function* () { - const [resumed] = yield* continuationDispatches(cutMidTurn()); - assert.deepEqual(resumed?.notification, { - source: { kind: "system" }, - outcome: "updated", - summary: "T3 Code restarted and resumed this turn", - detail: "Continue where you left off.", - }); - }), -); - it.effect( "carries the note forward when its continuation was cut before reaching the provider", () => @@ -1145,48 +1051,6 @@ it.effect("prepares later threads' continuations when one thread fails", () => }), ); -it.effect("stops rapid restart loops but allows repeated restarts after normal work", () => - Effect.gen(function* () { - const first = RunId.make("run:first-continuation"); - const second = RunId.make("run:second-continuation"); - const cut = cutMidTurn({ restartContinuationOfRunId: second }); - const withTwoLinks = { - ...cut, - runs: [ - ...cut.runs, - { ...cut.runs[0]!, id: second, restartContinuationOfRunId: first }, - { ...cut.runs[0]!, id: first, restartContinuationOfRunId: undefined }, - ], - } as unknown as OrchestrationV2ThreadProjection; - assert.lengthOf(yield* continuationTexts(withTwoLinks), 1); - - const withThreeLinks = { - ...withTwoLinks, - runs: withTwoLinks.runs.map((run) => - run.id === first ? { ...run, restartContinuationOfRunId: RunId.make("run:root") } : run, - ), - } as unknown as OrchestrationV2ThreadProjection; - for (const { completedAt, oldestRequestAge, expected } of [ - { completedAt: null, oldestRequestAge: { minutes: 6 }, expected: 0 }, - { completedAt: null, oldestRequestAge: { minutes: 6, milliseconds: 1 }, expected: 1 }, - { completedAt: now, oldestRequestAge: { minutes: 6 }, expected: 1 }, - ]) { - const projection = { - ...withThreeLinks, - runs: withThreeLinks.runs.map((run, index) => ({ - ...run, - completedAt, - requestedAt: DateTime.subtract( - now, - run.id === first ? oldestRequestAge : { minutes: index + 1 }, - ), - })), - }; - assert.lengthOf(yield* continuationTexts(projection), expected); - } - }), -); - it.effect.each([ ["user_input", "pending", "live", false], ["user_input", "expired", "not_resumable", false], diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 38bc09c25cbb..4dcfaa069f5d 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1,12 +1,9 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it, vi } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import { CheckpointScopeId, ChatAttachmentId, ChatFileAttachment, CommandId, - EnvironmentId, - EventId, MessageId, NodeId, type OrchestrationV2AppThread, @@ -34,19 +31,11 @@ import * as Exit from "effect/Exit"; 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 Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import { HttpServer } from "effect/http"; -import * as NetAddress from "effect/net/NetAddress"; -import * as TestClock from "effect/testing/TestClock"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; - -import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as CheckpointService from "./CheckpointService.ts"; import type { PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; @@ -82,11 +71,6 @@ const layerRunExecutionTest = RunExecutionService.layer.pipe( ), ); -it("keeps recoverable turn failures reusable and reserves error for broken threads", () => { - assert.equal(RunExecutionService.finalProviderThreadStatus("reusable"), "idle"); - assert.equal(RunExecutionService.finalProviderThreadStatus("broken"), "error"); -}); - it.effect("routes shared-runtime events only to their owning root run", () => Effect.gen(function* () { const now = yield* DateTime.now; @@ -1105,130 +1089,6 @@ it.effect( }).pipe(Effect.provide(layerRunExecutionTest)), ); -const layerMcpSessionRegistryTest = McpSessionRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(HttpServer.HttpServer)({ - address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 43123), - }), - Layer.mock(ServerEnvironment.ServerEnvironment)({ - getEnvironmentId: Effect.succeed(EnvironmentId.make("environment:mcp-liveness")), - }), - ), - ), -); - -it.effect("refreshes MCP credential liveness before calling the provider", () => - Effect.gen(function* () { - const order = yield* Ref.make>([]); - const key = "run-execution-mcp-liveness"; - const touchActiveMcpThread = vi - .spyOn(McpSessionRegistry, "touchActiveMcpThread") - .mockImplementation((threadId) => - Ref.update(order, (entries) => [...entries, `touch:${threadId}`]), - ); - - yield* captureRootRunTermination({ - key, - shouldFinalizeRun: () => Effect.succeed(true), - startTurn: () => Ref.update(order, (entries) => [...entries, "start-turn"]), - }).pipe(Effect.ensuring(Effect.sync(() => touchActiveMcpThread.mockRestore()))); - - assert.deepEqual(yield* Ref.get(order), [ - `touch:${backgroundScenarioIds(key).threadId}`, - "start-turn", - ]); - }), -); - -it.effect.each([ - ["keeps the MCP credential alive for a 44-hour turn without MCP traffic", false], - ["expires the MCP credential at 44 hours when ingestion ended at 2 hours", true], -] as const)("%s", ([, endEarly]) => - Effect.gen(function* () { - const key = `mcp-liveness:${endEarly}`; - const { threadId } = backgroundScenarioIds(key); - const registry = yield* McpSessionRegistry.McpSessionRegistry; - const issued = yield* registry.issue({ - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - }); - const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); - const started = yield* Deferred.make(); - const end = yield* Deferred.make(); - const run = yield* captureRootRunTermination({ - key, - shouldFinalizeRun: () => Effect.succeed(true), - startTurn: () => Deferred.succeed(started, undefined).pipe(Effect.asVoid), - events: (ids) => - Stream.fromEffect(Deferred.await(end)).pipe( - Stream.map(() => rootTerminalEvent(ids, "completed")), - ), - }).pipe(Effect.forkChild); - - yield* Effect.gen(function* () { - yield* Deferred.await(started); - yield* TestClock.adjust("2 hours"); - if (endEarly) { - yield* Deferred.succeed(end, undefined); - yield* Fiber.join(run); - } - yield* TestClock.adjust("42 hours"); - assert.equal( - (yield* registry.resolve(token))?.thread.threadId, - endEarly ? undefined : threadId, - ); - }).pipe( - Effect.ensuring( - Deferred.succeed(end, undefined).pipe(Effect.andThen(Fiber.join(run)), Effect.orDie), - ), - ); - }).pipe(Effect.provide(layerMcpSessionRegistryTest)), -); - -it.effect( - "does not renew an MCP credential after startup is interrupted during the app context read", - () => - Effect.gen(function* () { - const key = "mcp-liveness:abandoned-startup"; - const { threadId } = backgroundScenarioIds(key); - const registry = yield* McpSessionRegistry.McpSessionRegistry; - const issued = yield* registry.issue({ - threadId, - providerInstanceId: ProviderInstanceId.make("codex"), - }); - const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); - const contextReadStarted = yield* Deferred.make(); - const end = yield* Deferred.make(); - const closed = yield* Deferred.make(); - const run = yield* captureRootRunTermination({ - key, - shouldFinalizeRun: () => Effect.succeed(true), - appContext: Deferred.succeed(contextReadStarted, undefined).pipe( - Effect.andThen(Effect.never), - ), - events: (ids) => - Stream.fromEffect(Deferred.await(end)).pipe( - Stream.map(() => rootTerminalEvent(ids, "completed")), - ), - onSubscriptionClose: Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - }).pipe(Effect.interruptible, Effect.forkChild); - - yield* Effect.gen(function* () { - yield* Deferred.await(contextReadStarted); - yield* Fiber.interrupt(run); - assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(run))); - yield* TestClock.adjust("44 hours"); - assert.isUndefined(yield* registry.resolve(token)); - }).pipe( - Effect.ensuring( - Deferred.succeed(end, undefined).pipe(Effect.andThen(Deferred.await(closed))), - ), - ); - }).pipe(Effect.provide(layerMcpSessionRegistryTest)), -); - it.effect("starts the provider when checkpoint baseline capture fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-baseline-failure"); @@ -1887,36 +1747,6 @@ it.effect("ends a nested native subagent and its thread when the root run is int }), ); -it.effect("keeps ingesting a late background command item completion after root terminal", () => - Effect.gen(function* () { - const observed = yield* runBackgroundItemScenario("bg-command", (ids) => [ - backgroundTurnItemEvent(ids, "command_execution", "running", 1), - rootTerminalEvent(ids, "completed"), - backgroundTurnItemEvent(ids, "command_execution", "completed", 2), - ]); - assert.deepEqual(observed, ["turn_item:running", "root-finalized", "turn_item:completed"]); - }), -); - -it.effect("ingests the trailing subagent item completion after the subagent row completes", () => - Effect.gen(function* () { - const observed = yield* runBackgroundItemScenario("subagent-trailing-item", (ids) => [ - subagentEvent(ids, "running"), - backgroundTurnItemEvent(ids, "subagent", "running", 1), - rootTerminalEvent(ids, "completed"), - subagentEvent(ids, "completed"), - backgroundTurnItemEvent(ids, "subagent", "completed", 2), - ]); - assert.deepEqual(observed, [ - "subagent:running", - "turn_item:running", - "root-finalized", - "subagent:completed", - "turn_item:completed", - ]); - }), -); - it.effect("keeps ingesting a child thread's late background item completion", () => Effect.gen(function* () { const observed = yield* runBackgroundItemScenario("bg-child-item", (ids) => [ @@ -1996,26 +1826,6 @@ it.effect.each([null, "run:nested-sdk"] as const)( }), ); -it.effect("keeps ingesting until the last of several background items terminalizes", () => - Effect.gen(function* () { - const secondItemId = TurnItemId.make("turn-item:bg-multi:second"); - const observed = yield* runBackgroundItemScenario("bg-multi", (ids) => [ - backgroundTurnItemEvent(ids, "command_execution", "running", 1), - backgroundTurnItemEvent(ids, "dynamic_tool", "running", 2, secondItemId), - rootTerminalEvent(ids, "completed"), - backgroundTurnItemEvent(ids, "command_execution", "completed", 3), - backgroundTurnItemEvent(ids, "dynamic_tool", "completed", 4, secondItemId), - ]); - assert.deepEqual(observed, [ - "turn_item:running", - "turn_item:running", - "root-finalized", - "turn_item:completed", - "turn_item:completed", - ]); - }), -); - it.effect("does not pin ingestion on background items when the root turn is interrupted", () => Effect.gen(function* () { const observed = yield* runBackgroundItemScenario("bg-interrupted", (ids) => [ @@ -2047,70 +1857,6 @@ it.effect("seeds inherited background items before their next update", () => }), ); -it.effect("releases the live run after an inherited background item terminalizes", () => - Effect.gen(function* () { - const key = "inherited-background-terminal"; - const priorRunId = RunId.make(`run:${key}:prior`); - const observed = yield* runBackgroundItemScenario( - key, - (ids) => [ - backgroundTurnItemEventForRun(ids, priorRunId, "subagent", "running", 1), - rootTerminalEvent(ids, "completed"), - backgroundTurnItemEventForRun(ids, priorRunId, "subagent", "completed", 2), - ], - { - loadInheritedBackgroundTurnItems: () => - Effect.succeed([{ id: TurnItemId.make(`turn-item:${key}`), runId: priorRunId }]), - }, - ); - - assert.deepEqual(observed, ["turn_item:running", "root-finalized", "turn_item:completed"]); - }), -); - -it.effect("does not hold the live stream open for a foreign provider's background item", () => - Effect.gen(function* () { - const key = "inherited-background-foreign-provider"; - const ids = backgroundScenarioIds(key); - const priorRunId = RunId.make(`run:${key}:prior`); - const foreignProviderThreadId = ProviderThreadId.make(`provider-thread:${key}:foreign-session`); - const foreignItem = { - id: ids.itemId, - threadId: ids.threadId, - runId: priorRunId, - providerThreadId: foreignProviderThreadId, - type: "subagent", - status: "running", - } as OrchestrationV2TurnItem; - const inherited = RunExecutionService.selectInheritedBackgroundTurnItems({ - threadId: ids.threadId, - currentProviderThreadId: ids.providerThreadId, - currentRunOrdinal: 2, - runs: [ - { - id: priorRunId, - threadId: ids.threadId, - ordinal: 1, - status: "interrupted", - } as OrchestrationV2Run, - ], - turnItems: [foreignItem], - }); - - const observed = yield* runBackgroundItemScenario( - key, - (scenarioIds) => [rootTerminalEvent(scenarioIds, "completed")], - { - keepEventStreamOpen: true, - loadInheritedBackgroundTurnItems: () => Effect.succeed(inherited), - }, - ); - - assert.deepEqual(inherited, []); - assert.deepEqual(observed, ["root-finalized"]); - }), -); - it.effect("refreshes inherited background items after event subscription", () => Effect.gen(function* () { const key = "inherited-background-subscription-refresh"; @@ -2162,27 +1908,17 @@ it.effect("refreshes inherited background items after event subscription", () => ); it.effect( - "keeps ingesting a late empty provider-thread roster while thread-scoped pending work is true", + "cascade-terminalizes run-owned subagent rows on interrupt before root finalization", () => Effect.gen(function* () { - const key = "bg-roster-pending-work"; - const ids = backgroundScenarioIds(key); + const ids = backgroundScenarioIds("subagent-interrupt-cascade"); + const childThreadId = ids.childThreadId; + const unrelatedChildThreadId = ThreadId.make( + "thread:subagent-interrupt-cascade:unrelated-child", + ); const providerInstanceId = ProviderInstanceId.make("codex"); - const now = yield* DateTime.now; - const observed = yield* Ref.make>([]); - const pendingByProviderThreadId = yield* Ref.make(new Map([[ids.providerThreadId, true]])); - const scopedProbeArgs = yield* Ref.make>([]); - const ingestCalls = yield* Ref.make< - ReadonlyArray<{ - readonly activeAttemptId: RunAttemptId | null; - readonly eventType: string; - readonly hasWriteIfRunCurrent: boolean; - readonly hasWriteIfProviderThreadOwner: boolean; - readonly expectedLastRunOrdinal: number | null; - readonly runId: RunId | null; - readonly rosterLength: number | null; - }> - >([]); + const written = yield* Ref.make>([]); + const ingested = yield* Ref.make>([]); const ingestionDone = yield* Deferred.make(); const layerTest = RunExecutionService.layer.pipe( Layer.provide( @@ -2196,1259 +1932,119 @@ it.effect( write: () => Effect.succeed([]), writeWithEffects: (input) => Effect.gen(function* () { - if ( - input.events.some( - (event) => event.type === "run.updated" && event.runId === ids.runId, - ) - ) { - yield* Ref.update(observed, (current) => [...current, "root-finalized"]); - } + yield* Ref.update(written, (current) => [...current, ...input.events]); return []; }), writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), - writeIfProviderThreadOwner: () => - Effect.succeed({ committed: true, storedEvents: [] }), }), IdAllocator.layer, Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: (input) => - Effect.gen(function* () { - const event = input.event; - const rosterLength = - event.type === "provider_thread.updated" - ? (event.providerThread.pendingBackgroundTasks?.length ?? 0) - : null; - yield* Ref.update(ingestCalls, (current) => [ - ...current, - { - activeAttemptId: input.writeIfProviderThreadOwner?.activeAttemptId ?? null, - eventType: event.type, - hasWriteIfRunCurrent: input.writeIfRunCurrent !== undefined, - hasWriteIfProviderThreadOwner: input.writeIfProviderThreadOwner !== undefined, - expectedLastRunOrdinal: - input.writeIfProviderThreadOwner?.expectedLastRunOrdinal ?? null, - runId: input.writeIfProviderThreadOwner?.runId ?? null, - rosterLength, - }, - ]); - if (event.type === "provider_thread.updated" && rosterLength === 0) { - yield* Ref.update(pendingByProviderThreadId, (current) => { - const next = new Map(current); - next.set(event.providerThread.id, false); - return next; - }); - yield* Ref.update(observed, (current) => [...current, "roster-cleared"]); - } - if (event.type === "turn.terminal") { - yield* Ref.update(observed, (current) => [...current, "terminal"]); - } - return []; - }), + Ref.update(ingested, (current) => [...current, input.event]).pipe(Effect.as([])), }), ServerSettings.layerTest(), ), ), ); - const providerThreadBase = { - id: ids.providerThreadId, + const runningSubagent = makeRunOwnedSubagentFixture({ + ids, + providerInstanceId, + childThreadId, driver, + status: "running", + }); + const runningTurnItem = makeRunOwnedSubagentTurnItemFixture({ + ids, providerInstanceId, - providerSessionId: ProviderSessionId.make(`session:${key}`), - appThreadId: ids.threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "idle" as const, - firstRunOrdinal: 1, - lastRunOrdinal: 1, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, + childThreadId, + driver, + status: "running", + }); + const runningNode = makeRunOwnedSubagentNodeFixture({ + ids, + status: "running", + }); + const runningChildNode = makeRunOwnedSubagentChildNodeFixture({ + ids, + status: "running", + }); + const memberThreadId = ThreadId.make("thread:subagent-interrupt-cascade:member"); + const runningMember = { + ...runningSubagent, + id: NodeId.make("node:subagent-interrupt-cascade:member"), + threadId: childThreadId, + runId: null, + parentNodeId: runningChildNode.id, + childThreadId: memberThreadId, + }; + const foreignSubagent = { + ...runningMember, + id: NodeId.make("node:subagent-interrupt-cascade:foreign-subagent"), + runId: RunId.make("run:subagent-interrupt-cascade:foreign"), + childThreadId: unrelatedChildThreadId, + }; + const runningMemberRoot = { + ...runningChildNode, + id: NodeId.make("node:subagent-interrupt-cascade:member-root"), + threadId: memberThreadId, + rootNodeId: NodeId.make("node:subagent-interrupt-cascade:member-root"), + }; + const foreignChildNode = { + ...runningChildNode, + id: NodeId.make("node:subagent-interrupt-cascade:foreign-child"), + runId: foreignSubagent.runId, + }; + const runningChildTurnItem = makeLinkedChildTurnItemFixture({ + ids, + driver, + type: "command_execution", + }); + const foreignChildTurnItem = { + ...runningChildTurnItem, + id: TurnItemId.make("turn-item:subagent-interrupt-cascade:foreign-child"), + runId: foreignSubagent.runId, + }; + const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({ + ids: { + ...ids, + childItemId: TurnItemId.make("turn-item:subagent-interrupt-cascade:suppressed-assistant"), + }, + driver, + type: "assistant_message", + }); + const unrelatedChildNode = { + ...runningChildNode, + id: NodeId.make("node:subagent-interrupt-cascade:unrelated-child"), + threadId: unrelatedChildThreadId, + runId: ids.runId, + rootNodeId: NodeId.make("node:subagent-interrupt-cascade:unrelated-child"), + }; + const unrelatedChildTurnItem = { + ...runningChildTurnItem, + id: TurnItemId.make("turn-item:subagent-interrupt-cascade:unrelated-child"), + threadId: unrelatedChildThreadId, + nodeId: unrelatedChildNode.id, }; yield* Effect.gen(function* () { const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ - commandId: CommandId.make(`command:${key}`), + commandId: CommandId.make("command:subagent-interrupt-cascade"), appThread: { id: ids.threadId } as OrchestrationV2AppThread, - providerSessionId: ProviderSessionId.make(`session:${key}`), + providerSessionId: ProviderSessionId.make("session:subagent-interrupt-cascade"), session: { events: Stream.empty, - hasPendingBackgroundWork: Effect.succeed(true), - hasPendingBackgroundWorkForThread: (providerThread: OrchestrationV2ProviderThread) => - Effect.gen(function* () { - yield* Ref.update(scopedProbeArgs, (current) => [...current, providerThread.id]); - return (yield* Ref.get(pendingByProviderThreadId)).get(providerThread.id) === true; - }), subscribeEvents: Effect.succeed({ events: Stream.fromIterable([ + childThreadCreatedEvent(ids), { - type: "provider_thread.updated", + type: "subagent.updated", driver, - providerThread: { - ...providerThreadBase, - status: "active" as const, - pendingBackgroundTasks: [ - { taskId: "bg-1", description: "sleep 20", kind: "command" }, - ], - updatedAt: now, - }, - } as ProviderAdapter.ProviderAdapterV2Event, - rootTerminalEvent(ids, "completed"), + subagent: runningSubagent, + }, { - type: "provider_thread.updated", - driver, - providerThread: { - ...providerThreadBase, - status: "idle" as const, - pendingBackgroundTasks: [], - updatedAt: now, - }, - } as ProviderAdapter.ProviderAdapterV2Event, - ]), - close: Deferred.succeed(ingestionDone, undefined), - }), - startTurn: () => Effect.void, - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: ids.runId, - threadId: ids.threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, - checkpointScope: { - id: CheckpointScopeId.make(`checkpoint-scope:${key}`), - } as OrchestrationV2CheckpointScope, - providerThread: providerThreadBase as OrchestrationV2ProviderThread, - attempt: { - id: ids.attemptId, - providerTurnId: ids.rootProviderTurnId, - } as OrchestrationV2RunAttempt, - attemptId: ids.attemptId, - providerTurnOrdinal: 1, - message: { - messageId: MessageId.make(`message:${key}:user`), - text: "Start background work and settle.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }); - }).pipe(Effect.provide(layerTest)); - - const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); - assert.isTrue(Option.isSome(closed), "event subscription did not release"); - assert.deepEqual(yield* Ref.get(observed), ["terminal", "root-finalized", "roster-cleared"]); - assert.isTrue((yield* Ref.get(scopedProbeArgs)).includes(ids.providerThreadId)); - - const calls = yield* Ref.get(ingestCalls); - const preTerminalRoster = calls.find( - (call) => call.eventType === "provider_thread.updated" && call.rosterLength === 1, - ); - const lateClear = calls.find( - (call) => call.eventType === "provider_thread.updated" && call.rosterLength === 0, - ); - assert.isDefined(preTerminalRoster); - assert.isTrue(preTerminalRoster?.hasWriteIfRunCurrent); - assert.isFalse(preTerminalRoster?.hasWriteIfProviderThreadOwner); - assert.isDefined(lateClear); - assert.isFalse( - lateClear?.hasWriteIfRunCurrent, - "late empty roster must not use stale writeIfRunCurrent running-gate", - ); - assert.isTrue( - lateClear?.hasWriteIfProviderThreadOwner, - "late empty roster must gate on provider-thread ownership", - ); - assert.equal(lateClear?.expectedLastRunOrdinal, 1); - assert.equal(lateClear?.runId, ids.runId); - assert.equal(lateClear?.activeAttemptId, ids.attemptId); - }), -); - -it.effect("drops late root provider-thread writes from a superseded attempt", () => - Effect.gen(function* () { - const key = "bg-roster-attempt-owner-lost"; - const ids = backgroundScenarioIds(key); - const replacementAttemptId = RunAttemptId.make(`attempt:${key}:replacement`); - const providerInstanceId = ProviderInstanceId.make("codex"); - const now = yield* DateTime.now; - const observed = yield* Ref.make>([]); - const ingestionDone = yield* Deferred.make(); - const ingestCalls = yield* Ref.make< - ReadonlyArray<{ - readonly activeAttemptId: RunAttemptId | null; - readonly eventType: string; - readonly hasWriteIfProviderThreadOwner: boolean; - readonly expectedLastRunOrdinal: number | null; - readonly rosterLength: number | null; - readonly committed: boolean; - }> - >([]); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), - layerTurnItemPositionsTest, - Layer.mock(EventSink.EventSinkV2)({ - write: () => Effect.succeed([]), - writeWithEffects: (input) => - Effect.gen(function* () { - if ( - input.events.some( - (event) => event.type === "run.updated" && event.runId === ids.runId, - ) - ) { - yield* Ref.update(observed, (current) => [...current, "root-finalized"]); - } - return []; - }), - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), - writeIfProviderThreadOwner: () => - Effect.succeed({ committed: false, storedEvents: [] }), - }), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: (input) => - Effect.gen(function* () { - const event = input.event; - const rosterLength = - event.type === "provider_thread.updated" - ? (event.providerThread.pendingBackgroundTasks?.length ?? 0) - : null; - const ownerGate = input.writeIfProviderThreadOwner; - const rejectAsStaleOwner = - ownerGate !== undefined && ownerGate.activeAttemptId !== replacementAttemptId; - yield* Ref.update(ingestCalls, (current) => [ - ...current, - { - activeAttemptId: ownerGate?.activeAttemptId ?? null, - eventType: event.type, - hasWriteIfProviderThreadOwner: ownerGate !== undefined, - expectedLastRunOrdinal: ownerGate?.expectedLastRunOrdinal ?? null, - rosterLength, - committed: !rejectAsStaleOwner, - }, - ]); - if (event.type === "turn.terminal") { - yield* Ref.update(observed, (current) => [...current, "terminal"]); - } - if (event.type === "provider_thread.updated" && rejectAsStaleOwner) { - yield* Ref.update(observed, (current) => [...current, "stale-owner-rejected"]); - return []; - } - if (event.type === "provider_thread.updated") { - yield* Ref.update(observed, (current) => [...current, "roster-written"]); - } - return []; - }), - }), - ServerSettings.layerTest(), - ), - ), - ); - - const providerThreadBase = { - id: ids.providerThreadId, - driver, - providerInstanceId, - providerSessionId: ProviderSessionId.make(`session:${key}`), - appThreadId: ids.threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "idle" as const, - firstRunOrdinal: 1, - lastRunOrdinal: 1, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - - yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make(`command:${key}`), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, - providerSessionId: ProviderSessionId.make(`session:${key}`), - session: { - events: Stream.empty, - hasPendingBackgroundWork: Effect.succeed(true), - hasPendingBackgroundWorkForThread: () => Effect.succeed(true), - subscribeEvents: Effect.succeed({ - events: Stream.fromIterable([ - { - type: "provider_thread.updated", - driver, - providerThread: { - ...providerThreadBase, - status: "active" as const, - pendingBackgroundTasks: [ - { taskId: "bg-stale", description: "sleep 20", kind: "command" }, - ], - updatedAt: now, - }, - } as ProviderAdapter.ProviderAdapterV2Event, - rootTerminalEvent(ids, "completed"), - { - type: "provider_thread.updated", - driver, - providerThread: { - ...providerThreadBase, - status: "idle" as const, - lastRunOrdinal: 1, - pendingBackgroundTasks: [ - { taskId: "bg-stale", description: "sleep 20", kind: "command" }, - ], - updatedAt: now, - }, - } as ProviderAdapter.ProviderAdapterV2Event, - ]), - close: Deferred.succeed(ingestionDone, undefined), - }), - startTurn: () => Effect.void, - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: ids.runId, - threadId: ids.threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, - checkpointScope: { - id: CheckpointScopeId.make(`checkpoint-scope:${key}`), - } as OrchestrationV2CheckpointScope, - providerThread: providerThreadBase as OrchestrationV2ProviderThread, - attempt: { - id: ids.attemptId, - providerTurnId: ids.rootProviderTurnId, - } as OrchestrationV2RunAttempt, - attemptId: ids.attemptId, - providerTurnOrdinal: 1, - message: { - messageId: MessageId.make(`message:${key}:user`), - text: "Background work that loses ownership.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }); - }).pipe(Effect.provide(layerTest)); - - const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); - assert.isTrue( - Option.isSome(closed), - "subscription must release after ownership-loss reject even when probe stays true", - ); - assert.deepEqual(yield* Ref.get(observed), [ - "roster-written", - "terminal", - "root-finalized", - "stale-owner-rejected", - ]); - - const calls = yield* Ref.get(ingestCalls); - const lateStale = calls.find( - (call) => - call.eventType === "provider_thread.updated" && - call.hasWriteIfProviderThreadOwner && - call.rosterLength === 1, - ); - assert.isDefined(lateStale); - assert.equal(lateStale?.expectedLastRunOrdinal, 1); - assert.equal(lateStale?.activeAttemptId, ids.attemptId); - assert.isFalse(lateStale?.committed); - }), -); - -it.effect( - "keeps ingesting a late background turn-item completion after ownership-loss rejects roster writes", - () => - Effect.gen(function* () { - const key = "bg-item-after-owner-lost"; - const ids = backgroundScenarioIds(key); - const providerInstanceId = ProviderInstanceId.make("codex"); - const now = yield* DateTime.now; - const observed = yield* Ref.make>([]); - const ingestionDone = yield* Deferred.make(); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - captureBaseline: () => Effect.void, - }), - layerTurnItemPositionsTest, - Layer.mock(EventSink.EventSinkV2)({ - write: () => Effect.succeed([]), - writeWithEffects: (input) => - Effect.gen(function* () { - if ( - input.events.some( - (event) => event.type === "run.updated" && event.runId === ids.runId, - ) - ) { - yield* Ref.update(observed, (current) => [...current, "root-finalized"]); - } - return []; - }), - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), - writeIfProviderThreadOwner: () => - Effect.succeed({ committed: false, storedEvents: [] }), - }), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: (input) => - Effect.gen(function* () { - const event = input.event; - if (event.type === "turn.terminal") { - yield* Ref.update(observed, (current) => [...current, "terminal"]); - } - if ( - event.type === "provider_thread.updated" && - input.writeIfProviderThreadOwner !== undefined - ) { - yield* Ref.update(observed, (current) => [...current, "stale-owner-rejected"]); - return []; - } - if (event.type === "turn_item.updated") { - yield* Ref.update(observed, (current) => [ - ...current, - `turn_item:${event.turnItem.status}`, - ]); - } - return []; - }), - }), - ServerSettings.layerTest(), - ), - ), - ); - - const providerThreadBase = { - id: ids.providerThreadId, - driver, - providerInstanceId, - providerSessionId: ProviderSessionId.make(`session:${key}`), - appThreadId: ids.threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "idle" as const, - firstRunOrdinal: 1, - lastRunOrdinal: 1, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - - yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make(`command:${key}`), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, - providerSessionId: ProviderSessionId.make(`session:${key}`), - session: { - events: Stream.empty, - hasPendingBackgroundWork: Effect.succeed(true), - hasPendingBackgroundWorkForThread: () => Effect.succeed(true), - subscribeEvents: Effect.succeed({ - events: Stream.fromIterable([ - backgroundTurnItemEvent(ids, "command_execution", "running", 1), - rootTerminalEvent(ids, "completed"), - { - type: "provider_thread.updated", - driver, - providerThread: { - ...providerThreadBase, - status: "idle" as const, - lastRunOrdinal: 1, - pendingBackgroundTasks: [ - { taskId: "bg-stale", description: "sleep 20", kind: "command" }, - ], - updatedAt: now, - }, - } as ProviderAdapter.ProviderAdapterV2Event, - backgroundTurnItemEvent(ids, "command_execution", "completed", 2), - ]), - close: Deferred.succeed(ingestionDone, undefined), - }), - startTurn: () => Effect.void, - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: ids.runId, - threadId: ids.threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, - checkpointScope: { - id: CheckpointScopeId.make(`checkpoint-scope:${key}`), - } as OrchestrationV2CheckpointScope, - providerThread: providerThreadBase as OrchestrationV2ProviderThread, - attempt: { - id: ids.attemptId, - providerTurnId: ids.rootProviderTurnId, - } as OrchestrationV2RunAttempt, - attemptId: ids.attemptId, - providerTurnOrdinal: 1, - message: { - messageId: MessageId.make(`message:${key}:user`), - text: "Background item after ownership loss.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }); - }).pipe(Effect.provide(layerTest)); - - const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); - assert.isTrue( - Option.isSome(closed), - "subscription must release after background item completes past ownership loss", - ); - assert.deepEqual(yield* Ref.get(observed), [ - "turn_item:running", - "terminal", - "root-finalized", - "stale-owner-rejected", - "turn_item:completed", - ]); - }), -); - -it.effect( - "does not pin ingestion on a sibling session-wide pending state when this thread has no roster", - () => - Effect.gen(function* () { - const key = "bg-roster-sibling-not-pin"; - const ids = backgroundScenarioIds(key); - const providerInstanceId = ProviderInstanceId.make("codex"); - const now = yield* DateTime.now; - const observed = yield* Ref.make>([]); - const ingestionDone = yield* Deferred.make(); - const scopedProbeArgs = yield* Ref.make>([]); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - captureBaseline: () => Effect.void, - }), - layerTurnItemPositionsTest, - Layer.mock(EventSink.EventSinkV2)({ - write: () => Effect.succeed([]), - writeWithEffects: (input) => - Effect.gen(function* () { - if ( - input.events.some( - (event) => event.type === "run.updated" && event.runId === ids.runId, - ) - ) { - yield* Ref.update(observed, (current) => [...current, "root-finalized"]); - } - return []; - }), - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), - }), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: (input) => - Effect.gen(function* () { - if (input.event.type === "turn.terminal") { - yield* Ref.update(observed, (current) => [...current, "terminal"]); - } - return []; - }), - }), - ServerSettings.layerTest(), - ), - ), - ); - - const providerThreadBase = { - id: ids.providerThreadId, - driver, - providerInstanceId, - providerSessionId: ProviderSessionId.make(`session:${key}`), - appThreadId: ids.threadId, - ownerNodeId: null, - nativeThreadRef: { - driver, - nativeId: "native-self", - strength: "strong" as const, - }, - nativeConversationHeadRef: null, - status: "idle" as const, - firstRunOrdinal: 1, - lastRunOrdinal: 1, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - const siblingProviderThreadId = ProviderThreadId.make(`provider-thread:${key}:sibling`); - - yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make(`command:${key}`), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, - providerSessionId: ProviderSessionId.make(`session:${key}`), - session: { - events: Stream.empty, - hasPendingBackgroundWork: Effect.succeed(true), - hasPendingBackgroundWorkForThread: (providerThread: OrchestrationV2ProviderThread) => - Effect.gen(function* () { - yield* Ref.update(scopedProbeArgs, (current) => [...current, providerThread.id]); - return providerThread.id !== ids.providerThreadId; - }), - subscribeEvents: Effect.succeed({ - events: Stream.fromIterable([ - rootTerminalEvent(ids, "completed"), - { - type: "provider_thread.updated", - driver, - providerThread: { - ...providerThreadBase, - id: siblingProviderThreadId, - appThreadId: ThreadId.make(`thread:${key}:sibling`), - nativeThreadRef: { - driver, - nativeId: "native-sibling", - strength: "strong" as const, - }, - pendingBackgroundTasks: [ - { taskId: "sibling-bg", description: "other thread", kind: "command" }, - ], - updatedAt: now, - }, - } as ProviderAdapter.ProviderAdapterV2Event, - ]), - close: Deferred.succeed(ingestionDone, undefined), - }), - startTurn: () => Effect.void, - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: ids.runId, - threadId: ids.threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, - checkpointScope: { - id: CheckpointScopeId.make(`checkpoint-scope:${key}`), - } as OrchestrationV2CheckpointScope, - providerThread: providerThreadBase as OrchestrationV2ProviderThread, - attempt: { - id: ids.attemptId, - providerTurnId: ids.rootProviderTurnId, - } as OrchestrationV2RunAttempt, - attemptId: ids.attemptId, - providerTurnOrdinal: 1, - message: { - messageId: MessageId.make(`message:${key}:user`), - text: "Settle without local pending work.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }); - }).pipe(Effect.provide(layerTest)); - - const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); - assert.isTrue(Option.isSome(closed), "event subscription did not release"); - const observedEvents = [...(yield* Ref.get(observed))]; - assert.includeMembers(observedEvents, ["terminal", "root-finalized"]); - const probedIds = yield* Ref.get(scopedProbeArgs); - assert.isTrue(probedIds.includes(ids.providerThreadId)); - assert.isFalse(probedIds.includes(siblingProviderThreadId)); - }), -); - -it.effect( - "cascade-terminalizes run-owned subagent rows on interrupt before root finalization", - () => - Effect.gen(function* () { - const ids = backgroundScenarioIds("subagent-interrupt-cascade"); - const childThreadId = ids.childThreadId; - const unrelatedChildThreadId = ThreadId.make( - "thread:subagent-interrupt-cascade:unrelated-child", - ); - const providerInstanceId = ProviderInstanceId.make("codex"); - const written = yield* Ref.make>([]); - const ingested = yield* Ref.make>([]); - const ingestionDone = yield* Deferred.make(); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - captureBaseline: () => Effect.void, - }), - layerTurnItemPositionsTest, - Layer.mock(EventSink.EventSinkV2)({ - write: () => Effect.succeed([]), - writeWithEffects: (input) => - Effect.gen(function* () { - yield* Ref.update(written, (current) => [...current, ...input.events]); - return []; - }), - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), - }), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: (input) => - Ref.update(ingested, (current) => [...current, input.event]).pipe(Effect.as([])), - }), - ServerSettings.layerTest(), - ), - ), - ); - - const runningSubagent = makeRunOwnedSubagentFixture({ - ids, - providerInstanceId, - childThreadId, - driver, - status: "running", - }); - const runningTurnItem = makeRunOwnedSubagentTurnItemFixture({ - ids, - providerInstanceId, - childThreadId, - driver, - status: "running", - }); - const runningNode = makeRunOwnedSubagentNodeFixture({ - ids, - status: "running", - }); - const runningChildNode = makeRunOwnedSubagentChildNodeFixture({ - ids, - status: "running", - }); - const memberThreadId = ThreadId.make("thread:subagent-interrupt-cascade:member"); - const runningMember = { - ...runningSubagent, - id: NodeId.make("node:subagent-interrupt-cascade:member"), - threadId: childThreadId, - runId: null, - parentNodeId: runningChildNode.id, - childThreadId: memberThreadId, - }; - const foreignSubagent = { - ...runningMember, - id: NodeId.make("node:subagent-interrupt-cascade:foreign-subagent"), - runId: RunId.make("run:subagent-interrupt-cascade:foreign"), - childThreadId: unrelatedChildThreadId, - }; - const runningMemberRoot = { - ...runningChildNode, - id: NodeId.make("node:subagent-interrupt-cascade:member-root"), - threadId: memberThreadId, - rootNodeId: NodeId.make("node:subagent-interrupt-cascade:member-root"), - }; - const foreignChildNode = { - ...runningChildNode, - id: NodeId.make("node:subagent-interrupt-cascade:foreign-child"), - runId: foreignSubagent.runId, - }; - const runningChildTurnItem = makeLinkedChildTurnItemFixture({ - ids, - driver, - type: "command_execution", - }); - const foreignChildTurnItem = { - ...runningChildTurnItem, - id: TurnItemId.make("turn-item:subagent-interrupt-cascade:foreign-child"), - runId: foreignSubagent.runId, - }; - const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({ - ids: { - ...ids, - childItemId: TurnItemId.make("turn-item:subagent-interrupt-cascade:suppressed-assistant"), - }, - driver, - type: "assistant_message", - }); - const unrelatedChildNode = { - ...runningChildNode, - id: NodeId.make("node:subagent-interrupt-cascade:unrelated-child"), - threadId: unrelatedChildThreadId, - runId: ids.runId, - rootNodeId: NodeId.make("node:subagent-interrupt-cascade:unrelated-child"), - }; - const unrelatedChildTurnItem = { - ...runningChildTurnItem, - id: TurnItemId.make("turn-item:subagent-interrupt-cascade:unrelated-child"), - threadId: unrelatedChildThreadId, - nodeId: unrelatedChildNode.id, - }; - - yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make("command:subagent-interrupt-cascade"), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, - providerSessionId: ProviderSessionId.make("session:subagent-interrupt-cascade"), - session: { - events: Stream.empty, - subscribeEvents: Effect.succeed({ - events: Stream.fromIterable([ - childThreadCreatedEvent(ids), - { - type: "subagent.updated", - driver, - subagent: runningSubagent, - }, - { - type: "node.updated", - driver, - node: runningNode, - }, - { - type: "node.updated", - driver, - node: runningChildNode, - }, - { - type: "node.updated", - driver, - node: unrelatedChildNode, - }, - { - type: "turn_item.updated", - driver, - turnItem: runningTurnItem, - }, - { - type: "turn_item.updated", - driver, - turnItem: runningChildTurnItem, - }, - { - type: "turn_item.updated", - driver, - turnItem: suppressedChildAssistantTurnItem, - }, - { - type: "turn_item.updated", - driver, - turnItem: unrelatedChildTurnItem, - }, - { type: "subagent.updated", driver, subagent: runningMember }, - { type: "subagent.updated", driver, subagent: foreignSubagent }, - { type: "node.updated", driver, node: runningMemberRoot }, - { type: "node.updated", driver, node: foreignChildNode }, - { type: "turn_item.updated", driver, turnItem: foreignChildTurnItem }, - rootTerminalEvent(ids, "interrupted"), - { - type: "subagent.updated", - driver, - subagent: { - ...runningSubagent, - status: "completed" as const, - result: "should-not-apply", - completedAt: runningSubagent.updatedAt, - }, - }, - { - type: "turn_item.updated", - driver, - turnItem: { - ...runningTurnItem, - status: "completed" as const, - result: "should-not-apply", - completedAt: runningTurnItem.updatedAt, - updatedAt: runningTurnItem.updatedAt, - }, - }, - { - type: "turn_item.updated", - driver, - turnItem: { - ...runningChildTurnItem, - status: "completed" as const, - completedAt: runningChildTurnItem.updatedAt, - updatedAt: runningChildTurnItem.updatedAt, - }, - }, - { - type: "node.updated", - driver, - node: { - ...runningChildNode, - status: "completed" as const, - completedAt: runningChildNode.startedAt, - }, - }, - ] satisfies ReadonlyArray), - close: Deferred.succeed(ingestionDone, undefined), - }), - startTurn: () => Effect.void, - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: ids.runId, - threadId: ids.threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, - checkpointScope: { - id: CheckpointScopeId.make("checkpoint-scope:subagent-interrupt-cascade"), - } as OrchestrationV2CheckpointScope, - providerThread: { - id: ids.providerThreadId, - driver, - } as OrchestrationV2ProviderThread, - attempt: { - id: ids.attemptId, - providerTurnId: ids.rootProviderTurnId, - } as OrchestrationV2RunAttempt, - attemptId: ids.attemptId, - relatedThreadIds: [unrelatedChildThreadId, memberThreadId], - providerTurnOrdinal: 1, - message: { - messageId: MessageId.make("message:subagent-interrupt-cascade:user"), - text: "Spawn a subagent then stop.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }); - }).pipe(Effect.provide(layerTest)); - - const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); - assert.isTrue(Option.isSome(closed), "event ingestion fiber did not finish"); - - const events = yield* Ref.get(written); - const subagentEvents = events.flatMap((event) => - event.type === "subagent.updated" ? [event] : [], - ); - const turnItemEvents = events.flatMap((event) => { - if (event.type !== "turn-item.updated" || event.payload.type !== "subagent") { - return []; - } - return [ - { - ...event, - payload: event.payload, - }, - ]; - }); - const nodeEvents = events.flatMap((event) => - event.type === "node.updated" && - event.payload.status === "interrupted" && - (event.payload.id === runningNode.id || event.payload.id === runningChildNode.id) - ? [event] - : [], - ); - const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated"); - assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written"); - - assert.lengthOf(subagentEvents, 2); - const terminalSubagent = subagentEvents.find( - (event) => event.payload.id === runningSubagent.id, - ); - const terminalMember = subagentEvents.find((event) => event.payload.id === runningMember.id); - assert.isDefined(terminalMember); - assert.equal(terminalMember.payload.status, "interrupted"); - assert.equal(terminalMember.payload.runId, null); - assert.equal(terminalMember.payload.childThreadId, memberThreadId); - assert.isTrue( - events.some( - (event) => - event.type === "node.updated" && - event.payload.id === runningMemberRoot.id && - event.payload.status === "interrupted", - ), - ); - assert.isFalse( - events.some( - (event) => event.type === "subagent.updated" && event.payload.id === foreignSubagent.id, - ), - ); - assert.isFalse( - events.some( - (event) => event.type === "node.updated" && event.payload.id === foreignChildNode.id, - ), - ); - assert.isFalse( - events.some( - (event) => - event.type === "turn-item.updated" && event.payload.id === foreignChildTurnItem.id, - ), - ); - assert.isDefined(terminalSubagent); - assert.equal(terminalSubagent.payload.status, "interrupted"); - assert.equal(terminalSubagent.payload.childThreadId, childThreadId); - assert.equal(terminalSubagent.payload.result, null); - assert.isNotNull(terminalSubagent.payload.completedAt); - - assert.lengthOf(turnItemEvents, 1); - const terminalTurnItem = turnItemEvents[0]; - assert.isDefined(terminalTurnItem); - assert.equal(terminalTurnItem.payload.status, "interrupted"); - assert.equal(terminalTurnItem.payload.childThreadId, childThreadId); - assert.equal(terminalTurnItem.payload.result, null); - - const terminalChildTurnItems = events.flatMap((event) => - event.type === "turn-item.updated" && - event.payload.id === runningChildTurnItem.id && - event.payload.status === "interrupted" - ? [event] - : [], - ); - assert.lengthOf(terminalChildTurnItems, 1); - const terminalChildTurnItem = terminalChildTurnItems[0]; - assert.isDefined(terminalChildTurnItem); - assert.equal(terminalChildTurnItem.threadId, childThreadId); - assert.equal(terminalChildTurnItem.payload.threadId, childThreadId); - assert.equal(terminalChildTurnItem.payload.runId, null); - assert.equal(terminalChildTurnItem.payload.type, "command_execution"); - assert.isFalse( - events.some( - (event) => - event.type === "turn-item.updated" && - event.payload.id === unrelatedChildTurnItem.id && - event.payload.status === "interrupted", - ), - "related but unlinked child turn items must not cascade", - ); - assert.isFalse( - events.some( - (event) => - event.type === "turn-item.updated" && - event.payload.id === suppressedChildAssistantTurnItem.id, - ), - "suppressed streaming child items must not be created by the cascade", - ); - - assert.lengthOf(nodeEvents, 2); - assert.isTrue( - nodeEvents.some( - (event) => event.payload.id === runningNode.id && event.payload.kind === "subagent", - ), - ); - assert.isFalse( - events.some( - (event) => - event.type === "node.updated" && - event.payload.id === unrelatedChildNode.id && - event.payload.status === "interrupted", - ), - "owned child threads without a live run-owned subagent link must not cascade", - ); - assert.isTrue( - nodeEvents.some( - (event) => - event.payload.id === runningChildNode.id && - event.runId === (runningChildNode.runId ?? ids.runId) && - event.payload.threadId === childThreadId && - event.payload.kind === "root_turn", - ), - ); - - const cascadeIndexes = events.flatMap((event, index) => { - if (event.type === "subagent.updated" && event.payload.status === "interrupted") { - return [index]; - } - if ( - event.type === "turn-item.updated" && - event.payload.status === "interrupted" && - (event.payload.type === "subagent" || event.payload.id === runningChildTurnItem.id) - ) { - return [index]; - } - if (event.type === "node.updated" && event.payload.status === "interrupted") { - return event.payload.id === runningNode.id || event.payload.id === runningChildNode.id - ? [index] - : []; - } - return []; - }); - assert.isTrue( - cascadeIndexes.every((index) => index < runUpdatedIndex), - "subagent cascade must precede run.updated", - ); - assert.isFalse( - events.some((event) => { - if (event.type === "subagent.updated") { - return event.payload.status === "completed"; - } - if (event.type === "turn-item.updated" && event.payload.type === "subagent") { - return event.payload.status === "completed"; - } - return false; - }), - "late provider completion must not reopen cascaded subagent rows", - ); - assert.isFalse( - (yield* Ref.get(ingested)).some( - (event) => - (event.type === "subagent.updated" && event.subagent.status === "completed") || - (event.type === "turn_item.updated" && event.turnItem.status === "completed") || - (event.type === "node.updated" && event.node.status === "completed"), - ), - "late provider completion must not be ingested after interrupt", - ); - }), -); - -it.effect( - "cascades linked child-thread nodes after run-owned subagent and turn-item terminalize", - () => - Effect.gen(function* () { - const ids = backgroundScenarioIds("subagent-link-survives-terminal"); - const childThreadId = ids.childThreadId; - const unrelatedChildThreadId = ThreadId.make( - "thread:subagent-link-survives-terminal:unrelated-child", - ); - const providerInstanceId = ProviderInstanceId.make("codex"); - const written = yield* Ref.make>([]); - const ingestionDone = yield* Deferred.make(); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - captureBaseline: () => Effect.void, - }), - layerTurnItemPositionsTest, - Layer.mock(EventSink.EventSinkV2)({ - write: () => Effect.succeed([]), - writeWithEffects: (input) => - Effect.gen(function* () { - yield* Ref.update(written, (current) => [...current, ...input.events]); - return []; - }), - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), - }), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: () => Effect.succeed([]), - }), - ServerSettings.layerTest(), - ), - ), - ); - - const runningSubagent = makeRunOwnedSubagentFixture({ - ids, - providerInstanceId, - childThreadId, - driver, - status: "running", - }); - const runningTurnItem = makeRunOwnedSubagentTurnItemFixture({ - ids, - providerInstanceId, - childThreadId, - driver, - status: "running", - }); - const runningNode = makeRunOwnedSubagentNodeFixture({ - ids, - status: "running", - }); - const runningChildNode = makeRunOwnedSubagentChildNodeFixture({ - ids, - status: "running", - }); - const unrelatedChildNode = { - ...runningChildNode, - id: NodeId.make("node:subagent-link-survives-terminal:unrelated-child"), - threadId: unrelatedChildThreadId, - runId: ids.runId, - rootNodeId: NodeId.make("node:subagent-link-survives-terminal:unrelated-child"), - }; - const completedAt = runningSubagent.updatedAt; - - yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make("command:subagent-link-survives-terminal"), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, - providerSessionId: ProviderSessionId.make("session:subagent-link-survives-terminal"), - session: { - events: Stream.empty, - subscribeEvents: Effect.succeed({ - events: Stream.fromIterable([ - childThreadCreatedEvent(ids), - { - type: "subagent.updated", - driver, - subagent: runningSubagent, - }, - { - type: "node.updated", + type: "node.updated", driver, node: runningNode, }, @@ -3467,14 +2063,35 @@ it.effect( driver, turnItem: runningTurnItem, }, + { + type: "turn_item.updated", + driver, + turnItem: runningChildTurnItem, + }, + { + type: "turn_item.updated", + driver, + turnItem: suppressedChildAssistantTurnItem, + }, + { + type: "turn_item.updated", + driver, + turnItem: unrelatedChildTurnItem, + }, + { type: "subagent.updated", driver, subagent: runningMember }, + { type: "subagent.updated", driver, subagent: foreignSubagent }, + { type: "node.updated", driver, node: runningMemberRoot }, + { type: "node.updated", driver, node: foreignChildNode }, + { type: "turn_item.updated", driver, turnItem: foreignChildTurnItem }, + rootTerminalEvent(ids, "interrupted"), { type: "subagent.updated", driver, subagent: { ...runningSubagent, status: "completed" as const, - result: "subagent finished first", - completedAt, + result: "should-not-apply", + completedAt: runningSubagent.updatedAt, }, }, { @@ -3483,21 +2100,30 @@ it.effect( turnItem: { ...runningTurnItem, status: "completed" as const, - result: "turn item finished first", - completedAt, - updatedAt: completedAt, + result: "should-not-apply", + completedAt: runningTurnItem.updatedAt, + updatedAt: runningTurnItem.updatedAt, + }, + }, + { + type: "turn_item.updated", + driver, + turnItem: { + ...runningChildTurnItem, + status: "completed" as const, + completedAt: runningChildTurnItem.updatedAt, + updatedAt: runningChildTurnItem.updatedAt, }, }, { type: "node.updated", driver, node: { - ...runningNode, + ...runningChildNode, status: "completed" as const, - completedAt, + completedAt: runningChildNode.startedAt, }, }, - rootTerminalEvent(ids, "interrupted"), ] satisfies ReadonlyArray), close: Deferred.succeed(ingestionDone, undefined), }), @@ -3511,7 +2137,7 @@ it.effect( } as OrchestrationV2Run, rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, checkpointScope: { - id: CheckpointScopeId.make("checkpoint-scope:subagent-link-survives-terminal"), + id: CheckpointScopeId.make("checkpoint-scope:subagent-interrupt-cascade"), } as OrchestrationV2CheckpointScope, providerThread: { id: ids.providerThreadId, @@ -3522,11 +2148,11 @@ it.effect( providerTurnId: ids.rootProviderTurnId, } as OrchestrationV2RunAttempt, attemptId: ids.attemptId, - relatedThreadIds: [unrelatedChildThreadId], + relatedThreadIds: [unrelatedChildThreadId, memberThreadId], providerTurnOrdinal: 1, message: { - messageId: MessageId.make("message:subagent-link-survives-terminal:user"), - text: "Subagent settles before root interrupt.", + messageId: MessageId.make("message:subagent-interrupt-cascade:user"), + text: "Spawn a subagent then stop.", attachments: [], createdBy: "user", creationSource: "web", @@ -3549,297 +2175,178 @@ it.effect( const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); assert.isTrue(Option.isSome(closed), "event ingestion fiber did not finish"); - const events = yield* Ref.get(written); - const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated"); - assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written"); + const events = yield* Ref.get(written); + const subagentEvents = events.flatMap((event) => + event.type === "subagent.updated" ? [event] : [], + ); + const turnItemEvents = events.flatMap((event) => { + if (event.type !== "turn-item.updated" || event.payload.type !== "subagent") { + return []; + } + return [ + { + ...event, + payload: event.payload, + }, + ]; + }); + const nodeEvents = events.flatMap((event) => + event.type === "node.updated" && + event.payload.status === "interrupted" && + (event.payload.id === runningNode.id || event.payload.id === runningChildNode.id) + ? [event] + : [], + ); + const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated"); + assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written"); + + assert.lengthOf(subagentEvents, 2); + const terminalSubagent = subagentEvents.find( + (event) => event.payload.id === runningSubagent.id, + ); + const terminalMember = subagentEvents.find((event) => event.payload.id === runningMember.id); + assert.isDefined(terminalMember); + assert.equal(terminalMember.payload.status, "interrupted"); + assert.equal(terminalMember.payload.runId, null); + assert.equal(terminalMember.payload.childThreadId, memberThreadId); + assert.isTrue( + events.some( + (event) => + event.type === "node.updated" && + event.payload.id === runningMemberRoot.id && + event.payload.status === "interrupted", + ), + ); + assert.isFalse( + events.some( + (event) => event.type === "subagent.updated" && event.payload.id === foreignSubagent.id, + ), + ); + assert.isFalse( + events.some( + (event) => event.type === "node.updated" && event.payload.id === foreignChildNode.id, + ), + ); + assert.isFalse( + events.some( + (event) => + event.type === "turn-item.updated" && event.payload.id === foreignChildTurnItem.id, + ), + ); + assert.isDefined(terminalSubagent); + assert.equal(terminalSubagent.payload.status, "interrupted"); + assert.equal(terminalSubagent.payload.childThreadId, childThreadId); + assert.equal(terminalSubagent.payload.result, null); + assert.isNotNull(terminalSubagent.payload.completedAt); + + assert.lengthOf(turnItemEvents, 1); + const terminalTurnItem = turnItemEvents[0]; + assert.isDefined(terminalTurnItem); + assert.equal(terminalTurnItem.payload.status, "interrupted"); + assert.equal(terminalTurnItem.payload.childThreadId, childThreadId); + assert.equal(terminalTurnItem.payload.result, null); - const cascadedChildNodeEvents = events.flatMap((event, index) => - event.type === "node.updated" && - event.payload.id === runningChildNode.id && + const terminalChildTurnItems = events.flatMap((event) => + event.type === "turn-item.updated" && + event.payload.id === runningChildTurnItem.id && event.payload.status === "interrupted" - ? [{ event, index }] + ? [event] : [], ); - assert.lengthOf( - cascadedChildNodeEvents, - 1, - "open linked child-thread node must cascade after subagent/turn-item terminalize", - ); - const cascadedChild = cascadedChildNodeEvents[0]; - assert.isDefined(cascadedChild); - assert.isTrue( - cascadedChild.index < runUpdatedIndex, - "child-thread cascade must precede run.updated", - ); - assert.equal(cascadedChild.event.payload.threadId, childThreadId); - assert.equal(cascadedChild.event.payload.kind, "root_turn"); + assert.lengthOf(terminalChildTurnItems, 1); + const terminalChildTurnItem = terminalChildTurnItems[0]; + assert.isDefined(terminalChildTurnItem); + assert.equal(terminalChildTurnItem.threadId, childThreadId); + assert.equal(terminalChildTurnItem.payload.threadId, childThreadId); + assert.equal(terminalChildTurnItem.payload.runId, null); + assert.equal(terminalChildTurnItem.payload.type, "command_execution"); assert.isFalse( events.some( (event) => - event.type === "node.updated" && - event.payload.id === unrelatedChildNode.id && + event.type === "turn-item.updated" && + event.payload.id === unrelatedChildTurnItem.id && event.payload.status === "interrupted", ), - "related but unlinked child threads must not cascade", + "related but unlinked child turn items must not cascade", ); assert.isFalse( events.some( - (event) => event.type === "subagent.updated" && event.payload.status === "interrupted", + (event) => + event.type === "turn-item.updated" && + event.payload.id === suppressedChildAssistantTurnItem.id, + ), + "suppressed streaming child items must not be created by the cascade", + ); + + assert.lengthOf(nodeEvents, 2); + assert.isTrue( + nodeEvents.some( + (event) => event.payload.id === runningNode.id && event.payload.kind === "subagent", ), - "already-terminal subagent rows must not be re-cascaded", ); assert.isFalse( events.some( (event) => - event.type === "turn-item.updated" && - event.payload.type === "subagent" && + event.type === "node.updated" && + event.payload.id === unrelatedChildNode.id && event.payload.status === "interrupted", ), - "already-terminal subagent turn items must not be re-cascaded", + "owned child threads without a live run-owned subagent link must not cascade", + ); + assert.isTrue( + nodeEvents.some( + (event) => + event.payload.id === runningChildNode.id && + event.runId === (runningChildNode.runId ?? ids.runId) && + event.payload.threadId === childThreadId && + event.payload.kind === "root_turn", + ), ); - }), -); - -it.effect("cascade helper is provider-neutral for Claude and Codex-shaped child projections", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - let nextId = 0; - const allocateEventId = () => - Effect.sync(() => EventId.make(`event:cascade-helper:${nextId++}`)); - - for (const driverKind of [ - ProviderDriverKind.make("claudeAgent"), - ProviderDriverKind.make("codex"), - ] as const) { - const runId = RunId.make(`run:cascade-helper:${driverKind}`); - const threadId = ThreadId.make(`thread:cascade-helper:${driverKind}`); - const childThreadId = ThreadId.make(`thread:cascade-helper:${driverKind}:child`); - const subagentId = NodeId.make(`node:cascade-helper:${driverKind}:subagent`); - const childNodeId = NodeId.make(`node:cascade-helper:${driverKind}:child-root`); - const providerInstanceId = ProviderInstanceId.make(String(driverKind)); - const terminalStatus = driverKind === "claudeAgent" ? "failed" : "cancelled"; - const subagent: OrchestrationV2Subagent = { - id: subagentId, - threadId, - runId, - parentNodeId: NodeId.make(`node:cascade-helper:${driverKind}:root`), - origin: "provider_native", - createdBy: "agent", - driver: driverKind, - providerInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: { - driver: driverKind, - nativeId: `native-${driverKind}`, - strength: "strong", - }, - prompt: "hold", - title: "hold", - model: null, - status: "running", - progress: "partial progress", - result: "partial result", - startedAt: now, - completedAt: null, - updatedAt: now, - }; - const turnItem = { - id: TurnItemId.make(`turn-item:cascade-helper:${driverKind}`), - threadId, - runId, - nodeId: subagentId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: subagent.nativeTaskRef, - parentItemId: null, - ordinal: 3, - status: "running" as const, - title: "hold", - startedAt: now, - completedAt: null, - updatedAt: now, - type: "subagent" as const, - subagentId, - origin: "provider_native" as const, - driver: driverKind, - providerInstanceId, - childThreadId, - prompt: "hold", - progress: "partial progress", - result: "partial result", - } satisfies Extract; - const node: OrchestrationV2ExecutionNode = { - id: subagentId, - threadId, - runId, - parentNodeId: NodeId.make(`node:cascade-helper:${driverKind}:root`), - rootNodeId: NodeId.make(`node:cascade-helper:${driverKind}:root`), - kind: "subagent", - status: "running", - countsForRun: false, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: subagent.nativeTaskRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }; - const openChildNode: OrchestrationV2ExecutionNode = { - id: childNodeId, - threadId: childThreadId, - runId: null, - parentNodeId: null, - rootNodeId: childNodeId, - kind: "root_turn", - status: "running", - countsForRun: false, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }; - const childTurnItem: OrchestrationV2TurnItem = - driverKind === "claudeAgent" - ? { - id: TurnItemId.make(`turn-item:cascade-helper:${driverKind}:child-reasoning`), - threadId: childThreadId, - runId: null, - nodeId: childNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "running", - title: "Working", - startedAt: now, - completedAt: null, - updatedAt: now, - type: "reasoning", - text: "partial progress", - streaming: true, - } - : { - id: TurnItemId.make(`turn-item:cascade-helper:${driverKind}:child-command`), - threadId: childThreadId, - runId: null, - nodeId: childNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "running", - title: "sleep 300", - startedAt: now, - completedAt: null, - updatedAt: now, - type: "command_execution", - input: "sleep 300", - }; - const events = yield* RunExecutionService.cascadeTerminalizeRunOwnedSubagents({ - run: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - open: { - subagents: new Map([[subagentId, subagent]]), - turnItems: new Map([[subagentId, turnItem]]), - childTurnItems: new Map(), - nodes: new Map([[subagentId, node]]), - linkedChildThreadIds: new Set([childThreadId]), - }, - status: terminalStatus, - completedAt: now, - allocateEventId, + const cascadeIndexes = events.flatMap((event, index) => { + if (event.type === "subagent.updated" && event.payload.status === "interrupted") { + return [index]; + } + if ( + event.type === "turn-item.updated" && + event.payload.status === "interrupted" && + (event.payload.type === "subagent" || event.payload.id === runningChildTurnItem.id) + ) { + return [index]; + } + if (event.type === "node.updated" && event.payload.status === "interrupted") { + return event.payload.id === runningNode.id || event.payload.id === runningChildNode.id + ? [index] + : []; + } + return []; }); - - assert.equal(events.length, 3, `${driverKind}: subagent + node + turn item`); - const terminalSubagent = events.find((event) => event.type === "subagent.updated"); - assert.isDefined(terminalSubagent); - if (terminalSubagent?.type !== "subagent.updated") { - assert.fail("expected subagent.updated event"); - return; - } - assert.equal(terminalSubagent.payload.status, terminalStatus); - assert.equal(terminalSubagent.payload.childThreadId, childThreadId); - assert.equal(terminalSubagent.payload.progress, "partial progress"); - assert.equal(terminalSubagent.payload.result, "partial result"); - assert.equal(terminalSubagent.payload.driver, driverKind); - - const terminalItem = events.find( - (event) => event.type === "turn-item.updated" && event.payload.type === "subagent", + assert.isTrue( + cascadeIndexes.every((index) => index < runUpdatedIndex), + "subagent cascade must precede run.updated", ); - assert.isDefined(terminalItem); - if (terminalItem?.type !== "turn-item.updated" || terminalItem.payload.type !== "subagent") { - assert.fail("expected subagent turn-item.updated event"); - return; - } - assert.equal(terminalItem.payload.status, terminalStatus); - assert.equal(terminalItem.payload.childThreadId, childThreadId); - assert.equal(terminalItem.payload.progress, "partial progress"); - assert.equal(terminalItem.payload.result, "partial result"); - - const afterTerminalLinkEvents = - yield* RunExecutionService.cascadeTerminalizeRunOwnedSubagents({ - run: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - open: { - subagents: new Map(), - turnItems: new Map(), - childTurnItems: new Map([[childTurnItem.id, childTurnItem]]), - nodes: new Map([[childNodeId, openChildNode]]), - linkedChildThreadIds: new Set([childThreadId]), - }, - status: terminalStatus, - completedAt: now, - allocateEventId, - }); - assert.equal( - afterTerminalLinkEvents.length, - 2, - `${driverKind}: linked child node and turn item cascade after link rows terminalize`, + assert.isFalse( + events.some((event) => { + if (event.type === "subagent.updated") { + return event.payload.status === "completed"; + } + if (event.type === "turn-item.updated" && event.payload.type === "subagent") { + return event.payload.status === "completed"; + } + return false; + }), + "late provider completion must not reopen cascaded subagent rows", ); - const cascadedChild = afterTerminalLinkEvents.find((event) => event.type === "node.updated"); - assert.isDefined(cascadedChild); - if (cascadedChild?.type !== "node.updated") { - assert.fail("expected node.updated for linked child thread"); - return; - } - assert.equal(cascadedChild.payload.id, childNodeId); - assert.equal(cascadedChild.payload.threadId, childThreadId); - assert.equal(cascadedChild.payload.status, terminalStatus); - assert.equal(cascadedChild.payload.kind, "root_turn"); - const cascadedChildTurnItem = afterTerminalLinkEvents.find( - (event) => event.type === "turn-item.updated", + assert.isFalse( + (yield* Ref.get(ingested)).some( + (event) => + (event.type === "subagent.updated" && event.subagent.status === "completed") || + (event.type === "turn_item.updated" && event.turnItem.status === "completed") || + (event.type === "node.updated" && event.node.status === "completed"), + ), + "late provider completion must not be ingested after interrupt", ); - assert.isDefined(cascadedChildTurnItem); - if (cascadedChildTurnItem?.type !== "turn-item.updated") { - assert.fail("expected turn-item.updated for linked child thread"); - return; - } - assert.equal(cascadedChildTurnItem.threadId, childThreadId); - assert.equal(cascadedChildTurnItem.payload.id, childTurnItem.id); - assert.equal(cascadedChildTurnItem.payload.status, terminalStatus); - assert.equal(cascadedChildTurnItem.payload.runId, null); - assert.equal(cascadedChildTurnItem.payload.type, childTurnItem.type); - if (cascadedChildTurnItem.payload.type === "reasoning") { - assert.isFalse(cascadedChildTurnItem.payload.streaming); - } - } - }), + }), ); it.effect("omits interrupt results and subagent cascade for a superseded attempt", () => @@ -3881,21 +2388,6 @@ it.effect("emits run_interrupt_result when superseded attempt still has a hard-s }), ); -it.effect("omits run_interrupt_result when superseded attempt request is already paired", () => - Effect.gen(function* () { - const { written, observed } = yield* captureRootRunTermination({ - key: "stop-then-steer-already-paired", - shouldFinalizeRun: () => Effect.succeed(false), - hasUnpairedRunInterruptRequest: () => Effect.succeed(false), - }); - assert.deepEqual( - written.map((item) => item.type), - [], - ); - assert.deepEqual(observed, []); - }), -); - it.effect("does not overwrite Stop when ownership changes after the finalization read", () => Effect.gen(function* () { const { written, observed, submittedEffects, committedEffects } = @@ -3932,22 +2424,6 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp }), ); -it.effect.each(["completed", "interrupted", "cancelled", "failed"] as const)( - "refreshes pull requests after the current root run %s", - (status) => - Effect.gen(function* () { - const { observed } = yield* captureRootRunTermination({ - key: `pull-request-refresh:${status}`, - shouldFinalizeRun: () => Effect.succeed(true), - events: (ids) => Stream.make(rootTerminalEvent(ids, status)), - }); - assert.deepEqual(observed, [ - `run:${status === "completed" ? "waiting" : status}`, - "pull-requests-refreshed", - ]); - }), -); - it.effect("records a finished run as failed when its ownership check cannot be read", () => Effect.gen(function* () { const { observed } = yield* captureRootRunTermination({ @@ -4063,36 +2539,6 @@ it.effect("does not start a turn whose run settled during the app context read", }), ); -it.effect("refreshes pull requests only once when startup failure closes its event stream", () => - Effect.gen(function* () { - const ingestionStarted = yield* Deferred.make(); - const { observed, written } = yield* captureRootRunTermination({ - key: "pull-request-refresh:startup-error", - shouldFinalizeRun: () => Effect.succeed(true), - events: () => - Stream.unwrap(Deferred.succeed(ingestionStarted, undefined).pipe(Effect.as(Stream.never))), - startTurn: (input) => - Deferred.await(ingestionStarted).pipe( - Effect.andThen( - Effect.fail( - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver, - threadId: input.threadId, - providerThreadId: input.providerThread.id, - runId: input.runId, - cause: "provider rejected the turn", - }), - ), - ), - ), - }); - assert.equal(observed.filter((item) => item === "pull-requests-refreshed").length, 1); - assert.equal(observed[0], "run:failed"); - const error = written.find((item) => item.type === "error"); - assert.include(error?.failure.message ?? "", "provider could not start this turn"); - }), -); - it.effect("keeps completed runs completed when pull request refresh fails", () => Effect.gen(function* () { const { observed } = yield* captureRootRunTermination({ @@ -4105,230 +2551,6 @@ it.effect("keeps completed runs completed when pull request refresh fails", () = }), ); -it.effect( - "recovers ingestion failure through typed errors and defects with bounded backoff", - () => { - const warnings: Array = []; - const logger = Logger.make(({ message }) => { - const text = Array.isArray(message) ? message[0] : message; - if (text === "orchestration V2 run failure finalization retry") warnings.push(text); - }); - return Effect.gen(function* () { - const failIngestion = yield* Deferred.make(); - const failedWrite = yield* Deferred.make(); - const writeTimes: Array = []; - let storageAvailable = false; - let failedAt = yield* DateTime.now; - const { finalEvents, observed, written } = yield* captureRootRunTermination({ - key: "recovery-backoff", - shouldFinalizeRun: () => Effect.succeed(true), - ingestNormalized: () => - Deferred.await(failIngestion).pipe(Effect.andThen(Effect.die("ingestion failed"))), - beforeTerminalWrite: () => - Effect.gen(function* () { - writeTimes.push(DateTime.toEpochMillis(yield* DateTime.now)); - if (storageAvailable) return true; - yield* Deferred.succeed(failedWrite, undefined); - const error = new EventSink.EventSinkWriteError({ eventCount: 5 }); - return yield* writeTimes.length % 2 === 1 ? Effect.fail(error) : Effect.die(error); - }), - afterStart: () => - Effect.gen(function* () { - // Separate startup, ingestion failure, and eventual storage recovery. - yield* TestClock.adjust("5 seconds"); - failedAt = yield* DateTime.now; - yield* Deferred.succeed(failIngestion, undefined); - yield* Deferred.await(failedWrite); - yield* TestClock.adjust("999 millis"); - assert.lengthOf(writeTimes, 1); - yield* TestClock.adjust("1 millis"); - assert.lengthOf(writeTimes, 2); - assert.lengthOf(warnings, 1); - yield* TestClock.adjust("598999 millis"); - const offsets = writeTimes.map((time) => time - DateTime.toEpochMillis(failedAt)); - assert.deepEqual(offsets, [ - 0, - 1000, - 3000, - 7000, - 15000, - 31000, - ...Array.from({ length: 18 }, (_, index) => 61000 + index * 30000), - ]); - assert.lengthOf(warnings, 1); - yield* TestClock.adjust("1001 millis"); - assert.lengthOf(warnings, 2); - storageAvailable = true; - yield* TestClock.adjust("30 seconds"); - }), - }); - assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); - assert.lengthOf(warnings, 2); - assert.sameMembers( - finalEvents.map((event) => event.type), - [ - "run-attempt.updated", - "turn-item.updated", - "run.updated", - "node.updated", - "provider-thread.updated", - ], - ); - for (const event of finalEvents) { - assert.deepEqual(event.occurredAt, failedAt); - if ( - event.type === "run.updated" || - event.type === "run-attempt.updated" || - event.type === "node.updated" - ) { - assert.equal(event.payload.status, "failed"); - assert.deepEqual(event.payload.completedAt, failedAt); - } - if (event.type === "provider-thread.updated") - assert.deepEqual(event.payload.updatedAt, failedAt); - } - assert.lengthOf(written, 1); - assert.equal(written[0]?.type, "error"); - assert.deepEqual(written[0]?.completedAt, failedAt); - if (written[0]?.type === "error") - assert.equal(written[0].failure.message, "Provider turn failed."); - }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); - }, -); - -it.effect("stops recovery when the atomic gate rejects a superseded attempt", () => - Effect.gen(function* () { - const key = "recovery-successor"; - const ids = backgroundScenarioIds(key); - const failedWrite = yield* Deferred.make(); - let activeAttemptId = ids.attemptId; - let writes = 0; - const { finalEvents, observed, written } = yield* captureRootRunTermination({ - key, - shouldFinalizeRun: () => Effect.succeed(true), - ingestNormalized: () => Effect.die("ingestion failed"), - beforeTerminalWrite: (request) => - Effect.gen(function* () { - writes++; - assert.equal(request.runId, ids.runId); - assert.equal(request.activeAttemptId, ids.attemptId); - assert.equal(request.expectedStatus, "running"); - if (writes > 1) return request.activeAttemptId === activeAttemptId; - yield* Deferred.succeed(failedWrite, undefined); - return yield* new EventSink.EventSinkWriteError({ eventCount: 5 }); - }), - afterStart: () => - Effect.gen(function* () { - yield* Deferred.await(failedWrite); - activeAttemptId = RunAttemptId.make("attempt:successor"); - yield* TestClock.adjust("1 second"); - assert.equal(writes, 2); - yield* TestClock.adjust("10 minutes"); - assert.equal(writes, 2); - }), - }); - assert.deepEqual(finalEvents, []); - assert.deepEqual(written, []); - assert.deepEqual(observed, []); - }), -); - -it.effect.each([true, false])( - "keeps recovery alive after startup cancels its consumer (startup terminal persisted: %s)", - (startupPersisted) => - Effect.gen(function* () { - const failedWrite = yield* Deferred.make(); - let writes = 0; - let status: OrchestrationV2Run["status"] = "running"; - const { observed, finalEvents, written } = yield* captureRootRunTermination({ - key: `recovery-startup:${startupPersisted}`, - shouldFinalizeRun: () => Effect.succeed(true), - ingestNormalized: () => Effect.die("ingestion failed"), - beforeTerminalWrite: (request) => - Effect.gen(function* () { - writes++; - if (writes === 1 || (writes === 2 && !startupPersisted)) { - yield* Deferred.succeed(failedWrite, undefined); - return yield* new EventSink.EventSinkWriteError({ eventCount: 5 }); - } - if (request.expectedStatus !== status) return false; - status = "failed"; - return true; - }), - startTurn: (input) => - Deferred.await(failedWrite).pipe( - Effect.andThen( - Effect.fail( - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver, - threadId: input.threadId, - providerThreadId: input.providerThread.id, - runId: input.runId, - cause: "provider rejected startup", - }), - ), - ), - ), - onStartFailure: (error) => - Effect.sync(() => { - assert.isFalse(startupPersisted); - assert.instanceOf(error, RunExecutionService.RunExecutionStartError); - }), - afterStart: (consumerClosed) => - Effect.gen(function* () { - yield* Deferred.await(consumerClosed); - assert.equal(writes, 2); - yield* TestClock.adjust("1 second"); - assert.equal(writes, 3); - yield* TestClock.adjust("10 minutes"); - assert.equal(writes, 3); - }), - }); - assert.deepEqual(observed, ["run:failed", "pull-requests-refreshed"]); - assert.lengthOf( - finalEvents.filter( - (event) => event.type === "run-attempt.updated" && event.payload.status === "failed", - ), - 1, - ); - assert.lengthOf(written, 1); - assert.equal(written[0]?.type, "error"); - if (written[0]?.type === "error") - assert.include( - written[0].failure.message, - startupPersisted ? "provider could not start this turn" : "Provider turn failed.", - ); - }), -); - -it.effect("stops pending recovery at service shutdown", () => - Effect.gen(function* () { - const failedWrite = yield* Deferred.make(); - let writes = 0; - const exit = yield* captureRootRunTermination({ - key: "recovery-shutdown", - shouldFinalizeRun: () => Effect.succeed(true), - ingestNormalized: () => Effect.die("ingestion failed"), - beforeTerminalWrite: () => - Effect.gen(function* () { - writes++; - yield* Deferred.succeed(failedWrite, undefined); - return yield* new EventSink.EventSinkWriteError({ eventCount: 5 }); - }), - afterStart: () => - Effect.gen(function* () { - yield* Deferred.await(failedWrite); - yield* TestClock.adjust("1 second"); - assert.equal(writes, 2); - return yield* Effect.interrupt; - }), - }).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); - yield* TestClock.adjust("10 minutes"); - assert.equal(writes, 2); - }), -); - function captureRootRunTermination(input: { readonly key: string; readonly shouldFinalizeRun: () => Effect.Effect; @@ -5059,26 +3281,3 @@ it.effect("keeps a failed root's still-running native children live while the ru ); }), ); - -it.effect("releases ingestion after idle subagent rows and items settle", () => - Effect.gen(function* () { - const observed = yield* runBackgroundItemScenario( - "subagent-idle", - (ids) => [ - subagentEvent(ids, "running"), - backgroundTurnItemEvent(ids, "subagent", "running", 1), - subagentEvent(ids, "idle"), - backgroundTurnItemEvent(ids, "subagent", "idle", 2), - rootTerminalEvent(ids, "completed"), - ], - { keepEventStreamOpen: true }, - ); - assert.deepEqual(observed, [ - "subagent:running", - "turn_item:running", - "subagent:idle", - "turn_item:idle", - "root-finalized", - ]); - }), -); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e27106d20202..022b67d9cf48 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -318,7 +318,7 @@ export const cascadeTerminalizeRunOwnedSubagents = Effect.fn( return events; }); -export function finalProviderThreadStatus( +function finalProviderThreadStatus( disposition: ProviderTerminalEvent["threadDisposition"], ): OrchestrationV2ProviderThread["status"] { return disposition === "broken" ? "error" : "idle"; diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 052ef6b07e2d..38a18192531a 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -1,4 +1,4 @@ -import { assert, it, vi } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import { CheckpointScopeId, RunId, @@ -15,41 +15,6 @@ import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as RunFinalization from "./RunFinalizationService.ts"; -it.effect("refreshes workspace after checkpoint capture without reading history", () => { - const threadId = ThreadId.make("thread_finalize"); - const runId = RunId.make("run_finalize"); - const scopeId = CheckpointScopeId.make("scope_finalize"); - const capture = vi.fn(() => Effect.void); - const refresh = vi.fn(() => Effect.void); - const checkpointContext = { - runs: [], - checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], - checkpoints: [], - }; - const layer = RunFinalization.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: capture }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThreadProjection: () => - Effect.die("workspace refresh must not load transcript history"), - getCheckpointContext: () => Effect.succeed(checkpointContext), - }), - Layer.succeed(RunFinalization.RunFinalizationObserver, { - refresh, - refreshAfterTurn: () => Effect.void, - }), - ), - ), - ); - return Effect.gen(function* () { - const service = yield* RunFinalization.RunFinalizationService; - yield* service.finalize({ threadId, runId, scopeId }); - assert.equal(capture.mock.calls.length, 1); - assert.deepEqual(refresh.mock.calls[0], [{ cwd: "/repo", threadId, runId }]); - }).pipe(Effect.provide(layer)); -}); - it.effect("completes finalization when the post-checkpoint workspace refresh fails", () => { const threadId = ThreadId.make("thread_refresh_failure"); const runId = RunId.make("run_refresh_failure"); @@ -93,74 +58,6 @@ it.effect("completes finalization when the post-checkpoint workspace refresh fai }).pipe(Effect.provide(layer)); }); -it.effect("preserves checkpoint failures without refreshing the workspace", () => { - const threadId = ThreadId.make("thread_capture_failure"); - const runId = RunId.make("run_capture_failure"); - const scopeId = CheckpointScopeId.make("scope_capture_failure"); - const captureError = new CheckpointCapture.CheckpointCaptureExecutionError({ - threadId, - runId, - scopeId, - cause: new Error("Checkpoint capture failed"), - }); - const refresh = vi.fn(() => Effect.void); - const layer = RunFinalization.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ - execute: () => Effect.fail(captureError), - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getCheckpointContext: () => Effect.die("capture failed before context lookup"), - }), - Layer.succeed(RunFinalization.RunFinalizationObserver, { - refresh, - refreshAfterTurn: () => Effect.void, - }), - ), - ), - ); - return Effect.gen(function* () { - const service = yield* RunFinalization.RunFinalizationService; - const error = yield* service.finalize({ threadId, runId, scopeId }).pipe(Effect.flip); - assert.equal(error.operation, "capture-checkpoint"); - assert.strictEqual(error.cause, captureError); - assert.equal(refresh.mock.calls.length, 0); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves checkpoint context read failures without refreshing the workspace", () => { - const threadId = ThreadId.make("thread_context_failure"); - const runId = RunId.make("run_context_failure"); - const scopeId = CheckpointScopeId.make("scope_context_failure"); - const contextError = new ProjectionStore.ProjectionStoreReadError({ - threadId, - cause: new Error("Checkpoint context read failed"), - }); - const refresh = vi.fn(() => Effect.void); - const layer = RunFinalization.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.void }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getCheckpointContext: () => Effect.fail(contextError), - }), - Layer.succeed(RunFinalization.RunFinalizationObserver, { - refresh, - refreshAfterTurn: () => Effect.void, - }), - ), - ), - ); - return Effect.gen(function* () { - const service = yield* RunFinalization.RunFinalizationService; - const error = yield* service.finalize({ threadId, runId, scopeId }).pipe(Effect.flip); - assert.equal(error.operation, "refresh-workspace"); - assert.strictEqual(error.cause, contextError); - assert.equal(refresh.mock.calls.length, 0); - }).pipe(Effect.provide(layer)); -}); - it.effect.each( ( [ diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 1a3fed65ddc4..b6136de68c2e 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -108,30 +108,6 @@ const layerTest = RuntimePolicy.layerFromProjectStore.pipe( ); it.layer(layerTest)("RuntimePolicyV2", (it) => { - it.effect("uses the project root for local-checkout threads", () => - Effect.gen(function* () { - const policy = yield* RuntimePolicy.RuntimePolicyV2; - const now = yield* DateTime.now; - const resolved = yield* policy.resolve({ - thread: makeThread({ now, worktreePath: null }), - modelSelection, - }); - assert.equal(resolved.cwd, "/project-root"); - }), - ); - - it.effect("prefers a provisioned worktree over the project root", () => - Effect.gen(function* () { - const policy = yield* RuntimePolicy.RuntimePolicyV2; - const now = yield* DateTime.now; - const resolved = yield* policy.resolve({ - thread: makeThread({ now, worktreePath: "/project-worktree" }), - modelSelection, - }); - assert.equal(resolved.cwd, "/project-worktree"); - }), - ); - it.effect("runs a mode the provider does not offer in Supervised", () => Effect.gen(function* () { const policy = yield* RuntimePolicy.RuntimePolicyV2; diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts index 11dedc6ab474..70ddb5e84ef4 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts @@ -4,7 +4,6 @@ import { ProviderSessionId, RuntimeRequestId, ThreadId, - type OrchestrationV2RuntimeRequest, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -17,117 +16,6 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; -function projectionWithRuntimeRequest( - runtimeRequest?: OrchestrationV2RuntimeRequest, -): OrchestrationV2ThreadProjection { - return { - runtimeRequests: runtimeRequest === undefined ? [] : [runtimeRequest], - } as unknown as OrchestrationV2ThreadProjection; -} - -function resolvedRuntimeRequest( - requestId: RuntimeRequestId, - providerSessionId: ProviderSessionId, -): OrchestrationV2RuntimeRequest { - return { - id: requestId, - nodeId: NodeId.make(`node-${requestId}`), - providerTurnId: null, - nativeRequestRef: null, - kind: "command", - status: "resolved", - responseCapability: { - type: "live", - providerSessionId, - }, - createdAt: DateTime.makeUnsafe("2026-07-29T00:00:00.000Z"), - resolvedAt: DateTime.makeUnsafe("2026-07-29T00:00:01.000Z"), - }; -} - -function layerRuntimeRequestTest( - projection: OrchestrationV2ThreadProjection, - getSession: ProviderSessionManager.ProviderSessionManagerV2Shape["get"], -) { - return RuntimeRequestService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRuntimeRequest: (_threadId, requestId) => - Effect.succeed(projection.runtimeRequests.find((request) => request.id === requestId)), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - get: getSession, - }), - ), - ), - ); -} - -it.effect("forwards orchestrator-resolved runtime requests to the live adapter", () => { - const threadId = ThreadId.make("thread-runtime-request-resolved"); - const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-resolved"); - const requestId = RuntimeRequestId.make("request-resolved"); - const respondToRuntimeRequest = vi.fn( - (_input: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, - ); - const getSession = vi.fn(() => - Effect.succeed( - Option.some({ - respondToRuntimeRequest, - } as never), - ), - ); - const projection = { - runtimeRequests: [ - { - id: requestId, - nodeId: NodeId.make("node-runtime-request-resolved"), - providerTurnId: null, - nativeRequestRef: null, - kind: "command", - status: "resolved", - responseCapability: { - type: "live", - providerSessionId, - }, - createdAt: DateTime.makeUnsafe("2026-07-29T00:00:00.000Z"), - resolvedAt: DateTime.makeUnsafe("2026-07-29T00:00:01.000Z"), - }, - ], - } as unknown as OrchestrationV2ThreadProjection; - const layerTest = RuntimeRequestService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRuntimeRequest: (_threadId, requestId) => - Effect.succeed(projection.runtimeRequests.find((request) => request.id === requestId)), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - get: getSession, - }), - ), - ), - ); - - return Effect.gen(function* () { - const service = yield* RuntimeRequestService.RuntimeRequestServiceV2; - yield* service.respond({ - threadId, - providerSessionId, - requestId, - decision: "accept", - }); - - assert.equal(getSession.mock.calls.length, 1); - assert.equal(respondToRuntimeRequest.mock.calls.length, 1); - assert.deepEqual(respondToRuntimeRequest.mock.calls[0]?.[0], { - requestId, - decision: "accept", - }); - }).pipe(Effect.provide(layerTest)); -}); - it.effect("rejects expired runtime requests before invoking the live adapter", () => { const threadId = ThreadId.make("thread-runtime-request-expired"); const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-expired"); @@ -195,99 +83,3 @@ it.effect("rejects expired runtime requests before invoking the live adapter", ( assert.equal(respondToRuntimeRequest.mock.calls.length, 0); }).pipe(Effect.provide(layerTest)); }); - -it.effect("classifies runtime request response validation failures", () => { - const threadId = ThreadId.make("thread-runtime-request-validation"); - const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-validation"); - const otherProviderSessionId = ProviderSessionId.make( - "provider-session-runtime-request-validation-other", - ); - const requestId = RuntimeRequestId.make("request-validation"); - const unexpectedGetSession = vi.fn(() => Effect.succeed(Option.none())); - const layerMissing = layerRuntimeRequestTest( - projectionWithRuntimeRequest(), - unexpectedGetSession, - ); - const layerNotResumable = layerRuntimeRequestTest( - projectionWithRuntimeRequest(resolvedRuntimeRequest(requestId, otherProviderSessionId)), - unexpectedGetSession, - ); - const layerInactive = layerRuntimeRequestTest( - projectionWithRuntimeRequest(resolvedRuntimeRequest(requestId, providerSessionId)), - () => Effect.succeed(Option.none()), - ); - const respond = Effect.gen(function* () { - const service = yield* RuntimeRequestService.RuntimeRequestServiceV2; - return yield* service - .respond({ - threadId, - providerSessionId, - requestId, - decision: "accept", - }) - .pipe(Effect.flip); - }); - - return Effect.gen(function* () { - const missing = yield* respond.pipe(Effect.provide(layerMissing)); - assert.equal(missing.reason, "request-missing"); - assert.equal( - missing.message, - `Runtime request ${requestId} no longer exists on thread ${threadId}.`, - ); - assert.isUndefined(missing.cause); - - const notResumable = yield* respond.pipe(Effect.provide(layerNotResumable)); - assert.equal(notResumable.reason, "request-not-resumable"); - assert.equal( - notResumable.message, - `Runtime request ${requestId} on thread ${threadId} is not resumable on provider session ${providerSessionId}.`, - ); - assert.isUndefined(notResumable.cause); - - const inactive = yield* respond.pipe(Effect.provide(layerInactive)); - assert.equal(inactive.reason, "provider-session-not-active"); - assert.equal( - inactive.message, - `Provider session ${providerSessionId} is not active for runtime request ${requestId} on thread ${threadId}.`, - ); - assert.isUndefined(inactive.cause); - assert.equal(unexpectedGetSession.mock.calls.length, 0); - }); -}); - -it.effect("preserves genuine provider session lookup failures as the cause", () => { - const threadId = ThreadId.make("thread-runtime-request-lookup-failure"); - const providerSessionId = ProviderSessionId.make( - "provider-session-runtime-request-lookup-failure", - ); - const requestId = RuntimeRequestId.make("request-lookup-failure"); - const lookupFailure = new ProviderSessionManager.ProviderSessionLookupError({ - providerSessionId, - cause: "lookup failed", - }); - const layerTest = layerRuntimeRequestTest( - projectionWithRuntimeRequest(resolvedRuntimeRequest(requestId, providerSessionId)), - () => Effect.fail(lookupFailure), - ); - - return Effect.gen(function* () { - const service = yield* RuntimeRequestService.RuntimeRequestServiceV2; - const error = yield* service - .respond({ - threadId, - providerSessionId, - requestId, - decision: "accept", - }) - .pipe(Effect.flip); - - assert.instanceOf(error, RuntimeRequestService.RuntimeRequestResponseExecutionError); - assert.equal(error.reason, "unexpected-failure"); - assert.equal( - error.message, - `Failed to respond to runtime request ${requestId} on thread ${threadId} via provider session ${providerSessionId}.`, - ); - assert.strictEqual(error.cause, lookupFailure); - }).pipe(Effect.provide(layerTest)); -}); diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 1ea4f2407c00..03cdf5e85a64 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -47,12 +47,6 @@ const seedSelection = { instanceId: providerInstanceId, model: "seed-model", } satisfies ModelSelection; -const handoffDriver = ProviderDriverKind.make("claudeAgent"); -const handoffProviderInstanceId = ProviderInstanceId.make("claude-handoff-test"); -const handoffSelection = { - instanceId: handoffProviderInstanceId, - model: "handoff-model", -} satisfies ModelSelection; const pooledCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const exclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexProviderCapabilitiesV2, @@ -285,109 +279,6 @@ function makeRestartAdapter( }; } -function makeCompletingHandoffAdapter( - startCount: Ref.Ref, -): ProviderAdapter.ProviderAdapterV2["Service"] { - return { - instanceId: handoffProviderInstanceId, - driver: handoffDriver, - getCapabilities: () => Effect.succeed(exclusiveCapabilities), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: (sessionInput) => - Effect.gen(function* () { - const events = yield* Queue.unbounded(); - const now = yield* DateTime.now; - return { - instanceId: handoffProviderInstanceId, - driver: handoffDriver, - providerSessionId: sessionInput.providerSessionId, - providerSession: { - id: sessionInput.providerSessionId, - driver: handoffDriver, - providerInstanceId: handoffProviderInstanceId, - status: "ready", - cwd: sessionInput.runtimePolicy.cwd ?? "/fallback", - model: sessionInput.modelSelection.model, - capabilities: exclusiveCapabilities, - createdAt: now, - updatedAt: now, - lastError: null, - }, - events: Stream.fromQueue(events), - ensureThread: (threadInput) => - Effect.gen(function* () { - const createdAt = yield* DateTime.now; - return { - id: ProviderThreadId.make(`provider-thread:handoff:${threadInput.threadId}`), - driver: handoffDriver, - providerInstanceId: handoffProviderInstanceId, - providerSessionId: sessionInput.providerSessionId, - appThreadId: threadInput.threadId, - ownerNodeId: null, - nativeThreadRef: { - driver: handoffDriver, - nativeId: `native-thread:handoff:${threadInput.threadId}`, - strength: "strong", - }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt, - updatedAt: createdAt, - } satisfies OrchestrationV2ProviderThread; - }), - resumeThread: ({ providerThread }) => Effect.succeed(providerThread), - startTurn: (input) => - Effect.gen(function* () { - yield* Ref.update(startCount, (count) => count + 1); - const occurredAt = yield* DateTime.now; - const providerTurnId = ProviderTurnId.make( - `provider-turn:handoff:${input.attemptId}`, - ); - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver: handoffDriver, - providerTurn: { - id: providerTurnId, - providerThreadId: input.providerThread.id, - nodeId: input.rootNodeId, - runAttemptId: input.attemptId, - nativeTurnRef: { - driver: handoffDriver, - nativeId: `native:${providerTurnId}`, - strength: "strong", - }, - ordinal: input.providerTurnOrdinal, - status: "completed", - startedAt: occurredAt, - completedAt: occurredAt, - }, - }); - yield* Queue.offer(events, { - type: "turn.terminal", - driver: handoffDriver, - providerThreadId: input.providerThread.id, - providerTurnId, - runOrdinal: input.runOrdinal, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - }), - steerTurn: () => Effect.void, - interruptTurn: () => Effect.void, - respondToRuntimeRequest: () => Effect.void, - readThreadSnapshot: () => Effect.die("unused readThreadSnapshot"), - rollbackThread: () => Effect.die("unused rollbackThread"), - forkThread: () => Effect.die("unused forkThread"), - }; - }), - }; -} - it.live("retries a failed selection restart session open before completing", () => Effect.scoped( Effect.gen(function* () { @@ -547,12 +438,12 @@ it.live("retries a failed selection restart session open before completing", () ), ); -it.live.each(["stopped", "error"] as const)( - "restarts the live session on a model change when a newer %s session record exists", - (deadStatus) => +it.live( + "restarts the live session on a model change when a newer stopped session record exists", + () => Effect.scoped( Effect.gen(function* () { - const name = `selection-restart-dead-${deadStatus}`; + const name = "selection-restart-dead-stopped"; const cwd = yield* checkpointWorkspace(name); const threadId = ThreadId.make(`thread:${name}`); const state = yield* Ref.make({ @@ -650,9 +541,9 @@ it.live.each(["stopped", "error"] as const)( occurredAt: deadAt, payload: { ...deadSession, - status: deadStatus, + status: "stopped", updatedAt: deadAt, - lastError: deadStatus === "error" ? "Simulated session failure." : null, + lastError: null, }, }, ], @@ -709,120 +600,6 @@ it.live.each(["stopped", "error"] as const)( ), ); -it.live("detaches the old provider session after an active provider handoff", () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("selection-provider-handoff-lifecycle"); - const threadId = ThreadId.make("thread:selection-provider-handoff-lifecycle"); - const state = yield* Ref.make({ - activeTurn: null, - opened: [], - started: [], - closedSessionCount: 0, - failedReplacementOpen: false, - }); - const targetStartCount = yield* Ref.make(0); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeRestartAdapter(state, exclusiveCapabilities), - makeCompletingHandoffAdapter(targetStartCount), - ]); - - const result = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:selection-handoff:create"), - threadId, - projectId: ProjectId.make("project:selection-handoff"), - title: "Selection provider handoff", - modelSelection: initialSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:selection-handoff:first"), - threadId, - messageId: MessageId.make("message:selection-handoff:first"), - text: "first", - attachments: [], - modelSelection: initialSelection, - dispatchMode: { type: "start_immediately" }, - }); - let activeRunId: RunId | null = null; - for (let index = 0; index < 1_000; index += 1) { - const current = yield* orchestrator.getThreadProjection(threadId); - if (current.providerTurns.some((turn) => turn.status === "running")) { - activeRunId = current.runs[0]?.id ?? null; - break; - } - yield* Effect.sleep("5 millis"); - } - if (activeRunId === null) { - return yield* Effect.die("active provider-handoff run is missing"); - } - - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:selection-handoff:second"), - threadId, - messageId: MessageId.make("message:selection-handoff:second"), - text: "second", - attachments: [], - modelSelection: handoffSelection, - dispatchMode: { type: "restart_active", targetRunId: activeRunId }, - }); - for (let index = 0; index < 1_000; index += 1) { - const current = yield* orchestrator.getThreadProjection(threadId); - if (current.attempts.length === 2 && current.attempts[1]?.status === "completed") { - const captured = yield* Ref.get(state); - return { projection: current, captured }; - } - yield* Effect.sleep("5 millis"); - } - const current = yield* orchestrator.getThreadProjection(threadId); - const adapterState = yield* Ref.get(state); - const capturedTargetStartCount = yield* Ref.get(targetStartCount); - yield* Effect.logError("active provider handoff did not complete", { - runs: current.runs.map((run) => [run.status, run.activeAttemptId]), - attempts: current.attempts.map((attempt) => [attempt.id, attempt.status]), - providerTurns: current.providerTurns.map((turn) => [turn.id, turn.status]), - providerThreads: current.providerThreads.map((thread) => [ - thread.providerInstanceId, - thread.providerSessionId, - thread.status, - ]), - targetStartCount: capturedTargetStartCount, - adapterState, - }); - return yield* Effect.die("active provider handoff did not complete"); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "selection-provider-handoff-lifecycle" }, - layerRegistry, - ), - ), - ); - - assert.lengthOf(result.projection.runs, 1); - assert.lengthOf(result.projection.attempts, 2); - assert.equal(result.projection.runs[0]?.providerInstanceId, handoffProviderInstanceId); - assert.equal(result.projection.contextHandoffs.length, 1); - assert.equal(result.captured.closedSessionCount, 1); - assert.equal(yield* Ref.get(targetStartCount), 1); - }), - ), -); - it.live.each(["active", "idle", "selection-command", "pooled", "separate-home"] as const)( "preserves native history only for compatible account switches (%s)", (mode) => diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 240bd8d3e2f3..3ecfe50f1af3 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -1,56 +1,29 @@ import { describe, expect, it } from "@effect/vitest"; import type { - ApplicationStoredEvent, OrchestrationV2ShellSnapshot, OrchestrationV2ShellStreamItem, - OrchestrationV2StoredEvent, OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import type * as Cause from "effect/Cause"; 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 Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; -import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { - archivedShellStreamItemFromThreadShell, - buildActiveShellSnapshot, bufferShellLiveStream, - coalesceShellApplicationEvents, - coalesceStoredThreadEvents, composeShellStreamWithEnrichment, dedupeShellEnrichment, - loadShellSnapshotParts, shellStreamItemFromEnrichmentRefresh, - shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, - shellStreamItemsFromResumeSnapshot, skipUnchangedThreadShells, } from "./ShellStream.ts"; -function project(sequence: number, id: string): ApplicationStoredEvent { - return { - sequence, - aggregateKind: "project", - aggregateId: id, - } as ApplicationStoredEvent; -} - -function thread(sequence: number, id: string): ApplicationStoredEvent { - return { - sequence, - event: { threadId: id }, - } as ApplicationStoredEvent; -} - const emptyShellSnapshot = { schemaVersion: 1, snapshotSequence: 0, @@ -117,232 +90,10 @@ it.effect("coalesces a slow shell subscriber by identity and location in sequenc ), ); -describe("buildActiveShellSnapshot", () => { - it("never duplicates archived rows into the regular shell", () => { - const active = shellFixture({ archivedAt: null }); - const archived = shellFixture({ - id: ThreadId.make("thread-archived"), - archivedAt: "2026-07-30T00:00:00.000Z" as never, - }); - - expect( - buildActiveShellSnapshot({ - projects: [], - threads: { - schemaVersion: 1, - snapshotSequence: 4, - threads: [active], - archivedThreads: [archived], - }, - snapshotSequence: 7, - }), - ).toEqual({ - schemaVersion: 1, - snapshotSequence: 7, - projects: [], - threads: [active], - archivedThreads: [], - }); - }); -}); - -describe("coalesceShellApplicationEvents", () => { - it("keeps the newest event per aggregate and preserves sequence order", () => { - expect( - coalesceShellApplicationEvents([ - thread(2, "thread-a"), - project(3, "project-a"), - thread(4, "thread-b"), - thread(5, "thread-a"), - project(6, "project-a"), - ]).map((event) => event.sequence), - ).toEqual([4, 5, 6]); - }); -}); - -function storedThreadEvent( - sequence: number, - threadId: string, - event: Record = {}, -): OrchestrationV2StoredEvent { - return { sequence, event: { threadId, ...event } } as OrchestrationV2StoredEvent; -} - -describe("loadShellSnapshotParts", () => { - it.effect("decodes the threads after the read transaction releases the connection", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const steps: Array = []; - const step =
(name: string, value: A) => - Effect.serviceOption(sql.transactionService).pipe( - Effect.map((transaction) => { - steps.push(`${name}:${Option.isSome(transaction) ? "in" : "out of"} transaction`); - return value; - }), - ); - const thread = shellFixture({}); - - const snapshot = yield* loadShellSnapshotParts({ - sql, - readThreads: step( - "read threads", - step("decode threads", { - schemaVersion: 1, - snapshotSequence: 3, - threads: [thread], - archivedThreads: [], - }), - ), - listProjects: step("list projects", []), - latestSequence: step("latest sequence", 7), - }); - - expect(steps).toEqual([ - "read threads:in transaction", - "list projects:in transaction", - "latest sequence:in transaction", - "decode threads:out of transaction", - ]); - expect(snapshot.snapshotSequence).toBe(7); - expect(snapshot.threads.threads).toEqual([thread]); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), - ); -}); - function shellFixture(overrides: Partial): OrchestrationV2ThreadShell { return { id: "thread-a", archivedAt: null, ...overrides } as OrchestrationV2ThreadShell; } -describe("coalesceStoredThreadEvents", () => { - it("keeps the newest stored event per thread and preserves sequence order", () => { - expect( - coalesceStoredThreadEvents([ - storedThreadEvent(2, "thread-a"), - storedThreadEvent(3, "thread-b"), - storedThreadEvent(5, "thread-a"), - ]).map((stored) => stored.sequence), - ).toEqual([3, 5]); - }); -}); - -describe("shellStreamItemFromThreadShell", () => { - it("emits an active thread update when the shell is not archived", () => { - const shell = shellFixture({ archivedAt: null }); - expect( - shellStreamItemFromThreadShell({ stored: storedThreadEvent(4, "thread-a"), shell }), - ).toEqual({ - kind: "thread.updated", - sequence: 4, - location: "active", - thread: shell, - }); - }); - - it("removes archived threads from the active-only shell", () => { - const shell = shellFixture({ archivedAt: "2026-07-30T00:00:00.000Z" as never }); - expect( - shellStreamItemFromThreadShell({ stored: storedThreadEvent(4, "thread-a"), shell }), - ).toEqual({ - kind: "thread.removed", - sequence: 4, - location: "active", - threadId: "thread-a", - }); - }); - - it("emits an active-shell removal when an archived thread is deleted", () => { - expect( - shellStreamItemFromThreadShell({ - stored: storedThreadEvent(6, "thread-a", { - type: "thread.deleted", - payload: { archivedAt: "2026-07-30T00:00:00.000Z" }, - }), - shell: null, - }), - ).toEqual({ - kind: "thread.removed", - sequence: 6, - location: "active", - threadId: "thread-a", - }); - }); - - it("emits a removal from the active list for other missing shells", () => { - expect( - shellStreamItemFromThreadShell({ - stored: storedThreadEvent(6, "thread-a", { - type: "thread.deleted", - payload: { archivedAt: null }, - }), - shell: null, - }), - ).toEqual({ - kind: "thread.removed", - sequence: 6, - location: "active", - threadId: "thread-a", - }); - }); -}); - -describe("archivedShellStreamItemFromThreadShell", () => { - it("emits an update for an archived shell", () => { - const shell = shellFixture({ archivedAt: "2026-07-30T00:00:00.000Z" as never }); - expect( - archivedShellStreamItemFromThreadShell({ stored: storedThreadEvent(4, "thread-a"), shell }), - ).toEqual({ kind: "thread.updated", sequence: 4, thread: shell }); - }); - - it("ignores active threads that never touched the archive", () => { - expect( - archivedShellStreamItemFromThreadShell({ - stored: storedThreadEvent(4, "thread-a", { type: "thread.settled" }), - shell: shellFixture({ archivedAt: null }), - }), - ).toBeNull(); - }); - - it("emits a removal when a thread leaves the archive", () => { - expect( - archivedShellStreamItemFromThreadShell({ - stored: storedThreadEvent(4, "thread-a", { type: "thread.unarchived" }), - shell: shellFixture({ archivedAt: null }), - }), - ).toEqual({ kind: "thread.removed", sequence: 4, threadId: "thread-a" }); - }); - - it("emits a removal when an archived thread is deleted", () => { - expect( - archivedShellStreamItemFromThreadShell({ - stored: storedThreadEvent(4, "thread-a", { - type: "thread.deleted", - payload: { archivedAt: "2026-07-30T00:00:00.000Z" }, - }), - shell: null, - }), - ).toEqual({ kind: "thread.removed", sequence: 4, threadId: "thread-a" }); - }); -}); - -describe("shellStreamItemFromEnrichmentRefresh", () => { - it("batches nearby completion roots onto one snapshot item", () => { - expect( - shellStreamItemFromEnrichmentRefresh({ - snapshot: emptyShellSnapshot, - changes: [ - { workspaceRoot: "/workspace/a" }, - { workspaceRoot: "/workspace/b" }, - { workspaceRoot: "/workspace/a" }, - ], - }), - ).toEqual({ - kind: "snapshot", - snapshot: emptyShellSnapshot, - resolvedRepositoryIdentityRoots: ["/workspace/a", "/workspace/b"], - }); - }); -}); - describe("shellStreamItemsFromInitialSnapshot", () => { it("keeps thread rows out of the metadata-only enrichment frame", () => { const snapshot = { @@ -372,50 +123,6 @@ describe("shellStreamItemsFromInitialSnapshot", () => { }); expect(JSON.stringify(items[1]).length).toBeLessThan(JSON.stringify(items[0]).length); }); - - it("emits unmarked authoritative then same-sequence marked enrichment when roots resolved", () => { - const snapshot = { - ...emptyShellSnapshot, - snapshotSequence: 7, - } as OrchestrationV2ShellSnapshot; - - expect( - shellStreamItemsFromInitialSnapshot({ - snapshot, - resolvedRepositoryIdentityRoots: ["/workspace/a", "/workspace/a"], - }), - ).toEqual([ - { kind: "snapshot", snapshot }, - { - kind: "snapshot", - snapshot, - resolvedRepositoryIdentityRoots: ["/workspace/a"], - }, - ]); - }); - - it("emits only the unmarked authoritative snapshot when no roots resolved", () => { - expect( - shellStreamItemsFromInitialSnapshot({ - snapshot: emptyShellSnapshot, - resolvedRepositoryIdentityRoots: [], - }), - ).toEqual([{ kind: "snapshot", snapshot: emptyShellSnapshot }]); - }); -}); - -describe("shellStreamItemsFromResumeSnapshot", () => { - it("never repeats the authoritative shell snapshot", () => { - expect( - shellStreamItemsFromResumeSnapshot({ - snapshot: { - ...emptyShellSnapshot, - threads: [shellFixture({})], - }, - resolvedRepositoryIdentityRoots: [], - }), - ).toEqual([]); - }); }); describe("composeShellStreamWithEnrichment", () => { @@ -572,24 +279,6 @@ describe("composeShellStreamWithEnrichment", () => { } }), ); - - it.effect("still interleaves enrichment with the post-prefix tail after initials drain", () => - Effect.gen(function* () { - const items = Array.from( - yield* composeShellStreamWithEnrichment({ - initial: Stream.make("initial-unmarked", "initial-marked"), - tail: Stream.make("tail-a", "tail-b"), - enrichment: Stream.make("enrichment"), - }).pipe(Stream.runCollect), - ); - - expect(items.slice(0, 2)).toEqual(["initial-unmarked", "initial-marked"]); - expect(items).toContain("enrichment"); - expect(items).toContain("tail-a"); - expect(items).toContain("tail-b"); - expect(items.indexOf("enrichment")).toBeGreaterThanOrEqual(2); - }), - ); }); describe("dedupeShellEnrichment", () => { @@ -764,21 +453,6 @@ describe("skipUnchangedThreadShells", () => { Effect.map((items) => Array.from(items, (item) => ("sequence" in item ? item.sequence : -1))), ); - it.effect("drops shells that only moved updatedAt, and resends them after a while", () => - Effect.gen(function* () { - const sent = yield* run([ - updated(1, shell("a")), - updated(2, shell("a", { updatedAt: at(100) })), - updated(3, shell("a", { updatedAt: at(200), itemCount: 2, visibleItemCount: 2 })), - updated(4, shell("a", { updatedAt: at(300), itemCount: 2, visibleItemCount: 2 })), - updated(5, shell("b")), - { advanceMs: 5_000 }, - updated(6, shell("a", { updatedAt: at(5_300), itemCount: 2, visibleItemCount: 2 })), - ]); - expect(sent).toEqual([1, 3, 5, 6]); - }), - ); - it.effect("sends the next update after a removal", () => Effect.gen(function* () { const sent = yield* run([ diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 884683979fb6..441cf0a6e902 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -34,31 +34,20 @@ const instanceId = ProviderInstanceId.make("codex"); const modelSelection = { instanceId, model: "test-model" }; it.effect.each( - [false, true] - .flatMap((mailbox) => - ( - [ - "before delivery", - "during delivery", - "before dispatch", - "after delivery", - "without native steering", - "with interrupting native steering", - "settled only", - ] as const - ).map((timing) => ({ - mailbox, - timing, - label: mailbox ? "mailbox notification" : "steering", - })), - ) - .filter( - ({ mailbox, timing }) => - mailbox || - (timing !== "without native steering" && - timing !== "with interrupting native steering" && - timing !== "settled only"), - ), + ( + [ + [false, "during delivery"], + [false, "before dispatch"], + [true, "after delivery"], + [true, "without native steering"], + [true, "with interrupting native steering"], + [true, "settled only"], + ] as const + ).map(([mailbox, timing]) => ({ + mailbox, + timing, + label: mailbox ? "mailbox notification" : "steering", + })), )("delivers $label when completion wins $timing", ({ mailbox, timing }) => Effect.scoped( Effect.gen(function* () { @@ -561,14 +550,15 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* return { events, started, steered, layer, startFirstTurn }; }); -it.effect.each([{ canRestart: false }, { canRestart: true }])( - "steers a changed turn-scoped selection into the running turn (canRestart=$canRestart)", - ({ canRestart }) => +// regression: #19210 a provider that can restart the turn must still steer, since restarting ends its native subagents +it.effect( + "steers a changed turn-scoped selection into a running turn the provider could restart", + () => Effect.scoped( Effect.gen(function* () { const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( - `steering-selection-change-${canRestart}`, - canRestart, + "steering-selection-change", + true, ); yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 961add539fbe..e87fd7d9c6ee 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -117,30 +117,6 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); -it("attributes native subagent prompts to their parent thread", () => { - for (const role of ["user", "assistant"] as const) { - const artifacts = makeSubagentConversationArtifacts({ - messageId: MessageId.make(`native-${role}`), - turnItemId: TurnItemId.make(`native-${role}`), - threadId: childThreadId, - senderThreadId: parentThreadId, - rootNodeId: NodeId.make("child-root"), - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - role, - text: role === "user" ? "Review the changes" : "Review complete", - ordinal: 100, - now: childCreatedAt, - }); - assert.equal(artifacts.message.threadId, childThreadId); - assert.equal(artifacts.message.senderThreadId, role === "user" ? parentThreadId : undefined); - if (artifacts.turnItem.type === "user_message") { - assert.equal(artifacts.turnItem.senderThreadId, parentThreadId); - } - } -}); - function taskFixture() { const projection = emptyProjection({ type: "thread.created", @@ -323,43 +299,6 @@ it("exposes the provider failure rather than a progress message from the failed assert.isNull(result.messageId); }); -it("reports the last assistant message when the provider finalizes them together", () => { - const { projection, run } = taskFixture(); - // OpenCode stamps every assistant message of a turn with the turn's end time. - const finalizedAt = DateTime.makeUnsafe("2026-07-24T10:05:00.000Z"); - const message = (id: string, text: string, ordinal: number, createdAt: string) => { - const artifacts = makeSubagentConversationArtifacts({ - messageId: MessageId.make(id), - turnItemId: TurnItemId.make(id), - threadId: parentThreadId, - rootNodeId: NodeId.make("root"), - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - role: "assistant", - text, - ordinal, - now: DateTime.makeUnsafe(createdAt), - }); - return { - message: { ...artifacts.message, runId: run.id, updatedAt: finalizedAt }, - turnItem: { ...artifacts.turnItem, runId: run.id }, - }; - }; - const progress = message("progress", "Starting step 0.", 1, "2026-07-24T10:00:00.000Z"); - const report = message("report", "All steps passed.", 2, "2026-07-24T10:04:00.000Z"); - const result = subagentResultForRun( - { - ...projection, - messages: [progress.message, report.message], - turnItems: [progress.turnItem, report.turnItem], - }, - run, - ); - assert.equal(result.text, "All steps passed."); - assert.equal(result.messageId, report.message.id); -}); - it("orders a task's assistant messages by turn item, not by timestamps", () => { const { projection, run } = taskFixture(); const at = DateTime.makeUnsafe("2026-07-24T10:05:00.000Z"); diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index 0121227f6e86..3d261ed85e2f 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -8,7 +8,6 @@ import { TurnItemId, type OrchestrationV2TurnItem, type OrchestrationV2Run, - type OrchestrationV2SearchThreadResult, type OrchestrationV2ThreadHistoryPage, ProjectId, ProviderInstanceId, @@ -20,8 +19,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; -import { vi } from "vite-plus/test"; -import * as ThreadFindText from "@t3tools/shared/threadFindText"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -199,83 +196,6 @@ const setup = Effect.gen(function* () { }); describe("V2 thread find", () => { - it.effect("delivers the reading-position match before parsing the remaining history", () => - Effect.gen(function* () { - const projection = yield* setup; - yield* putItems([ - item("before", 0, "before progressive needle needle"), - item("reading", 1, "reading progressive needle needle"), - item("after", 2, "after progressive needle"), - ]); - const parse = vi.spyOn(ThreadFindText, "searchableMessageSegments"); - const frames: OrchestrationV2SearchThreadResult[] = []; - try { - yield* projection - .searchThreadStream({ - threadId, - query: "needle", - start: { entryId: "reading", occurrence: 1 }, - }) - .pipe( - Stream.runForEach((frame) => - Effect.sync(() => { - frames.push(frame); - if (frame.complete === false) { - assert.deepEqual(frame.match, { entryId: "reading", runId, occurrence: 1 }); - assert.equal(parse.mock.calls.length, 1); - assert.equal(parse.mock.calls[0]![0].text, "reading progressive needle needle"); - } - }), - ), - ); - assert.equal(frames.length, 2); - assert.notEqual(frames[1]!.complete, false); - assert.deepEqual(frames[1]!.match, frames[0]!.match); - assert.equal(frames[1]!.totalMatches, 5); - assert.equal(frames[1]!.activeIndex, 3); - const cached = yield* projection - .searchThreadStream({ threadId, query: "needle" }) - .pipe(Stream.runCollect); - assert.equal(cached.length, 1); - assert.notEqual(cached[0]!.complete, false); - } finally { - parse.mockRestore(); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("wraps from an exhausted bottom anchor and cancels without counting the rest", () => - Effect.gen(function* () { - const projection = yield* setup; - yield* putItems([ - item("first", 0, "cancel progressive needle"), - item("later", 1, "cancel later needle needle"), - item("bottom", 2, "cancel bottom needle"), - ]); - const parse = vi.spyOn(ThreadFindText, "searchableMessageSegments"); - try { - const frames = yield* projection - .searchThreadStream({ - threadId, - query: "needle", - start: { entryId: "bottom", occurrence: 1 }, - }) - .pipe(Stream.take(1), Stream.runCollect); - assert.equal(frames[0]!.complete, false); - assert.equal(frames[0]!.match?.entryId, "first"); - assert.deepEqual( - parse.mock.calls.map(([message]) => message.text), - ["cancel bottom needle", "cancel progressive needle"], - ); - const complete = yield* projection.searchThread({ threadId, query: "needle" }); - assert.equal(complete.totalMatches, 4); - assert.equal(parse.mock.calls.length, 3); - } finally { - parse.mockRestore(); - } - }).pipe(Effect.provide(layerTest)), - ); - it.effect("finishes one snapshot while later searches include newly inserted matches", () => Effect.gen(function* () { const projection = yield* setup; @@ -305,25 +225,6 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("returns only a final frame for empty searches and relative navigation", () => - Effect.gen(function* () { - const projection = yield* setup; - yield* putItems([item("only", 0, "needle needle")]); - const empty = yield* projection - .searchThreadStream({ threadId, query: "missing" }) - .pipe(Stream.runCollect); - assert.equal(empty.length, 1); - assert.equal(empty[0]!.totalMatches, 0); - assert.notEqual(empty[0]!.complete, false); - const relative = yield* projection - .searchThreadStream({ threadId, query: "needle", offset: 1 }) - .pipe(Stream.runCollect); - assert.equal(relative.length, 1); - assert.equal(relative[0]!.match?.occurrence, 1); - assert.notEqual(relative[0]!.complete, false); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("returns bounded navigation counts around the selection, including wraparound", () => Effect.gen(function* () { const projection = yield* setup; @@ -426,58 +327,6 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("reuses parses of large messages across navigation, edits, and new queries", () => - Effect.gen(function* () { - const projection = yield* setup; - const text = `${"large message ".repeat(2600)}needle`; - yield* putItems(Array.from({ length: 8 }, (_, i) => item(`large:${i}`, i + 1, text))); - const parse = vi.spyOn(ThreadFindText, "searchableMessageSegments"); - try { - const first = yield* projection.searchThread({ threadId, query: "needle" }); - assert.equal(first.totalMatches, 8); - assert.equal(parse.mock.calls.length, 1); - parse.mockClear(); - const next = yield* projection.searchThread({ threadId, query: "needle", index: 4 }); - assert.equal(next.match?.entryId, "large:4"); - assert.equal(next.snapshotSequence, first.snapshotSequence); - assert.equal(parse.mock.calls.length, 0); - yield* putItems([item("large:4", 5, `${text} needle`)], "changed"); - const changed = yield* projection.searchThread({ threadId, query: "needle", index: 5 }); - assert.equal(changed.totalMatches, 9); - assert.deepEqual(changed.match, { entryId: "large:4", runId, occurrence: 1 }); - assert.equal(parse.mock.calls.length, 1); - parse.mockClear(); - const retyped = yield* projection.searchThread({ threadId, query: "large message" }); - assert.equal(retyped.totalMatches, 8 * 2600); - assert.equal(parse.mock.calls.length, 0); - } finally { - parse.mockRestore(); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("reuses parses across queries in a thread of a few hundred messages", () => - Effect.gen(function* () { - const projection = yield* setup; - const total = 600; - yield* putItems( - Array.from({ length: total }, (_, i) => item(`long:${i}`, i + 1, `message ${i} needle`)), - ); - const parse = vi.spyOn(ThreadFindText, "searchableMessageSegments"); - try { - const first = yield* projection.searchThread({ threadId, query: "needle" }); - assert.equal(first.totalMatches, total); - assert.equal(parse.mock.calls.length, total); - parse.mockClear(); - const typed = yield* projection.searchThread({ threadId, query: "message 5" }); - assert.equal(typed.totalMatches, 111); - assert.equal(parse.mock.calls.length, 0); - } finally { - parse.mockRestore(); - } - }).pipe(Effect.provide(layerTest)), - ); - it.effect("searches visible rendered messages and plans in canonical item order", () => Effect.gen(function* () { const projection = yield* setup; @@ -514,30 +363,6 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("keeps a search snapshot consistent while another thread commits", () => - Effect.gen(function* () { - const projection = yield* setup; - const other = ThreadId.make("thread:busy"); - yield* commit([thread(other, projectId), run(other, RunId.make("run:busy"))]); - yield* putItems(Array.from({ length: 260 }, (_, i) => item(`message:${i}`, i, "needle"))); - const before = yield* projection.searchThread({ threadId, query: "needle" }); - const [during] = yield* Effect.all( - [ - projection.searchThread({ threadId, query: "needle", index: 259 }), - putItems([item("busy", 1, "busy", "assistant_message", other)]), - ], - { concurrency: "unbounded" }, - ); - assert.equal(during.totalMatches, 260); - assert.equal(during.match?.entryId, "message:259"); - assert.equal(during.snapshotSequence, before.snapshotSequence); - assert.equal( - (yield* projection.searchThread({ threadId, query: "needle" })).snapshotSequence, - before.snapshotSequence, - ); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("searches displayed skill labels and invalidates parsed text when labels change", () => Effect.gen(function* () { const projection = yield* setup; @@ -825,43 +650,6 @@ describe("V2 thread find", () => { assert.equal(updated.match?.entryId, "inherited"); }).pipe(Effect.provide(layerTest)), ); - - it.effect("memory store resolves worktree file labels and rejects deleted threads", () => - Effect.gen(function* () { - const projection = yield* ProjectionStore.ProjectionStoreV2; - const created = thread(threadId, projectId); - if (created.type !== "thread.created") return; - const withWorktree = { - ...created, - payload: { ...created.payload, worktreePath: "/work/tree" }, - }; - const message = item("file", 1, "See `docs/notes.md:12`."); - yield* Effect.forEach( - [ - withWorktree, - run(), - { - id: EventId.make("file"), - threadId, - occurredAt: at(1), - type: "turn-item.updated", - payload: message, - }, - ] satisfies OrchestrationV2DomainEvent[], - projection.apply, - { discard: true }, - ); - assert.equal( - (yield* projection.searchThread({ threadId, query: "notes.md · L12" })).totalMatches, - 1, - ); - const deleted = thread(threadId, projectId, { deletedAt: at(3) }); - if (deleted.type !== "thread.created") return; - yield* projection.apply({ ...deleted, id: EventId.make("deleted"), type: "thread.deleted" }); - const error = yield* Effect.flip(projection.searchThread({ threadId, query: "notes" })); - assert.equal(error._tag, "ProjectionStoreThreadNotFoundError"); - }).pipe(Effect.provide(ProjectionStore.layerMemory)), - ); }); describe("V2 history materialization", () => { diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 920b5fe321b2..f857738742a5 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -26,7 +26,12 @@ import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => { +const forkCases = ( + [ + ["codex", "interrupted"], + ["claudeAgent", "failed"], + ] as const +).map(([driverName, status]) => { const driver = ProviderDriverKind.make(driverName); const instanceId = ProviderInstanceId.make(driver); const modelSelection = { instanceId, model: "test-model" }; @@ -46,13 +51,7 @@ const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => { { runEffectWorker: false }, ); - return (["failed", "interrupted", "cancelled"] as const).map((status) => ({ - driver, - status, - instanceId, - modelSelection, - layer, - })); + return { driver, status, instanceId, modelSelection, layer }; }); it.effect("a persisted fork leaves PR watching with its parent until explicitly opted in", () => diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index be11f5d03d12..b6b89758f7b5 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -130,19 +130,6 @@ const planFork = ( }); }).pipe(Effect.provide(ThreadForkService.layer)); -it("treats usage-limited and other provider-finished runs as forkable", () => { - assert.isTrue(ThreadForkService.isForkableSourceRunStatus("completed")); - assert.isTrue(ThreadForkService.isForkableSourceRunStatus("waiting")); - assert.isTrue(ThreadForkService.isForkableSourceRunStatus("failed")); - assert.isTrue(ThreadForkService.isForkableSourceRunStatus("interrupted")); - assert.isTrue(ThreadForkService.isForkableSourceRunStatus("cancelled")); - assert.isFalse(ThreadForkService.isForkableSourceRunStatus("running")); - assert.isFalse(ThreadForkService.isForkableSourceRunStatus("starting")); - assert.isFalse(ThreadForkService.isForkableSourceRunStatus("queued")); - assert.isFalse(ThreadForkService.isForkableSourceRunStatus("preparing")); - assert.isFalse(ThreadForkService.isForkableSourceRunStatus("rolled_back")); -}); - it.effect("keeps a fork awake when its source thread is snoozed", () => Effect.gen(function* () { const sourceThread = makeSourceThread(); @@ -172,17 +159,6 @@ it.effect("keeps a fork awake when its source thread is snoozed", () => }), ); -it.effect("forks from a usage-limited failed run", () => - Effect.gen(function* () { - const result = yield* planFork(makeSourceRun("failed")); - assert.deepEqual(result.targetThread.forkedFrom, { - type: "run", - threadId: sourceThreadId, - runId: sourceRunId, - }); - }), -); - it.effect("preserves pull request context without inheriting any watches", () => Effect.gen(function* () { const sourceRun = makeSourceRun("completed"); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index cbcf20827ee5..3ba5621bdcba 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -10,12 +10,10 @@ import * as ThreadMessageIntake from "./ThreadMessageIntake.ts"; import { assert, it, vi } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { - ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, ChatAttachmentId, ComposerContextId, type ChatAttachment, CommandId, - EnvironmentId, DEFAULT_SERVER_SETTINGS, GitCommandError, MessageId, @@ -26,7 +24,6 @@ import { ScheduledTaskId, type ServerProvider, ThreadId, - type VcsStatusLocalResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -34,7 +31,6 @@ import * as DateTime from "effect/DateTime"; 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 Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -62,7 +58,6 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; -import * as OrchestratorMcpService from "../mcp/OrchestratorMcpService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; @@ -125,10 +120,6 @@ interface HarnessOptions { readonly fetchWorktreeBaseCommit?: GitWorkflow.GitWorkflowService["Service"]["fetchWorktreeBaseCommit"]; readonly remoteExists?: GitWorkflow.GitWorkflowService["Service"]["remoteExists"]; readonly hasCommit?: GitWorkflow.GitWorkflowService["Service"]["hasCommit"]; - readonly remoteAvailable?: boolean; - readonly remoteBranchExists?: boolean; - readonly defaultBranch?: string | null; - readonly checkedOutBranch?: string | null; readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"]; @@ -208,11 +199,8 @@ function makeHarness(options: HarnessOptions = {}) { (() => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" })), hasCommit: options.hasCommit ?? (() => Effect.succeed(false)), resolveWorktreeBaseRemote: () => Effect.succeed({ remoteName: "origin", branchName: "main" }), - remoteExists: options.remoteExists ?? (() => Effect.succeed(options.remoteAvailable ?? true)), - remoteBranchExists: () => Effect.succeed(options.remoteBranchExists ?? true), - resolveDefaultBranchName: () => Effect.succeed(options.defaultBranch ?? null), - localStatus: () => - Effect.succeed({ refName: options.checkedOutBranch ?? null } as VcsStatusLocalResult), + remoteExists: options.remoteExists ?? (() => Effect.succeed(true)), + remoteBranchExists: () => Effect.succeed(true), removeWorktree, ...options.git, }), @@ -317,21 +305,6 @@ function launchInput(input: { }; } -function waitUntil(predicate: () => Effect.Effect): Effect.Effect { - return Effect.gen(function* () { - for (let attempt = 0; attempt < 200; attempt += 1) { - if (yield* predicate()) return; - yield* Effect.promise( - () => - new Promise((resolve) => { - setImmediate(resolve); - }), - ); - } - assert.fail("Condition was not reached before timeout."); - }); -} - it.effect.each( (["new", "existing"] as const).flatMap((target) => (["user", "agent"] as const).map((createdBy) => ({ target, createdBy })), @@ -528,73 +501,6 @@ it.effect("retains automation and sender attribution while a message waits in th }).pipe(Effect.provide(harness.layer)); }); -it.effect("returns a visible preparing message while provisioning is still blocked", () => - Effect.gen(function* () { - const worktreeEntered = yield* Deferred.make(); - const allowWorktree = yield* Deferred.make(); - const setupEntered = yield* Deferred.make(); - const allowSetup = yield* Deferred.make(); - const harness = makeHarness({ - createWorktree: () => - Deferred.succeed(worktreeEntered, undefined).pipe( - Effect.andThen(Deferred.await(allowWorktree)), - Effect.as({ - worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" }, - } as never), - ), - runSetup: () => - Deferred.succeed(setupEntered, undefined).pipe( - Effect.andThen(Deferred.await(allowSetup)), - Effect.as({ status: "no-script" as const }), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = launchInput({ - command: "command:launch:blocked", - thread: "thread:launch:blocked", - message: "Build the feature", - workspace: { type: "worktree", baseRef: "main" }, - }); - const launched = yield* launches.launch(input); - assert.equal(launched.projection.messages[0]?.text, "Build the feature"); - assert.equal(launched.projection.runs[0]?.status, "preparing"); - assert.equal( - launched.projection.turnItems.find((item) => item.type === "command_execution")?.status, - "running", - ); - yield* Deferred.await(worktreeEntered); - let current = yield* threads.getThreadProjection(launched.threadId); - assert.equal( - current.turnItems.find((item) => item.type === "command_execution")?.title, - "Preparing worktree", - ); - yield* Deferred.succeed(allowWorktree, undefined); - const entered = yield* Deferred.await(setupEntered).pipe( - Effect.timeoutOption(Duration.seconds(2)), - ); - if (Option.isNone(entered)) { - current = yield* threads.getThreadProjection(launched.threadId); - assert.fail( - `Setup was not reached; run=${current.runs[0]?.status ?? "missing"}, worklog=${current.turnItems.find((item) => item.type === "command_execution")?.title ?? "missing"}.`, - ); - } - current = yield* threads.getThreadProjection(launched.threadId); - assert.equal( - current.turnItems.find((item) => item.type === "command_execution")?.title, - "Starting setup script", - ); - const prematureEffects = yield* outbox.listByCommandId( - CommandId.make("command:launch:blocked:initial-message"), - ); - assert.isEmpty(prematureEffects); - yield* Deferred.succeed(allowSetup, undefined); - }).pipe(Effect.provide(harness.layer)); - }), -); - it.effect("provisions independent launches concurrently instead of behind a global semaphore", () => Effect.gen(function* () { const setupCount = yield* Ref.make(0); @@ -644,130 +550,7 @@ it.effect("provisions independent launches concurrently instead of behind a glob }), ); -it.effect("enqueues provider work only after setup has been initiated", () => - Effect.gen(function* () { - const setupEntered = yield* Deferred.make(); - const allowSetup = yield* Deferred.make(); - const harness = makeHarness({ - runSetup: () => - Deferred.succeed(setupEntered, undefined).pipe( - Effect.andThen(Deferred.await(allowSetup)), - Effect.as({ - status: "started" as const, - async: false, - scriptId: "setup", - scriptName: "Setup", - scriptCommand: "vp install", - terminalId: "setup", - cwd: "/repo", - }), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = launchInput({ - command: "command:launch:release", - thread: "thread:launch:release", - message: "Start after setup", - workspace: { type: "worktree", baseRef: "main" }, - }); - const launched = yield* launches.launch(input); - yield* Deferred.await(setupEntered); - assert.isEmpty( - yield* outbox.listByCommandId(CommandId.make("command:launch:release:release")), - ); - yield* Deferred.succeed(allowSetup, undefined); - yield* waitUntil(() => - outbox - .listByCommandId(CommandId.make("command:launch:release:release")) - .pipe(Effect.map((effects) => effects.length === 1)), - ); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.runs[0]?.status, "starting"); - assert.equal(projection.checkpointScopes[0]?.cwd, "/repo-worktrees/feature"); - assert.equal( - projection.turnItems.find((item) => item.type === "command_execution")?.status, - "completed", - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect( - "queues follow-up messages behind preparation and checkpoints them in the final workspace", - () => - Effect.gen(function* () { - const setupEntered = yield* Deferred.make(); - const failSetup = yield* Deferred.make(); - const harness = makeHarness({ - runSetup: () => - Deferred.succeed(setupEntered, undefined).pipe( - Effect.andThen(Deferred.await(failSetup)), - Effect.andThen(Effect.fail(new Error("setup failed") as never)), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:queued-during-preparation", - thread: "thread:launch:queued-during-preparation", - message: "Prepare the workspace", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* Deferred.await(setupEntered); - - const followUp = yield* threads.sendToThread({ - projectId, - commandId: CommandId.make("command:launch:queued-follow-up"), - threadId: launched.threadId, - messageId: MessageId.make("message:launch:queued-follow-up"), - text: "Run after preparation", - attachments: [], - mode: "auto", - createdBy: "user", - creationSource: "web", - }); - assert.equal(followUp.delivery, "queued"); - assert.equal(followUp.run.status, "queued"); - assert.equal( - (yield* threads.getThreadRecords(launched.threadId, ["nodes"])).nodes.find( - (node) => node.runId === followUp.run.id && node.kind === "root_turn", - )?.checkpointScopeId, - null, - ); - - yield* Deferred.succeed(failSetup, undefined); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe( - Effect.map( - (projection) => - projection.runs.find((run) => run.id === followUp.run.id)?.status === "starting", - ), - ), - ); - - const projection = yield* threads.getThreadProjection(launched.threadId); - const rootNode = projection.nodes.find( - (node) => node.runId === followUp.run.id && node.kind === "root_turn", - ); - assert.isNotNull(rootNode?.checkpointScopeId); - assert.equal( - projection.checkpointScopes.find((scope) => scope.id === rootNode?.checkpointScopeId) - ?.cwd, - "/repo-worktrees/feature", - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect.each([" /COMPACT ", "/logout"])( +it.effect.each([" /COMPACT "])( "uses the first conversation message for a title after %s", (nativeCommand) => Effect.gen(function* () { @@ -1058,194 +841,6 @@ it.effect("does not update a reused thread title when the initial message is rej }), ); -it.effect("generates an initial title for an attachment-only message", () => - Effect.gen(function* () { - const harness = makeHarness(); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const messageId = MessageId.make("message:image-only"); - const input = { - ...launchInput({ - command: "command:launch:image-only", - thread: "thread:launch:image-only", - }), - title: "Image: screenshot.png", - generateTitle: true, - initialMessage: { - messageId, - text: "", - attachments: [ - { - type: "image" as const, - id: "attachment-image-only", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 128, - }, - ], - }, - }; - - const launched = yield* launches.launch(input); - yield* titleRegeneration.execute({ - threadId: launched.threadId, - requestId: CommandId.make("command:launch:image-only:initial-message"), - kind: { type: "initial", messageId }, - }); - - assert.equal(harness.generateThreadTitle.mock.calls[0]?.[0]?.message, ""); - assert.equal( - harness.generateThreadTitle.mock.calls[0]?.[0]?.attachments?.[0]?.name, - "screenshot.png", - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect("uses the available source control writer for generated worktree branches", () => - Effect.gen(function* () { - const writerInstanceId = ProviderInstanceId.make("source-control-writer"); - const writerModelSelection = { - instanceId: writerInstanceId, - model: "branch-writer-model", - } as const; - const harness = makeHarness({ - serverSettings: { - providerInstances: { - [writerInstanceId]: { - driver: ProviderDriverKind.make("codex"), - config: {}, - }, - }, - sourceControlWriterModelSelection: writerModelSelection, - }, - providers: [ - { - instanceId: writerInstanceId, - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-07-28T00:00:00.000Z", - availability: "available", - models: [], - slashCommands: [], - skills: [], - }, - ], - }); - - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - yield* launches.launch( - launchInput({ - command: "command:launch:source-control-writer", - thread: "thread:launch:source-control-writer", - message: "Generate a branch with the configured writer", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* waitUntil(() => Effect.sync(() => harness.generateBranchName.mock.calls.length === 1)); - assert.deepEqual( - harness.generateBranchName.mock.calls[0]?.[0]?.modelSelection, - writerModelSelection, - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect("falls back when the source control writer is unavailable", () => - Effect.gen(function* () { - const writerInstanceId = ProviderInstanceId.make("missing-source-control-writer"); - const harness = makeHarness({ - serverSettings: { - providerInstances: { - [writerInstanceId]: { - driver: ProviderDriverKind.make("missing-driver"), - config: {}, - }, - }, - sourceControlWriterModelSelection: { - instanceId: writerInstanceId, - model: "missing-branch-writer-model", - }, - }, - providers: [], - }); - - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - yield* launches.launch( - launchInput({ - command: "command:launch:source-control-writer-fallback", - thread: "thread:launch:source-control-writer-fallback", - message: "Generate a branch with the available writer", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* waitUntil(() => Effect.sync(() => harness.generateBranchName.mock.calls.length === 1)); - assert.deepEqual( - harness.generateBranchName.mock.calls[0]?.[0]?.modelSelection, - DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect("runs a Scratch thread launched at the root in its own folder", () => - Effect.gen(function* () { - const claimed: Array<{ readonly threadId: ThreadId; readonly text: string }> = []; - const harness = makeHarness({ - managedFolders: Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - folderForThread: (input) => - Effect.sync(() => { - if (input.projectId !== projectId) return Option.none(); - claimed.push({ threadId: input.threadId, text: input.text }); - return Option.some(`/scratch/folder-${claimed.length}`); - }), - }), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = launchInput({ - command: "command:launch:scratch", - thread: "thread:launch:scratch", - message: "Convert these PNGs", - }); - const launched = yield* launches.launch(input); - assert.deepEqual(claimed, [{ threadId: launched.threadId, text: "Convert these PNGs" }]); - assert.equal(launched.projection.thread.worktreePath, "/scratch/folder-1"); - yield* waitUntil(() => Effect.sync(() => harness.runSetup.mock.calls.length === 1)); - assert.equal(harness.runSetup.mock.calls[0]?.[0]?.worktreePath, "/scratch/folder-1"); - assert.equal(harness.createWorktree.mock.calls.length, 0); - - const retried = yield* launches.launch(input); - assert.isTrue(retried.resumed); - assert.lengthOf(claimed, 1); - assert.equal( - (yield* threads.getThreadProjection(launched.threadId)).thread.worktreePath, - "/scratch/folder-1", - ); - - const other = yield* launches.launch({ - ...launchInput({ - command: "command:launch:scratch-other", - thread: "thread:launch:scratch-other", - message: "Elsewhere", - }), - projectId: otherProjectId, - }); - assert.lengthOf(claimed, 1); - assert.isNull(other.projection.thread.worktreePath); - }).pipe(Effect.provide(harness.layer)); - }), -); - // Stands `projectId` in for the Scratch project: a plain folder, not a Git // repository, so Git fails there with the error a real one raises. function scratchHarness() { @@ -1423,400 +1018,6 @@ it.effect("runs a Scratch scheduled task saved with a worktree in its own folder }).pipe(Effect.provide(harness.layer)); }); -it.effect("starts a thread in the main checkout without running the setup action", () => - Effect.gen(function* () { - const harness = makeHarness(); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = launchInput({ - command: "command:launch:main-checkout", - thread: "thread:launch:main-checkout", - message: "Work in the main checkout", - }); - const launched = yield* launches.launch(input); - assert.isNull(launched.projection.thread.worktreePath); - const events = yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( - Stream.takeUntil( - (stored) => - stored.commandId === CommandId.make(`${input.commandId}:release`) && - stored.event.type === "run.updated", - ), - Stream.runCollect, - ); - assert.isFalse( - events.some( - (stored) => stored.commandId === CommandId.make(`${input.commandId}:progress:setup`), - ), - ); - assert.equal(harness.runSetup.mock.calls.length, 0); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect("names the worktree itself when the client provides no branch", () => - Effect.gen(function* () { - const harness = makeHarness(); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:server-named-branch", - thread: "thread:launch:server-named-branch", - message: "Build the feature", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* waitUntil(() => Effect.sync(() => harness.createWorktree.mock.calls.length === 1)); - assert.match( - harness.createWorktree.mock.calls[0]?.[0]?.newRefName ?? "", - /^t3\/[0-9a-f]{8}$/u, - ); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")), - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect.each( - [false, true].flatMap((archived) => - [false, true].map((parentMoved) => ({ archived, parentMoved })), - ), -)("renames shared temporary branches with %j", ({ archived, parentMoved }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const git = yield* GitVcsDriver.GitVcsDriver; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "launch-rename-" }); - const repo = `${directory}/repo`; - const worktreePath = `${directory}/shared`; - const movedPath = `${directory}/moved`; - yield* fs.makeDirectory(repo); - const command = (cwd: string, args: string[]) => - git.execute({ operation: "launch-test", cwd, args }); - yield* command(repo, ["init", "-b", "main"]); - yield* command(repo, [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.test", - "commit", - "--allow-empty", - "-m", - "initial", - ]); - yield* command(repo, ["worktree", "add", "-b", "t3/abcd1234", worktreePath]); - yield* command(repo, ["worktree", "add", "-b", "user/checkout", movedPath]); - const branchNameStarted = yield* Deferred.make>(); - const allowBranchName = yield* Deferred.make(); - const harness = makeHarness({ - createWorktree: (input) => - Effect.succeed({ - worktree: { path: worktreePath, refName: input.newRefName, headSha: "abc" }, - } as never), - renameBranch: git.renameBranch, - generateBranchName: () => - Effect.fiber.pipe( - Effect.flatMap((fiber) => Deferred.succeed(branchNameStarted, fiber)), - Effect.andThen(Deferred.await(allowBranchName)), - Effect.as({ branch: "generated-branch" }), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:temp-branch", - thread: "thread:launch:temp-branch", - message: "Build the feature", - workspace: { type: "worktree", baseRef: "main", branch: "t3/abcd1234" }, - }), - ); - const renameFiber = yield* Deferred.await(branchNameStarted); - assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.newRefName, "t3/abcd1234"); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.runs[0]?.status === "starting")), - ); - assert.equal( - (yield* threads.getThreadProjection(launched.threadId)).thread.branch, - "t3/abcd1234", - ); - const parent = yield* threads.getThreadProjection(launched.threadId); - const run = parent.runs[0]!; - assert.isNotNull(run.rootNodeId); - yield* threads.dispatch({ - type: "delegated_task.request", - commandId: CommandId.make("command:launch:temp-branch:delegate"), - parentThreadId: launched.threadId, - parentRunId: run.id, - parentNodeId: run.rootNodeId!, - task: "Work in the parent's checkout", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "agent", - creationSource: "mcp", - }); - const delegated = yield* threads.getThreadProjection(launched.threadId); - const childThreadId = delegated.subagents[0]!.childThreadId!; - const child = yield* threads.getThreadProjection(childThreadId); - assert.equal(child.thread.branch, "t3/abcd1234"); - assert.equal(child.thread.worktreePath, worktreePath); - assert.equal(child.thread.lineage.parentThreadId, launched.threadId); - - const otherThreadId = ThreadId.make("thread:launch:other-branch"); - const otherPathThreadId = ThreadId.make("thread:launch:other-worktree"); - for (const [threadId, branch, otherWorktreePath] of [ - [otherThreadId, "other-feature", worktreePath], - [otherPathThreadId, "t3/abcd1234", "/repo-worktrees/other"], - ] as const) { - yield* threads.dispatch({ - type: "thread.create", - commandId: CommandId.make(`command:create:${threadId}`), - threadId, - projectId, - title: "Unrelated thread", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch, - worktreePath: otherWorktreePath, - createdBy: "user", - creationSource: "web", - }); - } - if (archived) { - for (const threadId of [childThreadId, otherThreadId, otherPathThreadId]) { - yield* threads.dispatch({ - type: "thread.archive", - commandId: CommandId.make(`command:archive:${threadId}`), - threadId, - }); - } - } - const unrelatedBeforeRename = yield* threads.getThreadProjection(otherThreadId); - const otherPathBeforeRename = yield* threads.getThreadProjection(otherPathThreadId); - if (parentMoved) { - yield* threads.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("command:launch:parent-moved"), - threadId: launched.threadId, - branch: "user/checkout", - worktreePath: movedPath, - }); - } - yield* Deferred.succeed(allowBranchName, undefined); - yield* Fiber.await(renameFiber); - assert.equal(yield* git.resolveBranchName(worktreePath, "HEAD"), "generated-branch"); - assert.equal( - (yield* threads.getThreadProjection(launched.threadId)).thread.branch, - parentMoved ? "user/checkout" : "generated-branch", - ); - assert.equal( - (yield* threads.getThreadProjection(launched.threadId)).thread.worktreePath, - parentMoved ? movedPath : worktreePath, - ); - assert.equal( - (yield* threads.getThreadProjection(childThreadId)).thread.branch, - "generated-branch", - ); - assert.deepEqual( - (yield* threads.getThreadProjection(otherThreadId)).thread, - unrelatedBeforeRename.thread, - ); - assert.deepEqual( - (yield* threads.getThreadProjection(otherPathThreadId)).thread, - otherPathBeforeRename.thread, - ); - const staleUpdate = yield* threads - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("command:launch:stale-branch-update"), - threadId: otherThreadId, - branch: "generated-branch", - expectedBranch: "t3/abcd1234", - expectedWorktreePath: worktreePath, - }) - .pipe(Effect.flip); - assert.equal(staleUpdate._tag, "OrchestratorDispatchError"); - assert.deepEqual( - (yield* threads.getThreadProjection(otherThreadId)).thread, - unrelatedBeforeRename.thread, - ); - assert.deepEqual(harness.renameBranch.mock.calls[0]?.[0], { - cwd: worktreePath, - oldBranch: "t3/abcd1234", - newBranch: "generated-branch", - }); - }).pipe(Effect.provide(harness.layer)); - }).pipe( - Effect.provide( - GitVcsDriver.layer.pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "launch-git-" })), - Layer.provideMerge(NodeServices.layer), - ), - ), - ), -); - -it.effect("provisions under t3- when a plain t3 branch blocks t3/*", () => - Effect.gen(function* () { - const harness = makeHarness({ - hasCommit: (input) => Effect.succeed(input.refName === "refs/heads/t3"), - createWorktree: (input) => - Effect.succeed({ - worktree: { path: "/repo-worktrees/temp", refName: input.newRefName, headSha: "abc" }, - } as never), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:blocked-namespace", - thread: "thread:launch:blocked-namespace", - message: "Build the feature", - workspace: { type: "worktree", baseRef: "main", branch: "t3/abcd1234" }, - }), - ); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")), - ); - assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.newRefName, "t3-abcd1234"); - assert.equal(harness.renameBranch.mock.calls[0]?.[0]?.oldBranch, "t3-abcd1234"); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect.each([ - { - name: "uses origin's default branch", - options: { defaultBranch: "rtvision", checkedOutBranch: "feature/x" }, - expected: "rtvision", - }, - { - name: "falls back to the checked-out branch when origin's default was deleted", - options: { defaultBranch: "gone", remoteBranchExists: false, checkedOutBranch: "trunk" }, - expected: "trunk", - }, - { - name: "falls back to the checked-out branch when origin records no default", - options: { remoteAvailable: false, checkedOutBranch: "trunk" }, - expected: "trunk", - }, - { - name: "fails without a default branch on a detached checkout", - options: { defaultBranch: null, checkedOutBranch: null }, - expected: null, - }, -] satisfies ReadonlyArray<{ - name: string; - options: HarnessOptions; - expected: string | null; -}>)("resolveDefaultWorktreeBase $name", ({ options, expected }) => - Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const resolved = yield* launches.resolveDefaultWorktreeBase(projectId).pipe(Effect.option); - assert.deepEqual(resolved, Option.fromNullOr(expected)); - }).pipe(Effect.provide(makeHarness(options).layer)), -); - -it.effect("keeps an explicit branch name instead of generating one", () => - Effect.gen(function* () { - const harness = makeHarness(); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - yield* launches.launch( - launchInput({ - command: "command:launch:explicit-branch", - thread: "thread:launch:explicit-branch", - message: "Build the feature", - workspace: { type: "worktree", baseRef: "main", branch: "my-feature" }, - }), - ); - yield* waitUntil(() => Effect.sync(() => harness.createWorktree.mock.calls.length === 1)); - assert.equal(harness.generateBranchName.mock.calls.length, 0); - assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.newRefName, "my-feature"); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect("keeps the temporary branch when branch generation fails", () => - Effect.gen(function* () { - const harness = makeHarness({ - createWorktree: (input) => - Effect.succeed({ - worktree: { path: "/repo-worktrees/temp", refName: input.newRefName, headSha: "abc" }, - } as never), - generateBranchName: () => Effect.die("branch generation is down"), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:branch-fallback", - thread: "thread:launch:branch-fallback", - message: "Build the feature", - workspace: { type: "worktree", baseRef: "main", branch: "t3/abcd1234" }, - }), - ); - yield* waitUntil(() => Effect.sync(() => harness.generateBranchName.mock.calls.length === 1)); - assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.newRefName, "t3/abcd1234"); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.runs[0]?.status === "starting")), - ); - assert.equal(harness.renameBranch.mock.calls.length, 0); - assert.equal( - (yield* threads.getThreadProjection(launched.threadId)).thread.branch, - "t3/abcd1234", - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - -it.effect("renames a temporary branch on an existing worktree to a generated name", () => - Effect.gen(function* () { - const harness = makeHarness(); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:existing-worktree-rename", - thread: "thread:launch:existing-worktree-rename", - message: "Build the feature", - workspace: { - type: "existing_worktree", - worktreePath: "/repo-worktrees/t3-abcd1234", - branch: "t3/abcd1234", - }, - }), - ); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")), - ); - assert.deepEqual(harness.renameBranch.mock.calls[0]?.[0], { - cwd: "/repo-worktrees/t3-abcd1234", - oldBranch: "t3/abcd1234", - newBranch: "generated-branch", - }); - }).pipe(Effect.provide(harness.layer)); - }), -); - it.effect.each(["stable", "upstream/release"])( "launches from %s with the fetched commit and remote PR base", (baseRef) => @@ -1906,45 +1107,6 @@ it.effect.each(["stable", "upstream/release"])( ), ); -it.effect.each([false, true])( - "skips fetch only when no remote is selected (remote selected: %s)", - (remoteSelected) => - Effect.gen(function* () { - const setupStarted = yield* Deferred.make(); - const fetchWorktreeBaseCommit = vi.fn(() => Effect.succeed(null)); - const harness = makeHarness({ - git: { - resolveWorktreeBaseRemote: () => - Effect.succeed(remoteSelected ? { remoteName: "origin", branchName: "main" } : null), - }, - fetchWorktreeBaseCommit, - runSetup: () => - Deferred.succeed(setupStarted, undefined).pipe( - Effect.as({ status: "no-script" as const }), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:fetch-progress", - thread: "thread:launch:fetch-progress", - workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, - }), - ); - yield* Deferred.await(setupStarted); - const snapshot = yield* tracker.get(launched.threadId); - assert.equal( - snapshot?.stages.find((stage) => stage.id === "fetch")?.status, - remoteSelected ? "done" : "skipped", - ); - assert.equal(fetchWorktreeBaseCommit.mock.calls.length, remoteSelected ? 1 : 0); - assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.refName, "main"); - }).pipe(Effect.provide(harness.layer)); - }), -); - it.effect("shows the fetch diagnosis when preparing a worktree from origin fails", () => { const detail = "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; @@ -1973,93 +1135,24 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails ); yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( Stream.filter( - (stored) => stored.event.type === "run.updated" && stored.event.payload.status === "failed", - ), - Stream.runHead, - ); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.messages[0]?.text, "Start from origin"); - assert.equal(projection.runs[0]?.status, "failed"); - assert.equal(projection.thread.worktreePath, null); - assert.equal( - projection.turnItems.find((item) => item.type === "command_execution")?.status, - "failed", - ); - assert.include( - projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", - detail, - ); - assert.equal(harness.createWorktree.mock.calls.length, 0); - assert.equal(harness.runSetup.mock.calls.length, 0); - }).pipe(Effect.provide(harness.layer)); -}); - -it.effect("retries a failed workspace preparation on the same run", () => { - let fetchFailures = 1; - const harness = makeHarness({ - fetchWorktreeBaseCommit: () => - fetchFailures-- > 0 - ? Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.fetchRemote", - command: "git", - cwd: project.workspaceRoot, - detail: "Git could not update a local reference.", - exitCode: 1, - }), - ) - : Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), - }); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:retry", - thread: "thread:launch:retry", - message: "Retry me", - workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, - }), - ); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), - ); - const failed = yield* threads.getThreadProjection(launched.threadId); - const runId = failed.runs[0]!.id; - assert.equal( - failed.turnItems.find((item) => item.type === "error")?.failure.code, - ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, - ); - - const retry = { - commandId: CommandId.make("command:launch:retry:1"), - threadId: launched.threadId, - runId, - }; - yield* launches.retryPreparation(retry); - yield* waitUntil(() => - outbox - .listByCommandId(CommandId.make("command:launch:retry:1:release")) - .pipe(Effect.map((effects) => effects.length === 1)), + (stored) => stored.event.type === "run.updated" && stored.event.payload.status === "failed", + ), + Stream.runHead, ); - const retried = yield* threads.getThreadProjection(launched.threadId); - assert.equal(retried.runs.length, 1); - assert.equal(retried.runs[0]?.status, "starting"); - assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature"); - assert.equal(retried.turnItems.find((item) => item.type === "error")?.status, "cancelled"); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.messages[0]?.text, "Start from origin"); + assert.equal(projection.runs[0]?.status, "failed"); + assert.equal(projection.thread.worktreePath, null); assert.equal( - retried.turnItems.find((item) => item.type === "command_execution")?.status, - "completed", + projection.turnItems.find((item) => item.type === "command_execution")?.status, + "failed", ); - assert.equal(harness.createWorktree.mock.calls.length, 1); - - const rejected = yield* launches - .retryPreparation({ ...retry, commandId: CommandId.make("command:launch:retry:2") }) - .pipe(Effect.flip); - assert.equal(rejected._tag, "OrchestratorDispatchError"); + assert.include( + projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", + detail, + ); + assert.equal(harness.createWorktree.mock.calls.length, 0); + assert.equal(harness.runSetup.mock.calls.length, 0); }).pipe(Effect.provide(harness.layer)); }); @@ -2243,138 +1336,6 @@ it.effect.each(["failed", "interrupted"] as const)( }, ); -it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { - let setupFailures = 1; - const harness = makeHarness({ - runSetup: () => - setupFailures-- > 0 - ? Effect.fail(new Error("setup failed") as never) - : Effect.succeed({ status: "no-script" as const }), - }); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const threads = yield* ThreadManagement.ThreadManagementService; - const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:reuse", - thread: "thread:launch:reuse", - message: "Reuse the worktree", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe( - Effect.map( - (projection) => - projection.runs[0]?.status === "failed" && - projection.thread.branch === "generated-branch", - ), - ), - ); - const failed = yield* threads.getThreadProjection(launched.threadId); - assert.equal(failed.thread.worktreePath, "/repo-worktrees/feature"); - - yield* launches.retryPreparation({ - commandId: CommandId.make("command:launch:reuse:retry"), - threadId: launched.threadId, - runId: failed.runs[0]!.id, - }); - yield* waitUntil(() => - outbox - .listByCommandId(CommandId.make("command:launch:reuse:retry:release")) - .pipe(Effect.map((effects) => effects.length === 1)), - ); - const retried = yield* threads.getThreadProjection(launched.threadId); - assert.equal(retried.runs[0]?.status, "starting"); - assert.equal(harness.createWorktree.mock.calls.length, 1); - assert.equal(harness.renameBranch.mock.calls.length, 1); - assert.equal(retried.thread.branch, "generated-branch"); - assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature"); - const snapshot = yield* tracker.get(launched.threadId); - assert.equal(snapshot?.phase, "done"); - assert.deepEqual( - snapshot?.stages.map((stage) => stage.id), - ["setup-script", "agent"], - ); - }).pipe(Effect.provide(harness.layer)); -}); - -it.effect("removes a worktree that failed before the thread recorded it", () => { - const harness = makeHarness({ - createWorktree: (_input, options) => - (options?.progress?.onWorktreeClaimed?.("/repo-worktrees/partial") ?? Effect.void).pipe( - Effect.andThen(Effect.fail(new Error("checkout failed") as never)), - ), - }); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:partial-worktree", - thread: "thread:launch:partial-worktree", - message: "Partial checkout", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), - ); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.thread.worktreePath, null); - assert.deepEqual( - harness.removeWorktree.mock.calls.map(([input]) => input.path), - ["/repo-worktrees/partial"], - ); - }).pipe(Effect.provide(harness.layer)); -}); - -it.effect.each(["worktree", "setup"] as const)( - "%s failure keeps the thread and message visible and emits failure items", - (failurePoint) => - Effect.gen(function* () { - const failure = new Error(`${failurePoint} failed`); - const harness = makeHarness( - failurePoint === "worktree" - ? { createWorktree: () => Effect.fail(failure as never) } - : { runSetup: () => Effect.fail(failure as never) }, - ); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = launchInput({ - command: `command:launch:${failurePoint}-failure`, - thread: `thread:launch:${failurePoint}-failure`, - message: `Fail during ${failurePoint}`, - workspace: { type: "worktree", baseRef: "main" }, - }); - const launched = yield* launches.launch(input); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), - ); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`); - assert.equal(projection.runs[0]?.status, "failed"); - assert.equal( - projection.turnItems.find((item) => item.type === "command_execution")?.status, - "failed", - ); - assert.match( - projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", - new RegExp(`${failurePoint} failed`, "u"), - ); - }).pipe(Effect.provide(harness.layer)); - }), -); - it.effect("replays a server-allocated launch", () => Effect.gen(function* () { const setupEntered = yield* Deferred.make(); @@ -2425,33 +1386,6 @@ it.effect("replays a server-allocated launch", () => }), ); -it.effect("refuses a launch when the project disables the model's provider instance", () => { - const harness = makeHarness({ - serverSettings: { - projectSettingsOverrides: { - [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, - }, - }, - }); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const failed = yield* launches - .launch( - launchInput({ - command: "command:launch:project-disabled-instance", - thread: "thread:launch:project-disabled-instance", - message: "Hello", - }), - ) - .pipe(Effect.flip); - if (failed._tag !== "ThreadManagementProviderInstanceDisabledError") { - assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${failed._tag}`); - } - assert.equal(failed.projectId, projectId); - assert.equal(failed.instanceId, modelSelection.instanceId); - }).pipe(Effect.provide(harness.layer)); -}); - it.effect("replays an accepted launch after the project disables the instance", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2514,59 +1448,6 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id }).pipe(Effect.provide(harness.layer)); }); -it.effect("rejects a server-allocated launch receipt from another project", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const { threadId: _unusedThreadId, ...rest } = launchInput({ - command: "command:launch:allocated-wrong-project", - thread: "unused", - message: "Wrong project", - }); - const first = yield* launches.launch(rest); - const failed = yield* launches - .launch({ - ...rest, - projectId: otherProjectId, - }) - .pipe(Effect.flip); - if (failed._tag !== "ThreadLaunchError") { - assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); - } - assert.equal(failed.operation, "resolve-project"); - assert.equal(failed.threadId, first.threadId); - assert.equal(failed.cause, "Project identity changed."); - }).pipe(Effect.provide(harness.layer)); -}); - -it.effect("rejects a server-allocated launch retry after the thread is deleted", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const { threadId: _unusedThreadId, ...rest } = launchInput({ - command: "command:launch:allocated-deleted", - thread: "unused", - message: "Deleted before retry", - }); - const first = yield* launches.launch(rest); - yield* threads.dispatch({ - type: "thread.delete", - commandId: CommandId.make("command:launch:allocated-deleted:delete"), - threadId: first.threadId, - }); - const failed = yield* launches.launch(rest).pipe(Effect.flip); - if (failed._tag !== "ThreadLaunchError") { - assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); - } - assert.equal(failed.operation, "create-thread"); - assert.equal(failed.threadId, first.threadId); - assert.equal(failed.cause, "Thread not found."); - const shells = yield* threads.listProjectThreads({ projectId, includeSubagents: true }); - assert.equal(shells.length, 0); - }).pipe(Effect.provide(harness.layer)); -}); - it.effect("does not treat an unrelated accepted command receipt as a launch", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2666,38 +1547,6 @@ it.effect("bounds concurrent first launches to one thread per command", () => }), ); -it.effect("deduplicates retried launch side effects in-process", () => - Effect.gen(function* () { - const setupEntered = yield* Deferred.make(); - const allowSetup = yield* Deferred.make(); - const harness = makeHarness({ - runSetup: () => - Deferred.succeed(setupEntered, undefined).pipe( - Effect.andThen(Deferred.await(allowSetup)), - Effect.as({ status: "no-script" as const }), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const input = launchInput({ - command: "command:launch:retry", - thread: "thread:launch:retry", - message: "Only once", - workspace: { type: "worktree", baseRef: "main" }, - }); - const [first, retry] = yield* Effect.all([launches.launch(input), launches.launch(input)], { - concurrency: "unbounded", - }); - yield* Deferred.await(setupEntered); - assert.equal(first.threadId, retry.threadId); - assert.isFalse(first.resumed); - assert.isTrue(retry.resumed); - assert.equal(harness.runSetup.mock.calls.length, 1); - yield* Deferred.succeed(allowSetup, undefined); - }).pipe(Effect.provide(harness.layer)); - }), -); - it.effect("does not let a failing same-command caller strand a concurrent durable launch", () => Effect.gen(function* () { const setupEntered = yield* Deferred.make(); @@ -2932,7 +1781,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch assert.isDefined(stored); assert.notEqual(stored.attachments[0]?.id, attachment.id); assert.equal( - (stored.context?.records[0] as { attachmentId: string }).attachmentId, + (stored.context?.records?.[0] as { attachmentId: string } | undefined)?.attachmentId, stored.attachments[0]?.id, ); const userItem = accepted.turnItems.find( @@ -3275,57 +2124,6 @@ it.effect.each([ }), ); -it.effect.each([ - { name: "a base ref that resolves", baseRef: "main", commits: ["main", "HEAD"] }, - { - name: "a base ref only origin has", - baseRef: "remote-only", - startFromOrigin: true, - commits: ["HEAD"], - }, - { - name: "a base ref a remote branch carries", - baseRef: "remote-only", - commits: ["HEAD"], - remoteBranch: true, - }, - { name: "a repository with no commits yet", baseRef: "main", commits: [] }, - { name: "the previous checkout", baseRef: "-", commits: ["@{-1}", "HEAD"] }, - { name: "a commit search", baseRef: ":/fix", commits: ["HEAD"] }, - { name: "a folder that is not a repository", baseRef: "main", commits: null }, -])("leaves $name to the launch", (testCase) => - Effect.gen(function* () { - const { commits } = testCase; - const harness = makeHarness({ - git: { hasRefNamed: () => Effect.succeed(testCase.remoteBranch === true) }, - hasCommit: ({ refName }) => - commits === null - ? Effect.fail( - new GitCommandError({ - operation: "GitWorkflowService.hasCommit", - command: "git rev-parse", - cwd: "/repo", - detail: "Not a Git repository.", - }), - ) - : Effect.succeed(commits.includes(refName)), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - yield* launches.checkWorktreeBase({ - projectId, - workspaceStrategy: { - type: "worktree", - baseRef: testCase.baseRef, - ...(testCase.startFromOrigin === undefined - ? {} - : { startFromOrigin: testCase.startFromOrigin }), - }, - }); - }).pipe(Effect.provide(harness.layer)); - }), -); - it.effect("replays an accepted send after the project disables the instance", () => { const harness = makeHarness(); const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-send-retry-" }).pipe( @@ -3373,289 +2171,3 @@ it.effect("replays an accepted send after the project disables the instance", () } }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); }); - -it.effect("replays an accepted message.dispatch after the project disables the instance", () => { - const harness = makeHarness(); - const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-dispatch-retry-" }).pipe( - Layer.provideMerge(NodeServices.layer), - ); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const settings = yield* ServerSettings.ServerSettingsService; - const created = yield* launches.launch( - launchInput({ - command: "command:dispatch:retry-setup", - thread: "thread:dispatch:retry-after-disable", - }), - ); - - const dispatchInput = { - type: "message.dispatch" as const, - commandId: CommandId.make("command:dispatch:retry-after-disable"), - threadId: created.threadId, - messageId: MessageId.make("message:dispatch:retry-after-disable"), - text: "Hello again", - attachments: [], - dispatchMode: { type: "queue_after_active" as const }, - createdBy: "user" as const, - creationSource: "web" as const, - }; - const first = yield* ThreadMessageIntake.dispatchCommand(dispatchInput); - - yield* settings.updateSettings({ - projectSettingsOverrides: { - [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, - }, - }); - - const retry = yield* ThreadMessageIntake.dispatchCommand(dispatchInput); - assert.deepEqual(retry, first); - - const fresh = yield* ThreadMessageIntake.dispatchCommand({ - ...dispatchInput, - commandId: CommandId.make("command:dispatch:fresh-after-disable"), - messageId: MessageId.make("message:dispatch:fresh-after-disable"), - }).pipe(Effect.flip); - if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") { - assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`); - } - }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); -}); - -it.effect.each( - (["codex", "claudeAgent"] as const).flatMap((driver) => - [ - { failSetup: false, failResolve: false }, - { failSetup: true, failResolve: false }, - { failSetup: false, failResolve: true }, - // Only a parent with its own checkout can hold a commit the project lacks. - ...(driver === "codex" ? [] : [{ failSetup: false, failResolve: false, foreignBase: true }]), - { failSetup: false, failResolve: false, cancelSetup: true }, - { failSetup: false, failResolve: false, cancelSetup: true, cancelAsync: true }, - ].map((failures) => ({ - driver, - cancelSetup: false, - cancelAsync: false, - foreignBase: false, - ...failures, - })), - ), -)( - "prepares $driver delegated worktrees before execution, setup failure=$failSetup, commit failure=$failResolve, base outside project=$foreignBase, cancellation=$cancelSetup, async=$cancelAsync", - ({ driver, failSetup, failResolve, foreignBase, cancelSetup, cancelAsync }) => - Effect.gen(function* () { - const setupEntered = yield* Deferred.make(); - const allowSetup = yield* Deferred.make(); - const terminalClosed = yield* Deferred.make(); - const childInstanceId = ProviderInstanceId.make(`child-${driver}`); - const parentWorktree = driver === "codex" ? null : "/parent-worktree"; - const resolveCommit = vi.fn((input: { readonly cwd: string; readonly revision: string }) => - failResolve || (foreignBase && input.cwd === "/repo") - ? Effect.fail( - new GitCommandError({ - operation: "resolveCommit", - command: "git", - cwd: "/private-checkout", - detail: "private Git failure details", - }), - ) - : Effect.succeed({ commitSha: "parent-local-commit" }), - ); - const harness = makeHarness({ - closeTerminal: () => Deferred.succeed(terminalClosed, undefined).pipe(Effect.asVoid), - adapters: [ - adapter, - { ...adapter, instanceId: childInstanceId, driver: ProviderDriverKind.make(driver) }, - ], - serverSettings: { - providerInstances: { - [childInstanceId]: { driver: ProviderDriverKind.make(driver), config: {} }, - }, - }, - hasCommit: () => Effect.succeed(true), - providers: [ - { - instanceId: childInstanceId, - driver: ProviderDriverKind.make(driver), - enabled: true, - installed: true, - version: "test", - checkedAt: "2026-10-13T00:00:00.000Z", - slashCommands: [], - skills: [], - status: "ready", - auth: { status: "authenticated" }, - models: [{ slug: "child-model", name: "Child", isCustom: true, capabilities: null }], - }, - ], - runSetup: () => - cancelSetup - ? Effect.succeed({ - status: "started" as const, - async: cancelAsync, - scriptId: "setup", - scriptName: "Setup", - scriptCommand: "setup", - terminalId: "setup", - cwd: "/repo-worktrees/feature", - completion: Deferred.succeed(setupEntered, undefined).pipe( - Effect.andThen(Deferred.await(allowSetup)), - Effect.as({ exitCode: 0, durationMs: 0 }), - ), - }) - : Deferred.succeed(setupEntered, undefined).pipe( - Effect.andThen(Deferred.await(allowSetup)), - Effect.andThen( - failSetup - ? Effect.fail( - new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ - threadId: "child", - worktreePath: "/repo-worktrees/feature", - operation: "writeCommand", - cause: "setup failed", - }), - ) - : Effect.succeed({ status: "no-script" as const }), - ), - ), - }); - const layerService = OrchestratorMcpService.layer.pipe( - Layer.provide(NodeCrypto.layer), - Layer.provide(harness.layer), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({ resolveCommit })), - Layer.provide(Layer.mock(ScheduledTasks.ScheduledTaskService)({})), - Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), - ); - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const threads = yield* ThreadManagement.ThreadManagementService; - const parentThreadId = ThreadId.make("thread:delegated-worktree-parent"); - yield* threads.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-parent"), - threadId: parentThreadId, - projectId, - title: "Parent", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: "parent-feature", - worktreePath: parentWorktree, - createdBy: "user", - creationSource: "web", - }); - yield* threads.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-parent"), - threadId: parentThreadId, - messageId: MessageId.make("parent-message"), - text: "Delegate work", - attachments: [], - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", - }); - const scope = { - environmentId: EnvironmentId.make("delegated-worktree-test"), - requestNamespace: "parent-session", - thread: { - threadId: parentThreadId, - providerSessionId: "parent-session", - providerInstanceId: modelSelection.instanceId, - }, - client: undefined, - capabilities: new Set(["orchestration"] as const), - issuedAt: 1, - }; - const input = { - task: "Implement the child task", - target: { providerInstanceId: childInstanceId, model: "child-model" }, - isolation: "worktree" as const, - clientRequestId: "isolated-child", - }; - if (failResolve) { - const error = yield* service.delegateTask(scope, input).pipe(Effect.flip); - assert.equal(error.code, "invalid_request"); - assert.equal(error.message, "Unable to resolve the parent checkout commit."); - assert.isEmpty((yield* threads.getThreadProjection(parentThreadId)).subagents); - assert.equal(harness.createWorktree.mock.calls.length, 0); - return; - } - if (foreignBase) { - const error = yield* service.delegateTask(scope, input).pipe(Effect.flip); - assert.equal(error.code, "invalid_request"); - assert.include(error.message, 'Isolation "worktree" creates the child checkout'); - assert.notInclude(error.message, "private Git failure details"); - assert.isEmpty((yield* threads.getThreadProjection(parentThreadId)).subagents); - assert.equal(harness.createWorktree.mock.calls.length, 0); - return; - } - const delegated = yield* service.delegateTask(scope, input); - yield* Deferred.await(setupEntered); - const child = yield* threads.getThreadProjection(delegated.childThreadId); - assert.equal(child.thread.lineage.parentThreadId, parentThreadId); - assert.equal(child.thread.worktreePath, "/repo-worktrees/feature"); - assert.equal(child.runs[0]?.status, cancelAsync ? "starting" : "preparing"); - assert.equal(child.thread.modelSelection.instanceId, childInstanceId); - assert.equal(child.thread.modelSelection.model, "child-model"); - assert.deepEqual(resolveCommit.mock.calls, [ - [{ cwd: parentWorktree ?? "/repo", revision: "HEAD" }], - ...(parentWorktree === null ? [] : [[{ cwd: "/repo", revision: "parent-local-commit" }]]), - ]); - assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, "parent-local-commit"); - assert.equal((yield* service.delegateTask(scope, input)).taskId, delegated.taskId); - assert.equal(harness.createWorktree.mock.calls.length, 1); - assert.equal(resolveCommit.mock.calls.length, parentWorktree === null ? 1 : 2); - if (!cancelAsync) assert.isEmpty(child.checkpointScopes); - const parent = yield* threads.getThreadProjection(parentThreadId); - assert.equal(parent.thread.worktreePath, parentWorktree); - assert.equal(parent.thread.branch, "parent-feature"); - if (cancelSetup) { - const afterSequence = yield* threads.getThreadEventSequence(delegated.childThreadId); - yield* service.cancelTask(scope, { taskId: delegated.taskId }); - yield* Deferred.await(terminalClosed); - if (!cancelAsync) { - yield* threads - .streamStoredEventsFrom({ threadId: delegated.childThreadId, afterSequence }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "thread.metadata-updated" && - stored.event.payload.worktreePath === null, - ), - Stream.runHead, - ); - } - assert.equal(harness.closeTerminal.mock.calls.length, 1); - assert.equal(harness.removeWorktree.mock.calls.length, cancelAsync ? 0 : 1); - const cancelled = yield* threads.getThreadProjection(delegated.childThreadId); - assert.equal(cancelled.runs[0]?.status, "interrupted"); - if (cancelAsync) { - assert.equal(cancelled.thread.worktreePath, "/repo-worktrees/feature"); - assert.equal(cancelled.checkpointScopes[0]?.cwd, "/repo-worktrees/feature"); - } else { - assert.isNull(cancelled.thread.worktreePath); - assert.isEmpty(cancelled.checkpointScopes); - } - return; - } - yield* Deferred.succeed(allowSetup, undefined); - yield* threads - .streamStoredEventsFrom({ threadId: delegated.childThreadId, eventType: "run.updated" }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "run.updated" && - stored.event.payload.status === (failSetup ? "failed" : "starting"), - ), - Stream.runHead, - ); - const prepared = yield* threads.getThreadProjection(delegated.childThreadId); - if (failSetup) { - assert.isEmpty(prepared.checkpointScopes); - } else { - assert.equal(prepared.checkpointScopes[0]?.cwd, "/repo-worktrees/feature"); - } - }).pipe(Effect.provide(Layer.merge(harness.layer, layerService))); - }), -); diff --git a/apps/server/src/orchestration-v2/ThreadLifecycleService.test.ts b/apps/server/src/orchestration-v2/ThreadLifecycleService.test.ts deleted file mode 100644 index 7517c843a053..000000000000 --- a/apps/server/src/orchestration-v2/ThreadLifecycleService.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { - CommandId, - ProviderInstanceId, - ThreadId, - type OrchestrationV2ThreadProjection, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import * as ThreadLifecycle from "./ThreadLifecycleService.ts"; -import * as ThreadManagement from "./ThreadManagementService.ts"; - -it.effect("maps application lifecycle operations to V2-native commands", () => { - const threadId = ThreadId.make("thread_lifecycle_service"); - const commands: Array = []; - const projection = { thread: { id: threadId } } as OrchestrationV2ThreadProjection; - const layer = ThreadLifecycle.layer.pipe( - Layer.provide( - Layer.mock(ThreadManagement.ThreadManagementService)({ - dispatch: (command) => { - commands.push(command.type); - return Effect.succeed({ sequence: commands.length, storedEvents: [] }); - }, - getThreadRecords: () => Effect.succeed(projection), - }), - ), - ); - return Effect.gen(function* () { - const service = yield* ThreadLifecycle.ThreadLifecycleService; - yield* service.archive({ commandId: CommandId.make("archive"), threadId }); - yield* service.unarchive({ commandId: CommandId.make("unarchive"), threadId }); - yield* service.updateMetadata({ commandId: CommandId.make("metadata"), threadId, title: "T" }); - yield* service.setRuntimeMode({ - commandId: CommandId.make("runtime"), - threadId, - runtimeMode: "approval-required", - }); - yield* service.setInteractionMode({ - commandId: CommandId.make("interaction"), - threadId, - interactionMode: "plan", - }); - yield* service.setModelSelection({ - commandId: CommandId.make("model"), - threadId, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.2" }, - }); - yield* service.delete({ commandId: CommandId.make("delete"), threadId }); - assert.deepEqual(commands, [ - "thread.archive", - "thread.unarchive", - "thread.metadata.update", - "thread.runtime-mode.set", - "thread.interaction-mode.set", - "thread.model-selection.set", - "thread.delete", - ]); - }).pipe(Effect.provide(layer)); -}); diff --git a/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.test.ts index f99beb05466c..de8fee88508b 100644 --- a/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.test.ts @@ -7,7 +7,6 @@ import { type OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; import { it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; @@ -126,73 +125,6 @@ describe("ThreadLiveEventCoalescer", () => { expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]); }); - it.effect("flushes pending tool updates as soon as an unrelated event arrives", () => - Effect.scoped( - Effect.gen(function* () { - const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); - const startedAt = yield* Clock.currentTimeMillis; - yield* Effect.forEach( - Array.from({ length: 10 }, (_, index) => index + 2), - (sequence) => coalescer.offer(makeToolActivity(sequence)), - { discard: true }, - ); - yield* coalescer.offer(makeMessage(12)); - - expect(yield* Clock.currentTimeMillis).toBe(startedAt); - expect( - (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) => - item.kind === "event" ? item.sequence : item.kind, - ), - ).toEqual([11, 12]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () => - Effect.scoped( - Effect.gen(function* () { - const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); - const startedAt = yield* Clock.currentTimeMillis; - yield* coalescer.offer(makeToolActivity(2)); - yield* coalescer.offer(makeToolActivity(3)); - yield* coalescer.offer({ kind: "synchronized" }); - - expect(yield* Clock.currentTimeMillis).toBe(startedAt); - expect( - (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) => - item.kind === "event" ? item.sequence : item.kind, - ), - ).toEqual([3, "synchronized"]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("fails and clears pending updates when their serialized payload fills the budget", () => - Effect.scoped( - Effect.gen(function* () { - const first = makeToolActivity(1); - const coalescer = yield* makeThreadLiveEventCoalescer({ - coalesceWindow: "500 millis", - maxSerializedBytes: Buffer.byteLength(encodeEvent(first)), - }); - yield* coalescer.offer(first); - expect(yield* coalescer.usage).toEqual({ - retainedItems: 1, - retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)), - }); - - const overflow = yield* coalescer.offer(makeToolActivity(2)).pipe(Effect.result); - expect(overflow._tag).toBe("Failure"); - yield* coalescer.closed; - expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 }); - const marker = yield* coalescer.offer({ kind: "synchronized" }).pipe(Effect.result); - expect(marker._tag).toBe("Failure"); - const delivered = yield* coalescer.stream.pipe(Stream.runCollect, Effect.result); - expect(delivered._tag).toBe("Failure"); - }), - ), - ); - it.effect("keeps the flush timer alive when an offer's shorter scope closes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 1f05d1c8d4de..8d1f613180a3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -2,17 +2,9 @@ import { expect, it } from "@effect/vitest"; import { CommandId, MessageId, - NodeId, - type OrchestrationV2Command, type OrchestrationV2Run, - type OrchestrationV2Subagent, - type OrchestrationV2ProviderThread, - ProviderThreadId, - ProviderDriverKind, - ProviderSessionId, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadProjection, - type OrchestrationV2ThreadShell, ProjectId, ProviderInstanceId, RunId, @@ -20,342 +12,23 @@ import { } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; -import * as Option from "effect/Option"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; 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 Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; -import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; -import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; -import { DispatchModeLimit, type DispatchModeRefusal } from "./DispatchModeLimit.ts"; - -it("stamps authoritative provenance on commands that create threads or messages", () => { - const command: OrchestrationV2Command = { - type: "thread.create", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:thread-management:create"), - threadId: ThreadId.make("thread:thread-management:create"), - projectId: ProjectId.make("project:thread-management"), - title: "Thread management", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }; - - expect( - ThreadManagementService.withCreationProvenance(command, { - createdBy: "user", - creationSource: "web", - }), - ).toMatchObject({ - createdBy: "user", - creationSource: "web", - }); -}); - -it("leaves commands that do not create durable authored content unchanged", () => { - const command: OrchestrationV2Command = { - type: "run.interrupt", - commandId: CommandId.make("command:thread-management:interrupt"), - threadId: ThreadId.make("thread:thread-management:interrupt"), - runId: RunId.make("run:thread-management:interrupt"), - }; - - expect( - ThreadManagementService.withCreationProvenance(command, { - createdBy: "user", - creationSource: "web", - }), - ).toBe(command); -}); - -it("identifies every existing thread that must be hydrated before dispatch", () => { - const sourceThreadId = ThreadId.make("thread:thread-management:source"); - const targetThreadId = ThreadId.make("thread:thread-management:target"); - const parentThreadId = ThreadId.make("thread:thread-management:parent"); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:thread-management:create"), - threadId: targetThreadId, - projectId: ProjectId.make("project:thread-management"), - title: "Created thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }), - ).toEqual([]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "thread.archive", - commandId: CommandId.make("command:thread-management:archive"), - threadId: targetThreadId, - }), - ).toEqual([targetThreadId]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "thread.visit", - commandId: CommandId.make("command:thread-management:visit"), - threadId: targetThreadId, - visitedAt: "2026-07-30T00:00:00.000Z", - }), - ).toEqual([]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "thread.mark-unread", - commandId: CommandId.make("command:thread-management:mark-unread"), - threadId: targetThreadId, - }), - ).toEqual([]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:thread-management:fork"), - sourceThreadId, - targetThreadId, - sourcePoint: { - type: "run", - runId: RunId.make("run:thread-management:source"), - }, - }), - ).toEqual([sourceThreadId]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "thread.merge_back", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:thread-management:merge"), - sourceThreadId, - targetThreadId, - sourcePoint: { - type: "run", - runId: RunId.make("run:thread-management:source"), - }, - }), - ).toEqual([sourceThreadId, targetThreadId]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "provider", - commandId: CommandId.make("command:thread-management:delegate"), - parentThreadId, - parentRunId: RunId.make("run:thread-management:parent"), - parentNodeId: NodeId.make("node:thread-management:parent"), - task: "Inspect the migration", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - interactionMode: "default", - }), - ).toEqual([parentThreadId]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "delegated_task.wake-policy", - commandId: CommandId.make("command:thread-management:wake-policy"), - parentThreadId, - taskId: NodeId.make("node:thread-management:delegated"), - completionWake: "always", - }), - ).toEqual([parentThreadId]); - - expect( - ThreadManagementService.existingThreadIdsForCommand({ - type: "thread.created.record", - commandId: CommandId.make("command:thread-management:record"), - parentThreadId, - parentRunId: RunId.make("run:thread-management:parent"), - parentNodeId: NodeId.make("node:thread-management:parent"), - targetThreadId, - targetRunId: null, - }), - ).toEqual([parentThreadId, targetThreadId]); -}); - -it("derives thread management messages from structural error attributes", () => { - const projectId = ProjectId.make("project:thread-management:errors"); - const threadId = ThreadId.make("thread:thread-management:errors"); - const runId = RunId.make("run:thread-management:errors"); - const messageId = MessageId.make("message:thread-management:errors"); - const infrastructureCause = new Error("private sqlite detail"); - - const threadNotFound = new ThreadManagementService.ThreadManagementThreadNotFoundError({ - projectId, - threadId, - }); - expect(threadNotFound).toMatchObject({ projectId, threadId }); - expect(threadNotFound.message).toBe(`Thread ${threadId} was not found in project ${projectId}.`); - - const runNotFound = new ThreadManagementService.ThreadManagementRunNotFoundError({ - threadId, - runId, - }); - expect(runNotFound).toMatchObject({ threadId, runId }); - expect(runNotFound.message).toBe(`Run ${runId} does not belong to thread ${threadId}.`); - - const archived = new ThreadManagementService.ThreadManagementThreadArchivedError({ - threadId, - }); - expect(archived).toMatchObject({ threadId }); - expect(archived.message).toBe(`Thread ${threadId} is archived and cannot receive messages.`); - - const notSteerable = new ThreadManagementService.ThreadManagementNoSteerableRunError({ - threadId, - mode: "restart", - }); - expect(notSteerable).toMatchObject({ - threadId, - mode: "restart", - }); - expect(notSteerable.message).toBe( - `Thread ${threadId} has no running turn that can be restarted.`, - ); - - const notInterruptible = new ThreadManagementService.ThreadManagementThreadNotInterruptibleError({ - threadId, - runId, - }); - expect(notInterruptible).toMatchObject({ threadId, runId }); - expect(notInterruptible.message).toBe(`Run ${runId} is not currently interruptible.`); - - const listFailure = new ThreadManagementService.ThreadManagementProjectThreadsListError({ - projectId, - cause: infrastructureCause, - }); - expect(listFailure).toMatchObject({ projectId, cause: infrastructureCause }); - expect(listFailure.message).toBe(`Unable to list threads in project ${projectId}.`); - expect(listFailure.message).not.toContain(infrastructureCause.message); - - const durableProjectionFailure = - new ThreadManagementService.ThreadManagementDurableRunProjectionError({ - threadId, - messageId, - }); - expect(durableProjectionFailure).toMatchObject({ threadId, messageId }); - expect(durableProjectionFailure.message).toBe( - `Message ${messageId} was accepted on thread ${threadId} without a durable run projection.`, - ); -}); const layerReceiptsTest = CommandReceiptStore.layer.pipe( Layer.provide(SqlitePersistence.layerMemory), ); -const layerThreadManagementTest = (settingsLayer: ReturnType) => - ThreadManagementService.layer.pipe( - Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), - Layer.provide(Layer.mock(Orchestrator.OrchestratorV2)({})), - Layer.provide(settingsLayer), - Layer.provide(layerReceiptsTest), - ); - -it.effect("refuses a new turn when the project effectively disables the instance", () => { - const projectId = ProjectId.make("project:thread-management:gate-disabled"); - const instanceId = ProviderInstanceId.make("codex_work"); - const commandId = CommandId.make("command:thread-management:gate-disabled"); - const layerTest = layerThreadManagementTest( - ServerSettings.layerTest({ - providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, - }, - }), - ); - - return Effect.gen(function* () { - const threads = yield* ThreadManagementService.ThreadManagementService; - const error = yield* Effect.flip( - threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }), - ); - - expect(error).toBeInstanceOf( - ThreadManagementService.ThreadManagementProviderInstanceDisabledError, - ); - expect(error).toMatchObject({ projectId, instanceId }); - expect(error.message).toBe( - `Provider instance "${instanceId}" is disabled for project ${projectId}.`, - ); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect("allows a new turn when the project enables an instance the machine disabled", () => { - const projectId = ProjectId.make("project:thread-management:gate-enabled"); - const instanceId = ProviderInstanceId.make("codex_work"); - const commandId = CommandId.make("command:thread-management:gate-enabled"); - const layerTest = layerThreadManagementTest( - ServerSettings.layerTest({ - providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, - }, - }), - ); - - return Effect.gen(function* () { - const threads = yield* ThreadManagementService.ThreadManagementService; - yield* threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect("inherits the machine value when the project has no override", () => { - const projectId = ProjectId.make("project:thread-management:gate-inherit"); - const instanceId = ProviderInstanceId.make("codex_work"); - const commandId = CommandId.make("command:thread-management:gate-inherit"); - const layerTest = layerThreadManagementTest( - ServerSettings.layerTest({ - providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, - }), - ); - - return Effect.gen(function* () { - const threads = yield* ThreadManagementService.ThreadManagementService; - const error = yield* Effect.flip( - threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }), - ); - - expect(error).toBeInstanceOf( - ThreadManagementService.ThreadManagementProviderInstanceDisabledError, - ); - }).pipe(Effect.provide(layerTest)); -}); - const layerDispatchChokepointTest = (settingsLayer: ReturnType) => (dispatched: Array) => ThreadManagementService.layer.pipe( @@ -474,39 +147,6 @@ it.effect( }, ); -it.effect("classifies projection infrastructure failures separately from a missing thread", () => { - const projectId = ProjectId.make("project:thread-management:projection-failure"); - const threadId = ThreadId.make("thread:thread-management:projection-failure"); - const infrastructureCause = new Error("sqlite read failed"); - const projectionError = new Orchestrator.OrchestratorProjectionError({ - threadId, - cause: infrastructureCause, - }); - const layerTest = ThreadManagementService.layer.pipe( - Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), - Layer.provide( - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadProjection: () => Effect.fail(projectionError), - }), - ), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerReceiptsTest), - ); - - return Effect.gen(function* () { - const service = yield* ThreadManagementService.ThreadManagementService; - const error = yield* Effect.flip(service.getProjectThread({ projectId, threadId })); - - expect(error).toBeInstanceOf(ThreadManagementService.ThreadManagementProjectionLoadError); - expect(error).toMatchObject({ - projectId, - threadId, - cause: projectionError, - }); - expect(error.message).toBe(`Unable to load thread ${threadId} in project ${projectId}.`); - }).pipe(Effect.provide(layerTest)); -}); - it.effect("uses thread-not-found only after a projection loads outside the project", () => { const projectId = ProjectId.make("project:thread-management:requested"); const otherProjectId = ProjectId.make("project:thread-management:other"); @@ -539,168 +179,6 @@ it.effect("uses thread-not-found only after a projection loads outside the proje }).pipe(Effect.provide(layerTest)); }); -it.effect("preserves failed legacy materialization when reading checkpoint context", () => { - const threadId = ThreadId.make("thread:thread-management:checkpoint-import-failure"); - const importError = new LegacyV1ThreadImporter.LegacyV1ThreadImportError({ - threadId, - operation: "hydrate transcript for", - cause: new Error("checkpoint import failed"), - }); - const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe( - Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), - Layer.provide( - Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - getCheckpointContext: () => - Effect.succeed({ runs: [], checkpointScopes: [], checkpoints: [] }), - }), - Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ - ensureTranscript: () => Effect.fail(importError), - }), - ), - ), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerReceiptsTest), - ); - return Effect.gen(function* () { - const service = yield* ThreadManagementService.ThreadManagementService; - const error = yield* service.getCheckpointContext(threadId).pipe(Effect.flip); - expect(error).toBeInstanceOf(Orchestrator.OrchestratorProjectionError); - expect(error).toMatchObject({ threadId, cause: importError }); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect.each([ - { finalStatus: "completed" as const, timedOut: false }, - { finalStatus: "failed" as const, timedOut: false }, - { finalStatus: "cancelled" as const, timedOut: false }, - { finalStatus: "interrupted" as const, timedOut: false }, - { finalStatus: "rolled_back" as const, timedOut: false }, - { finalStatus: "running" as const, timedOut: true }, - { finalStatus: "missing" as const }, -])("waitForThread timeout final read when selected run is $finalStatus", (scenario) => - Effect.gen(function* () { - const projectId = ProjectId.make("project:thread-management:wait-timeout"); - const threadId = ThreadId.make("thread:thread-management:wait-timeout"); - const runId = RunId.make("run:thread-management:wait-timeout"); - const subscribed = yield* Deferred.make(); - let reads = 0; - const projection = (status: OrchestrationV2Run["status"] | "missing") => - ({ - thread: { id: threadId, projectId, deletedAt: null }, - runs: status === "missing" ? [] : [{ id: runId, status }], - }) as unknown as OrchestrationV2ThreadProjection; - const layerTest = ThreadManagementService.layer.pipe( - Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), - Layer.provide( - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadEventSequence: () => Effect.succeed(0), - restartContinuationPending: () => Effect.succeed(false), - streamStoredEventsFrom: () => - Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.never), - ), - getThreadRecords: () => - Effect.sync(() => { - reads += 1; - return projection(reads === 1 ? "running" : scenario.finalStatus); - }), - }), - ), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerReceiptsTest), - ); - const service = yield* ThreadManagementService.ThreadManagementService.pipe( - Effect.provide(layerTest), - ); - const fiber = yield* service - .waitForThread({ - projectId, - threadId, - runId, - timeoutMs: 1, - }) - .pipe(Effect.result, Effect.forkChild); - yield* Deferred.await(subscribed); - yield* TestClock.adjust(Duration.millis(1)); - const result = yield* Fiber.join(fiber); - - if (scenario.finalStatus === "missing") { - expect(result._tag).toBe("Failure"); - expect(result).toMatchObject({ - failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError), - }); - expect(result).toMatchObject({ - failure: { threadId, runId }, - }); - } else { - expect(result._tag).toBe("Success"); - expect(result).toMatchObject({ - success: { - threadId, - timedOut: scenario.timedOut, - run: { id: runId, status: scenario.finalStatus }, - }, - }); - } - }), -); - -it.effect("waitForThread reads the run again only when the run updates", () => - Effect.gen(function* () { - const projectId = ProjectId.make("project:thread-management:wait-event"); - const threadId = ThreadId.make("thread:thread-management:wait-event"); - const runId = RunId.make("run:thread-management:wait-event"); - const subscribed = yield* Deferred.make(); - const events = yield* Queue.unbounded(); - let status: OrchestrationV2Run["status"] = "running"; - let reads = 0; - const stored = (sequence: number, event: object) => - ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent; - const layerTest = ThreadManagementService.layer.pipe( - Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), - Layer.provide( - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadEventSequence: () => Effect.succeed(0), - restartContinuationPending: () => Effect.succeed(false), - streamStoredEventsFrom: (input) => - input?.eventType === "run.updated" - ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.fromQueue(events)), - ) - : Stream.never, - getThreadRecords: () => - Effect.sync(() => { - reads += 1; - return { - thread: { id: threadId, projectId, deletedAt: null }, - runs: [{ id: runId, status }], - } as unknown as OrchestrationV2ThreadProjection; - }), - }), - ), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerReceiptsTest), - ); - const service = yield* ThreadManagementService.ThreadManagementService.pipe( - Effect.provide(layerTest), - ); - const fiber = yield* service - .waitForThread({ projectId, threadId, runId, timeoutMs: 60 * 60 * 1_000 }) - .pipe(Effect.forkChild); - yield* Deferred.await(subscribed); - status = "completed"; - yield* Queue.offer(events, stored(1, { type: "run.updated", payload: { id: "other-run" } })); - yield* Queue.offer(events, stored(2, { type: "run.updated", payload: { id: runId, status } })); - const result = yield* Fiber.join(fiber); - - expect(result).toMatchObject({ timedOut: false, run: { id: runId, status: "completed" } }); - expect(reads).toBe(2); - }), -); - it.effect("waitForThread follows a restart continuation instead of the cut run", () => Effect.gen(function* () { const projectId = ProjectId.make("project:thread-management:wait-restart"); @@ -819,224 +297,6 @@ it.effect("waitForThread returns the cut run once its continuation is declined", }), ); -it.effect.each([ - "running", - "missing", - "completed", - "unsupported", - "missing-native-id", - "wrong-thread", - "no-session", - "unsupported-runtime", - "provider-failed", - "provider-timeout", - "within-limit", - "runtime-raised", - "interaction-raised", -] as const)("stops a persisted workflow with state %s", (state) => - Effect.gen(function* () { - const threadId = ThreadId.make("thread:workflow-stop"); - const subagentId = NodeId.make("node:workflow-stop"); - const runId = RunId.make("run:workflow-stop"); - const providerThreadId = ProviderThreadId.make("provider-thread:workflow-stop"); - const providerSessionId = ProviderSessionId.make("provider-session:workflow-stop"); - const driver = ProviderDriverKind.make("claudeAgent"); - const instanceId = ProviderInstanceId.make("claudeAgent"); - const now = yield* DateTime.now; - let targetModes: Pick = { - runtimeMode: "approval-required", - interactionMode: "plan", - }; - const task: OrchestrationV2Subagent = { - id: subagentId, - threadId, - runId, - parentNodeId: NodeId.make("node:workflow-parent"), - origin: "provider_native", - createdBy: "agent", - driver, - providerInstanceId: instanceId, - providerThreadId: null, - childThreadId: null, - nativeTaskRef: - state === "missing-native-id" - ? null - : { - driver, - nativeId: "native-workflow-id", - strength: "strong", - }, - workflow: { phases: [], agents: [] }, - prompt: "Run the workflow", - title: null, - model: null, - status: state === "completed" ? "completed" : "running", - result: null, - startedAt: now, - completedAt: null, - updatedAt: now, - ...(state === "unsupported" ? { driver: ProviderDriverKind.make("codex") } : {}), - }; - const providerThread: OrchestrationV2ProviderThread = { - id: providerThreadId, - driver, - providerInstanceId: instanceId, - providerSessionId, - appThreadId: state === "wrong-thread" ? ThreadId.make("other-thread") : threadId, - ownerNodeId: null, - nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 1, - handoffIds: [], - forkedFrom: null, - pendingBackgroundTasks: [], - contextUsage: null, - nativeMetadata: null, - createdAt: now, - updatedAt: now, - }; - const calls: Array<{ taskId: string; providerThread: OrchestrationV2ProviderThread }> = []; - const stopStarted = yield* Deferred.make(); - const runtime = { - driver, - providerSessionId, - instanceId, - ...(state === "unsupported-runtime" - ? {} - : { - stopTask: (input: (typeof calls)[number]) => - state === "provider-failed" - ? Effect.fail( - new ProviderAdapter.ProviderAdapterProtocolError({ - driver, - detail: "private provider detail", - }), - ) - : state === "provider-timeout" - ? Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.sync(() => { - calls.push(input); - }), - }), - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; - const layerTest = ThreadManagementService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadRecords: () => - Effect.sync( - () => - ({ - thread: { id: threadId, deletedAt: null, ...targetModes }, - subagents: state === "missing" ? [] : [task], - runs: [{ id: runId, providerThreadId }], - providerThreads: [providerThread], - }) as unknown as OrchestrationV2ThreadProjection, - ), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - get: (id) => { - expect(id).toBe(providerSessionId); - return Effect.succeed(state === "no-session" ? Option.none() : Option.some(runtime)); - }, - }), - ), - ), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerReceiptsTest), - ); - yield* Effect.gen(function* () { - const service = yield* ThreadManagementService.ThreadManagementService; - const refused = yield* Ref.make(undefined); - const stop = service - .stopWorkflow({ threadId, subagentId }) - .pipe( - Effect.provideService( - DispatchModeLimit, - state === "within-limit" || state === "runtime-raised" || state === "interaction-raised" - ? { runtimeMode: "approval-required", interactionMode: "plan", refused } - : undefined, - ), - Effect.result, - ); - const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; - const result = - state === "runtime-raised" || state === "interaction-raised" - ? yield* Effect.gen(function* () { - const started = yield* Deferred.make(); - const pending = yield* executor.withLock( - threadId, - Effect.gen(function* () { - const fiber = yield* Deferred.succeed(started, undefined).pipe( - Effect.andThen(stop), - Effect.forkChild, - ); - yield* Deferred.await(started); - targetModes = - state === "runtime-raised" - ? { ...targetModes, runtimeMode: "full-access" } - : { ...targetModes, interactionMode: "default" }; - return fiber; - }), - ); - return yield* Fiber.join(pending); - }) - : state === "provider-timeout" - ? yield* Effect.gen(function* () { - const finished = yield* Ref.make(false); - const pending = yield* stop.pipe( - Effect.tap(() => Ref.set(finished, true)), - Effect.forkChild, - ); - yield* Deferred.await(stopStarted); - yield* TestClock.adjust("15 seconds"); - expect(yield* Ref.get(finished)).toBe(true); - const result = yield* Fiber.join(pending); - expect(yield* executor.withLock(threadId, Effect.succeed("lock-released"))).toBe( - "lock-released", - ); - return result; - }) - : yield* stop; - if (state === "running" || state === "within-limit") { - expect(result).toMatchObject({ _tag: "Success" }); - expect(calls).toEqual([{ taskId: "native-workflow-id", providerThread }]); - expect(task.status).toBe("running"); - } else { - expect(result).toMatchObject({ - _tag: "Failure", - failure: { - _tag: "OrchestrationV2StopWorkflowError", - threadId, - subagentId, - reason: - state === "missing" || state === "completed" - ? "not-running" - : state === "unsupported" || state === "unsupported-runtime" - ? "unsupported" - : state === "provider-failed" || state === "provider-timeout" - ? "stop-failed" - : "unavailable", - }, - }); - expect(calls).toEqual([]); - if (state === "provider-timeout") { - expect(result).toMatchObject({ failure: { cause: { _tag: "TimeoutError" } } }); - } - if (state === "runtime-raised" || state === "interaction-raised") { - expect(yield* Ref.get(refused)).toEqual({ - threadId, - ...targetModes, - mode: state === "runtime-raised" ? "runtime" : "interaction", - }); - } - } - }).pipe(Effect.provide(Layer.merge(layerTest, ThreadCommandExecutor.layer))); - }), -); - it.effect.each([ { status: "completed" as const, settles: true }, { status: "failed" as const, settles: false }, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index af44e898021b..543b3e2f89fe 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -80,7 +80,7 @@ export function withCreationProvenance( } } -export function existingThreadIdsForCommand( +function existingThreadIdsForCommand( command: OrchestrationV2ServerCommand, ): ReadonlyArray { switch (command.type) { diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index d548dd507509..2a884d19d3fb 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -7,9 +7,6 @@ import { ChatAttachmentId, CommandId, EventId, - MessageId, - ProjectId, - ProviderInstanceId, RuntimeRequestId, ThreadId, TurnItemId, @@ -29,7 +26,7 @@ import { OrchestratorDispatchError, } from "./Orchestrator.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; -import { dispatchCommand, sendToThread } from "./ThreadMessageIntake.ts"; +import { dispatchCommand } from "./ThreadMessageIntake.ts"; const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { prefix: "t3-question-intake-", @@ -92,41 +89,6 @@ const answeredRespondEvents = (input: { }, ]; -const textOnlyRespondEvents = (input: { - readonly threadId: ThreadId; - readonly requestId: string; -}): OrchestrationV2StoredEvent[] => [ - { - sequence: 1, - commandId: null, - event: { - id: EventId.make("evt-recorded-answer"), - type: "turn-item.updated", - threadId: input.threadId, - occurredAt: NOW, - payload: { - id: TurnItemId.make("item-recorded-answer"), - type: "user_input_request", - threadId: input.threadId, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "completed", - title: null, - startedAt: null, - completedAt: NOW, - updatedAt: NOW, - requestId: RuntimeRequestId.make(input.requestId), - questions: [], - }, - }, - }, -]; - it.effect("claims question uploads and passes readable paths through the V2 request command", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -165,123 +127,6 @@ it.effect("claims question uploads and passes readable paths through the V2 requ }).pipe(Effect.provide(layerIntakeTest)), ); -it.effect("rolls back earlier question claims when a later pending upload is missing", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const threadId = ThreadId.make("thread-q-fail"); - const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); - const existingId = ChatAttachmentId.make("thread-q-fail-00000000-0000-4000-8000-000000000001"); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${pendingId}.png`), - new Uint8Array([1, 2, 3]), - ); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${existingId}.png`), - new Uint8Array([9, 9, 9, 9]), - ); - const captured: OrchestrationV2ServerCommand[] = []; - const result = yield* dispatchCommand({ - type: "runtime-request.respond", - commandId: CommandId.make("answer-missing-pending"), - threadId, - requestId: RuntimeRequestId.make("request-missing-pending"), - answers: { q1: ["one"], q2: ["two"] }, - attachmentsByQuestionId: { - q1: [ - { - type: "image", - id: existingId, - name: "keep.png", - mimeType: "image/png", - sizeBytes: 4, - }, - { - type: "image", - id: pendingId, - name: "screen.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - q2: [ - { - type: "image", - id: ChatAttachmentId.make(createPendingAttachmentId()!), - name: "gone.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - }, - }).pipe(Effect.provide(layerFailingDispatch(captured)), Effect.result); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(result.failure._tag).toBe("AttachmentClaimError"); - } - expect(captured).toHaveLength(0); - expect(NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toEqual( - Buffer.from([1, 2, 3]), - ); - expect( - NodeFS.readdirSync(config.attachmentsDir).filter((entry) => - entry.startsWith("thread-q-fail-"), - ), - ).toEqual([`${existingId}.png`]); - }).pipe(Effect.provide(layerIntakeTest)), -); - -it.effect("rolls back earlier question claims when attachment path preparation fails", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${pendingId}.png`), - new Uint8Array([1, 2, 3]), - ); - const captured: OrchestrationV2ServerCommand[] = []; - const result = yield* dispatchCommand({ - type: "runtime-request.respond", - commandId: CommandId.make("answer-missing-claimed"), - threadId: ThreadId.make("thread-path-fail"), - requestId: RuntimeRequestId.make("request-missing-claimed"), - answers: { q1: ["one"], q2: ["two"] }, - attachmentsByQuestionId: { - q1: [ - { - type: "image", - id: pendingId, - name: "screen.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - q2: [ - { - type: "image", - id: ChatAttachmentId.make("thread-path-fail-00000000-0000-4000-8000-000000000099"), - name: "missing.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - }, - }).pipe(Effect.provide(layerFailingDispatch(captured)), Effect.result); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(result.failure._tag).toBe("AttachmentClaimError"); - } - expect(captured).toHaveLength(0); - expect(NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toEqual( - Buffer.from([1, 2, 3]), - ); - expect( - NodeFS.readdirSync(config.attachmentsDir).filter((entry) => - entry.startsWith("thread-path-fail-"), - ), - ).toEqual([]); - }).pipe(Effect.provide(layerIntakeTest)), -); - it.effect("retains claimed copies when dispatch failure may have been accepted", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -432,215 +277,6 @@ it.effect("releases claimed copies when dispatch replays an earlier accepted res }).pipe(Effect.provide(layerIntakeTest)), ); -it.effect("releases claimed copies when the recorded answer has no attachments", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const threadId = ThreadId.make("thread-empty-answer"); - const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${pendingId}.png`), - new Uint8Array([1, 2, 3]), - ); - const storedEvents = answeredRespondEvents({ - threadId, - requestId: "request-empty-answer", - attachmentsByQuestionId: {}, - }); - const result = yield* dispatchCommand({ - type: "runtime-request.respond", - commandId: CommandId.make("answer-empty"), - threadId, - requestId: RuntimeRequestId.make("request-empty-answer"), - answers: { q: ["one"] }, - attachmentsByQuestionId: { - q: [ - { - type: "image", - id: pendingId, - name: "screen.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - }, - }).pipe( - Effect.provide( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: () => Effect.succeed({ sequence: 1, storedEvents }), - }), - ), - Effect.result, - ); - expect(result._tag).toBe("Success"); - expect( - NodeFS.readdirSync(config.attachmentsDir).filter((entry) => - entry.startsWith("thread-empty-answer-"), - ), - ).toEqual([]); - expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true); - }).pipe(Effect.provide(layerIntakeTest)), -); - -it.effect("releases claimed copies when the recorded answer was text-only", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const threadId = ThreadId.make("thread-text-only-answer"); - const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${pendingId}.png`), - new Uint8Array([1, 2, 3]), - ); - const storedEvents = textOnlyRespondEvents({ - threadId, - requestId: "request-text-only", - }); - const result = yield* dispatchCommand({ - type: "runtime-request.respond", - commandId: CommandId.make("answer-text-only"), - threadId, - requestId: RuntimeRequestId.make("request-text-only"), - answers: { q: ["one"] }, - attachmentsByQuestionId: { - q: [ - { - type: "image", - id: pendingId, - name: "screen.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - }, - }).pipe( - Effect.provide( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: () => Effect.succeed({ sequence: 1, storedEvents }), - }), - ), - Effect.result, - ); - expect(result._tag).toBe("Success"); - expect( - NodeFS.readdirSync(config.attachmentsDir).filter((entry) => - entry.startsWith("thread-text-only-answer-"), - ), - ).toEqual([]); - expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true); - }).pipe(Effect.provide(layerIntakeTest)), -); - -it.effect("retains claimed copies referenced by a fresh recorded answer", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const threadId = ThreadId.make("thread-fresh-answer"); - const firstId = ChatAttachmentId.make(createPendingAttachmentId()!); - const secondId = ChatAttachmentId.make(createPendingAttachmentId()!); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${firstId}.png`), - new Uint8Array([1, 2, 3]), - ); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${secondId}.png`), - new Uint8Array([7, 7]), - ); - const result = yield* dispatchCommand({ - type: "runtime-request.respond", - commandId: CommandId.make("answer-fresh"), - threadId, - requestId: RuntimeRequestId.make("request-fresh"), - answers: { q1: ["one"], q2: ["two"] }, - attachmentsByQuestionId: { - q1: [ - { - type: "image", - id: firstId, - name: "one.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - q2: [ - { - type: "image", - id: secondId, - name: "two.png", - mimeType: "image/png", - sizeBytes: 2, - }, - ], - }, - }).pipe( - Effect.provide( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: (command) => - Effect.succeed({ - sequence: 1, - storedEvents: answeredRespondEvents({ - threadId, - requestId: "request-fresh", - attachmentsByQuestionId: - command.type === "runtime-request.respond" - ? (command.attachmentsByQuestionId ?? {}) - : {}, - }), - }), - }), - ), - Effect.result, - ); - expect(result._tag).toBe("Success"); - expect( - NodeFS.readdirSync(config.attachmentsDir).filter((entry) => - entry.startsWith("thread-fresh-answer-"), - ), - ).toHaveLength(2); - }).pipe(Effect.provide(layerIntakeTest)), -); - -it.effect("releases claimed copies when preparation dies with a defect", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${pendingId}.png`), - new Uint8Array([1, 2, 3]), - ); - const poisoned = { - get id(): string { - throw new Error("poisoned"); - }, - }; - const captured: OrchestrationV2ServerCommand[] = []; - const result = yield* dispatchCommand({ - type: "runtime-request.respond", - commandId: CommandId.make("answer-defect"), - threadId: ThreadId.make("thread-defect"), - requestId: RuntimeRequestId.make("request-defect"), - answers: { q1: ["one"], q2: ["two"] }, - attachmentsByQuestionId: { - q1: [ - { - type: "image", - id: pendingId, - name: "screen.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - q2: [poisoned] as never, - }, - }).pipe(Effect.provide(layerFailingDispatch(captured)), Effect.exit); - expect(result._tag).toBe("Failure"); - expect(captured).toHaveLength(0); - expect( - NodeFS.readdirSync(config.attachmentsDir).filter((entry) => - entry.startsWith("thread-defect-"), - ), - ).toEqual([]); - expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true); - }).pipe(Effect.provide(layerIntakeTest)), -); - it.effect("a retried response re-claims the preserved pending uploads", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -723,79 +359,3 @@ it.effect("a retried response re-claims the preserved pending uploads", () => ).toHaveLength(2); }).pipe(Effect.provide(layerIntakeTest)), ); - -it.effect("applies the image budget across all questions before dispatch", () => - Effect.gen(function* () { - const attachments = Array.from({ length: 5 }, (_, index) => ({ - type: "image" as const, - id: ChatAttachmentId.make(`existing-budget-${index}`), - name: `${index}.png`, - mimeType: "image/png", - sizeBytes: 10 * 1024 * 1024, - })); - const captured: OrchestrationV2ServerCommand[] = []; - const result = yield* Effect.exit( - dispatchCommand({ - type: "runtime-request.respond", - commandId: CommandId.make("command-image-budget"), - threadId: ThreadId.make("thread-image-budget"), - requestId: RuntimeRequestId.make("question-image-budget"), - answers: {}, - attachmentsByQuestionId: { q1: attachments, q2: attachments }, - }).pipe(Effect.provide(layerFailingDispatch(captured))), - ); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") expect(String(result.cause)).toContain("80 MiB"); - expect(captured).toEqual([]); - }).pipe(Effect.provide(layerIntakeTest)), -); - -it.effect("releases claimed copies when a project disables the message's provider instance", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); - NodeFS.writeFileSync( - NodePath.join(config.attachmentsDir, `${pendingId}.png`), - new Uint8Array([1, 2, 3]), - ); - const result = yield* sendToThread({ - projectId: ProjectId.make("project-disabled-send"), - commandId: CommandId.make("command-disabled-send"), - threadId: ThreadId.make("thread-disabled-send"), - messageId: MessageId.make("message-disabled-send"), - text: "Hello", - attachments: [ - { - type: "image", - id: pendingId, - name: "screen.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ], - mode: "auto", - createdBy: "user", - creationSource: "web", - }).pipe( - Effect.provide( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - sendToThread: (input) => - Effect.fail( - new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ - projectId: input.projectId, - instanceId: ProviderInstanceId.make("codex"), - }), - ), - }), - ), - Effect.result, - ); - expect(result._tag).toBe("Failure"); - expect( - NodeFS.readdirSync(config.attachmentsDir).filter((entry) => - entry.startsWith("thread-disabled-send-"), - ), - ).toEqual([]); - expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true); - }).pipe(Effect.provide(layerIntakeTest)), -); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index e47191c3fbc0..f3d28514e108 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -1,11 +1,9 @@ import { - EventId, ProjectId, ProviderInstanceId, SourceControlProviderKind, ThreadId, type OrchestrationProjectShell, - type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, type OrchestrationV2ServerCommand, } from "@t3tools/contracts"; @@ -17,10 +15,7 @@ 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 Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as GitManager from "../git/GitManager.ts"; import * as ProjectStore from "./ProjectStore.ts"; @@ -31,72 +26,6 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ThreadPullRequestService from "./ThreadPullRequestService.ts"; describe("ThreadPullRequestServiceV2 project guard", () => { - it.effect("discovers a repository from a project shell without enrichment", () => - Effect.gen(function* () { - const project: OrchestrationProjectShell = { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/workspace/project", - defaultModelSelection: null, - scripts: [], - repositoryIdentity: null, - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - }; - let resolvedRoot: string | null = null; - const result = yield* ThreadPullRequestService.resolveProjectForPullRequestDiscovery( - project, - { - resolve: (root) => { - resolvedRoot = root; - return Effect.succeed({ - canonicalKey: "github.com/pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - provider: "github" as const, - displayName: "pingdotgg/t3code", - owner: "pingdotgg", - name: "t3code", - }); - }, - }, - ); - expect(resolvedRoot).toBe("/workspace/project"); - expect(result.repository).toBe("pingdotgg/t3code"); - expect(result.project.repositoryIdentity?.canonicalKey).toBe("github.com/pingdotgg/t3code"); - }), - ); - - it.effect("refreshes the cached repository identity only when asked", () => - Effect.gen(function* () { - const project: OrchestrationProjectShell = { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/workspace/project", - defaultModelSelection: null, - scripts: [], - repositoryIdentity: null, - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - }; - const refreshes: Array = []; - const resolver = { - resolve: (_root: string, options?: { readonly refresh?: boolean }) => { - refreshes.push(options?.refresh); - return Effect.succeed(null); - }, - }; - yield* ThreadPullRequestService.resolveProjectForPullRequestDiscovery(project, resolver); - yield* ThreadPullRequestService.resolveProjectForPullRequestDiscovery(project, resolver, { - refresh: true, - }); - expect(refreshes).toEqual([false, true]); - }), - ); - it("rejects a pull-request result when the project root changes before dispatch", () => { const currentProject = Option.some({ workspaceRoot: "/workspace/replaced", @@ -109,28 +38,6 @@ describe("ThreadPullRequestServiceV2 project guard", () => { ), ).toBe(false); }); - - it("rejects a pull-request result when the project was deleted before dispatch", () => { - expect( - ThreadPullRequestService.projectWorkspaceMatchesSnapshot( - Option.none>(), - "/workspace/original", - ), - ).toBe(false); - }); - - it("accepts a pull-request result while the project root is unchanged", () => { - const currentProject = Option.some({ - workspaceRoot: "/workspace/original", - } satisfies Pick); - - expect( - ThreadPullRequestService.projectWorkspaceMatchesSnapshot( - currentProject, - "/workspace/original", - ), - ).toBe(true); - }); }); describe("ThreadPullRequestServiceV2 reads", () => { @@ -288,150 +195,4 @@ describe("ThreadPullRequestServiceV2 reads", () => { }), ), ); - - it.effect("an event for one thread reads that thread's shell, not every thread's", () => - Effect.scoped( - Effect.gen(function* () { - const thread = threadShell("updated-thread"); - const other = threadShell("other-thread"); - const activation = yield* Deferred.make(); - const events = yield* PubSub.unbounded(); - const reads = yield* Queue.unbounded< - ThreadId | { readonly location?: string; readonly unsettledOnly?: boolean } - >(); - const layerDependencies = Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.fromPubSub(events), - getShellSnapshot: (options) => - Queue.offer(reads, options ?? {}).pipe( - Effect.as({ - schemaVersion: 2, - snapshotSequence: 1, - threads: [thread, other], - archivedThreads: [], - }), - ), - getThreadEventSequence: () => Effect.succeed(1), - getThreadShell: (threadId) => - Queue.offer(reads, threadId).pipe( - Effect.as([thread, other].find((candidate) => candidate.id === threadId) ?? null), - ), - }), - Layer.mock(ProjectStore.ProjectStoreV2)({ - listShells: () => Effect.succeed([]), - }), - Layer.mock(GitManager.GitManager)({}), - Layer.mock(PullRequestService.PullRequestService)({}), - Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), - Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), - Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), - }), - ), - FileSystem.layerNoop({}), - ); - - yield* Effect.gen(function* () { - const service = yield* ThreadPullRequestService.make; - yield* service.start(); - yield* Deferred.succeed(activation, undefined); - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: false }); - yield* service.drain; - yield* PubSub.publish(events, { - type: "thread.metadata-updated", - id: EventId.make("event:metadata"), - threadId: thread.id, - occurredAt: NOW, - payload: { - createdBy: thread.createdBy, - creationSource: thread.creationSource, - id: thread.id, - projectId: thread.projectId, - title: thread.title, - providerInstanceId: thread.providerInstanceId, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - branch: thread.branch, - worktreePath: thread.worktreePath, - activeProviderThreadId: thread.activeProviderThreadId, - lineage: thread.lineage, - forkedFrom: null, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); - expect(yield* Queue.take(reads)).toBe(thread.id); - yield* service.drain; - expect(yield* Queue.size(reads)).toBe(0); - }).pipe(Effect.provide(layerDependencies)); - }), - ), - ); - - it.effect("periodic sweeps read only unsettled threads once backfill is done", () => - Effect.scoped( - Effect.gen(function* () { - const settled = { - ...threadShell("settled-thread"), - branch: "feature/settled", - settledOverride: "settled" as const, - settledAt: NOW, - }; - const activation = yield* Deferred.make(); - const reads = yield* Queue.unbounded<{ - readonly location?: string; - readonly unsettledOnly?: boolean; - }>(); - const layerDependencies = Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.never, - getShellSnapshot: (options) => - Queue.offer(reads, options ?? {}).pipe( - Effect.as({ - schemaVersion: 2, - snapshotSequence: 1, - threads: options?.unsettledOnly ? [] : [settled], - archivedThreads: [], - }), - ), - }), - Layer.mock(ProjectStore.ProjectStoreV2)({ - listShells: () => Effect.succeed([]), - }), - Layer.mock(GitManager.GitManager)({}), - Layer.mock(PullRequestService.PullRequestService)({}), - Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), - Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), - Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), - }), - ), - FileSystem.layerNoop({}), - ); - - yield* Effect.gen(function* () { - const service = yield* ThreadPullRequestService.make; - yield* service.start(); - yield* Deferred.succeed(activation, undefined); - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: false }); - yield* service.drain; - yield* TestClock.adjust("1 minute"); - expect(yield* Queue.take(reads)).toEqual({ location: "active", unsettledOnly: true }); - yield* service.drain; - }).pipe(Effect.provide(layerDependencies)); - }), - ), - ); }); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 6382a448cf3d..320166fbc6a0 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -63,7 +63,7 @@ function pullRequestMatchesProject( ); } -export const resolveProjectForPullRequestDiscovery = Effect.fn( +const resolveProjectForPullRequestDiscovery = Effect.fn( "ThreadPullRequestServiceV2.resolveProject", )(function* ( project: OrchestrationProjectShell, diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 7ea506c7cab7..f12355a49e55 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -178,30 +178,6 @@ it.layer(layerTest)("ThreadSearch", (it) => { assert.deepEqual((yield* search.search({ query: "ne%le" })).matches, []); }), ); - - it.effect("reports an unreadable match as a decode failure", () => - Effect.gen(function* () { - const projections = yield* ProjectionStore.ProjectionStoreV2; - const search = yield* ThreadSearch.ThreadSearch; - const sql = yield* SqlClient.SqlClient; - const project = ProjectId.make("project:search-corrupt"); - const threadId = ThreadId.make("thread:corrupt"); - yield* createProject(project); - yield* Effect.forEach( - [thread(threadId, project), message(threadId, "corrupt", "user", "20260927")], - projections.apply, - { discard: true }, - ); - yield* sql` - UPDATE orchestration_v2_projection_messages - SET payload_json = json_set(payload_json, '$.text', 20260927) - WHERE message_id = 'corrupt' - `; - - const error = yield* Effect.flip(search.search({ query: "0260927" })); - assert.equal(error.operation, "decode"); - }), - ); }); it.effect("searches on the reader worker while the writer is busy", () => diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 28a62cdfe45c..10caf988e475 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -6,7 +6,6 @@ import { ProjectId, EventId, ProviderInstanceId, - ProviderSessionId, ThreadId, type OrchestrationProjectShell, type OrchestrationV2AppThread, @@ -102,54 +101,6 @@ function shell(overrides: Partial = {}): SettlementShell { } describe("isAutoSettlementCandidate", () => { - it("excludes overridden, pinned, blocked, and working threads", () => { - expect(ThreadSettlementService.isAutoSettlementCandidate(shell(), NOW_MS)).toBe(true); - expect( - ThreadSettlementService.isAutoSettlementCandidate(shell({ archivedAt: at(-1) }), NOW_MS), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate( - shell({ settledOverride: "settled" }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate( - shell({ settledOverride: "active" }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate(shell({ pinnedAt: at(-1) }), NOW_MS), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate( - shell({ autoSettleDisabledAt: at(-1) }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate( - shell({ activityRunStatus: "running" }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate( - shell({ - pendingRuntimeRequest: { kind: "approval" } as never, - }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate( - shell({ pendingBackgroundTasks: [{ taskId: "review", kind: "subagent" }] }), - NOW_MS, - ), - ).toBe(false); - }); - it("settles a thread whose only background work is a command left running", () => { expect( ThreadSettlementService.isAutoSettlementCandidate( @@ -202,41 +153,6 @@ describe("isAutoSettlementCandidate", () => { }); }); -describe("threadHasQueuedTurnStart", () => { - it("holds a fresh unadopted user message inside the grace window only", () => { - const fresh = shell({ latestUserMessageAt: at(-1_000) }); - expect(ThreadSettlementService.threadHasQueuedTurnStart(fresh, NOW_MS)).toBe(true); - expect( - ThreadSettlementService.threadHasQueuedTurnStart( - shell({ - latestUserMessageAt: at(-1_000), - latestRunId: "run-1" as never, - latestRunRequestedAt: at(-500), - }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.threadHasQueuedTurnStart( - shell({ latestUserMessageAt: at(-ThreadSettlementService.QUEUED_TURN_START_GRACE_MS - 1) }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.threadHasQueuedTurnStart( - shell({ latestUserMessageAt: at(ThreadSettlementService.QUEUED_TURN_START_GRACE_MS + 1) }), - NOW_MS, - ), - ).toBe(false); - expect( - ThreadSettlementService.threadHasQueuedTurnStart( - shell({ latestUserMessageAt: at(-1_000), status: "failed" }), - NOW_MS, - ), - ).toBe(false); - }); -}); - describe("resolveAutoSettlementAt", () => { it("keeps threads with a PR from inactivity settlement only when asked", () => { const input = { @@ -440,31 +356,10 @@ describe("resolveAutoSettlementAt", () => { }), ).toEqual(at(-45 * 60 * 1_000)); }); - - it("settles inactive threads even when their pull request remains open", () => { - const input = { - thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), - pullRequest: { state: "open" as const }, - nowMs: NOW_MS, - autoSettleAfterDays: 2, - autoSettleOnMerge: true, - }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-30 * DAY_MS)); - expect( - ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null }), - ).toBeNull(); - expect( - ThreadSettlementService.resolveAutoSettlementAt({ - ...input, - thread: shell({ latestUserMessageAt: at(-DAY_MS) }), - }), - ).toBeNull(); - }); }); const NOW = "2026-08-28T12:00:00.000Z"; const PROJECT_ID = ProjectId.make("settlement-project"); -const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); describe("autoSettlementSettingsKey", () => { it("distinguishes a project that inherits the threshold from one that disables it", () => { @@ -480,21 +375,6 @@ describe("autoSettlementSettingsKey", () => { }); assert.notStrictEqual(inherits, never); }); - - it("ignores project overrides that do not touch settlement", () => { - const base = ThreadSettlementService.autoSettlementSettingsKey({ - ...DEFAULT_SERVER_SETTINGS, - projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } }, - }); - const unrelated = ThreadSettlementService.autoSettlementSettingsKey({ - ...DEFAULT_SERVER_SETTINGS, - projectSettingsOverrides: { - [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" }, - [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true }, - }, - }); - assert.strictEqual(base, unrelated); - }); }); type AutoSettleCommand = Extract; @@ -844,133 +724,6 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); - it.effect("dispatches the last activity time with the v2 snapshot guard", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const thread = makeThread("inactive-open-pr", { - branch: "feature", - latestRunCompletedAt: DateTime.makeUnsafe("2026-08-25T00:00:00.000Z"), - }); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([thread]), - settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 2 }, - branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")), - }); - yield* Effect.gen(function* () { - const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; - yield* startHarness(service, fixture.activation, fixture.snapshotReads); - const commands = yield* Ref.get(fixture.commands); - expect(commands).toHaveLength(1); - expect(commands[0]?.settledAt).toEqual(thread.latestRunCompletedAt); - expect(commands[0]?.snapshotAt).toEqual(thread.updatedAt); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - - it.effect("skips the branch recheck when a terminal link would settle nothing", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const previous = { - projectId: PROJECT_ID, - repository: "owner/repository", - number: 1, - url: "https://example.test/owner/repository/pull/1", - }; - const fixture = yield* makeHarness({ - snapshot: makeSnapshot( - [ - makeThread("resumed-manual", { - branch: "main", - linkedPullRequest: previous, - latestUserMessageAt: DateTime.makeUnsafe("2026-08-28T00:00:00.000Z"), - }), - ], - [makeProject()], - ), - settings: { - ...DEFAULT_SERVER_SETTINGS, - sidebarAutoSettleAfterDays: null, - sidebarAutoSettleOnMerge: true, - }, - branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")), - pullRequestSummary: (input) => - Effect.succeed({ - ...makePullRequestSummary({ ...input, state: "merged" }), - mergedAt: "2026-08-27T00:00:00.000Z", - }), - }); - yield* Effect.gen(function* () { - const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; - yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); - assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); - assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - - it.effect("does not look up a pull request that was already merged when linked", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const reference = { - projectId: PROJECT_ID, - repository: "owner/repository", - number: 42, - url: "https://example.test/owner/repository/pull/42", - }; - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("merged-then-linked", { - linkedPullRequest: reference, - pullRequests: [ - { - host: "example.test", - repository: reference.repository, - number: reference.number, - url: reference.url, - source: "agent", - linkedAt: "2026-08-28T00:00:00.000Z", - snapshot: { - state: "merged", - title: "Pull request", - headBranch: "feature", - baseBranch: "main", - isDraft: false, - updatedAt: "2026-08-27T00:00:00.000Z", - syncedAt: "2026-08-28T00:00:00.000Z", - mergedAt: "2026-08-27T00:00:00.000Z", - }, - stack: null, - }, - ], - }), - ]), - settings: { - ...DEFAULT_SERVER_SETTINGS, - sidebarAutoSettleAfterDays: null, - sidebarAutoSettleOnMerge: true, - }, - pullRequestSummary: (input) => - Effect.succeed({ - ...makePullRequestSummary({ ...input, state: "merged" }), - mergedAt: "2026-08-27T00:00:00.000Z", - }), - }); - yield* Effect.gen(function* () { - const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; - yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); - assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); - assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - it.effect("reevaluates immediately after a pull request merge", () => Effect.scoped( Effect.gen(function* () { @@ -1136,48 +889,6 @@ describe("ThreadSettlementServiceV2 worker", () => { }), ), ); - - it.effect("looks up the branch pull request from a thread's live worktree", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot( - [ - makeThread("live-worktree", { - branch: "feature/live", - worktreePath: "/workspace/project-root/.worktrees/live", - }), - makeThread("deleted-worktree", { - branch: "feature/deleted", - worktreePath: "/workspace/project-root/.worktrees/deleted", - }), - ], - [makeProject(PROJECT_ID, "/workspace/project-root")], - ), - existingWorktreePaths: ["/workspace/project-root/.worktrees/live"], - settings: { - ...DEFAULT_SERVER_SETTINGS, - sidebarAutoSettleAfterDays: null, - sidebarAutoSettleOnMerge: true, - }, - }); - - yield* Effect.gen(function* () { - const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; - yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); - - assert.deepStrictEqual( - new Set(yield* Ref.get(fixture.branchCalls)), - new Set([ - { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" }, - { cwd: "/workspace/project-root", branch: "feature/deleted" }, - ]), - ); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); }); describe("ThreadSettlementServiceV2 terminals", () => { @@ -1219,25 +930,6 @@ describe("ThreadSettlementServiceV2 terminals", () => { payload: thread, }); - it.effect("closes the thread's idle terminals when it settles", () => - Effect.scoped( - Effect.gen(function* () { - const thread = appThread("settled-thread", "settled"); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([]), - currentThreads: [thread], - }); - - yield* Effect.gen(function* () { - const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; - yield* startHarness(service, fixture.activation, fixture.snapshotReads); - yield* fixture.publishEvent(settledEvent(thread)); - assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: thread.id }); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - it.effect("keeps the terminals of a thread re-engaged before the event ran", () => Effect.scoped( Effect.gen(function* () { @@ -1368,41 +1060,6 @@ describe("ThreadSettlementServiceV2 terminals", () => { ); }); -describe("ThreadSettlementServiceV2 single-thread sweeps", () => { - it.effect("a finished run reads only its own thread's settlement candidate", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const finished = makeThread("finished-run"); - const other = makeThread("other-thread"); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([finished, other]), - settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 3 }, - }); - - yield* Effect.gen(function* () { - const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; - yield* startHarness(service, fixture.activation, fixture.snapshotReads); - yield* Ref.set(fixture.candidateReads, []); - yield* fixture.publishEvent({ - type: "provider-session.detached", - id: EventId.make("event:detached"), - threadId: finished.id, - occurredAt: DateTime.makeUnsafe(NOW), - payload: { - providerSessionId: ProviderSessionId.make("provider-session:finished"), - detachedAt: DateTime.makeUnsafe(NOW), - }, - }); - yield* Queue.take(fixture.snapshotReads); - yield* service.drain; - assert.deepStrictEqual(yield* Ref.get(fixture.candidateReads), [finished.id]); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); -}); - describe("isSnoozed", () => { const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) }; it("keeps a run requested and completed after snoozing parked until the timer", () => { @@ -1426,17 +1083,4 @@ describe("isSnoozed", () => { }), ).toBeNull(); }); - - it("keeps quiet and interrupted threads snoozed until the timer expires", () => { - expect(ThreadSettlementService.isSnoozed(shell(snoozed), NOW_MS)).toBe(true); - expect( - ThreadSettlementService.isSnoozed( - shell({ ...snoozed, status: "interrupted", latestRunCompletedAt: at(-DAY_MS) }), - NOW_MS, - ), - ).toBe(true); - expect( - ThreadSettlementService.isSnoozed(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS), - ).toBe(false); - }); }); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 6d46ce113e26..7d95dfd035a3 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -36,7 +36,7 @@ export interface SettlementPullRequest { } const DAY_MS = 24 * 60 * 60 * 1_000; -export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; function toMillis(value: DateTime.Utc | null | undefined): number | null { return value == null ? null : DateTime.toEpochMillis(value); diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 57e83cb239a3..c8d3131f4009 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -10,6 +10,7 @@ import { ProviderDriverKind, ProviderInstanceId, ProviderSessionId, + ProviderThreadId, ProviderTurnId, RuntimeRequestId, ScheduledTaskId, @@ -20,7 +21,6 @@ import * as DateTime from "effect/DateTime"; 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 Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; @@ -167,21 +167,26 @@ const threadState = (threadId: ThreadId) => }); it.effect.each([ - "idle", - "archived", - "missing", - "running", - "queued", - "waiting", - "correlated", - "provider-active", - "roster", - "background", - "question", - "restart", + ["idle", "thread.stop"], + ["idle", "run.interrupt"], + ["idle", "subagent.stop"], + ["running", "subagent.stop"], + ["bound", "subagent.stop"], + ["correlated", "subagent.stop"], + ["archived", "thread.stop"], + ["missing", "thread.stop"], + ["running", "thread.stop"], + ["queued", "thread.stop"], + ["waiting", "thread.stop"], + ["correlated", "thread.stop"], + ["provider-active", "thread.stop"], + ["roster", "thread.stop"], + ["background", "thread.stop"], + ["question", "thread.stop"], + ["restart", "thread.stop"], ] as const)( - "Stop clears an orphaned follow-up only when its child is finished (%s)", - (childState) => + "Stop clears an orphaned follow-up when its child is finished; its own Stop does unless a run is bound (%s via %s)", + ([childState, type]) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -281,7 +286,9 @@ it.effect.each([ const childRunStatus = childState === "running" || childState === "queued" || childState === "waiting" ? childState - : "completed"; + : childState === "bound" + ? "running" + : "completed"; yield* apply({ type: "run.updated", threadId: childThreadId, @@ -301,12 +308,14 @@ it.effect.each([ ...runAtStart, status: childRunStatus, completedAt: childRunStatus === "completed" ? now : null, - ...(childState === "correlated" ? { delegatedTaskId: taskId } : {}), + ...(childState === "correlated" || childState === "bound" + ? { delegatedTaskId: taskId } + : {}), }, }); assert.equal( (yield* orchestrator.getThreadProjection(childThreadId)).runs[0]?.delegatedTaskId, - childState === "correlated" ? taskId : undefined, + childState === "correlated" || childState === "bound" ? taskId : undefined, ); if (childState === "provider-active" || childState === "roster") { yield* apply({ @@ -373,6 +382,41 @@ it.effect.each([ occurredAt: now, payload: { ...parent.runs[0]!, status: "completed", completedAt: now }, }); + if (type === "run.interrupt") { + const run = parent.runs[0]!; + yield* apply({ + type: "provider-turn.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { + id: ProviderTurnId.make("provider-turn:orphan-parent"), + providerThreadId: run.providerThreadId!, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId!, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + }); + yield* apply({ + type: "run-attempt.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { ...parent.attempts[0]!, status: "completed", completedAt: now }, + }); + yield* apply({ + type: "node.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { + ...parent.nodes.find((node) => node.id === run.rootNodeId)!, + status: "completed", + completedAt: now, + }, + }); + } if (childState === "archived") { yield* apply({ type: "thread.archived", @@ -388,20 +432,36 @@ it.effect.each([ ), 1, ); - yield* orchestrator.dispatch({ - type: "thread.stop", + const stop = { commandId: CommandId.make(`stop-orphan:${childState}`), threadId: parentThreadId, - }); + }; + yield* orchestrator.dispatch( + type === "run.interrupt" + ? { ...stop, type, runId: parent.runs.at(-1)!.id, holdQueue: true } + : type === "subagent.stop" + ? { ...stop, type, runId: original.runId!, subagentId: taskId } + : { ...stop, type }, + ); const after = yield* orchestrator.getThreadProjection(parentThreadId); - assert.isEmpty((after.thread.pullRequests ?? []).filter((link) => link.watch !== undefined)); - const expected = childState === "idle" || childState === "archived" ? "cancelled" : "running"; + // The Lineage stop of one subagent leaves the parent's watches alone. + assert.lengthOf( + (after.thread.pullRequests ?? []).filter((link) => link.watch !== undefined), + type === "subagent.stop" ? 1 : 0, + ); + // A task with a bound run, live or ended, keeps its settlement path. + const expected = + childState === "bound" || childState === "correlated" + ? "running" + : type === "subagent.stop" || childState === "idle" || childState === "archived" + ? "cancelled" + : "running"; assert.equal(after.subagents.find((task) => task.id === taskId)?.status, expected); assert.equal(after.nodes.find((node) => node.id === taskId)?.status, expected); assert.equal(after.turnItems.find((item) => item.id === itemId)?.status, expected); assert.equal( after.subagents.find((task) => task.id === taskId)?.completionDelivery?.state, - "disposed", + type === "subagent.stop" && expected === "running" ? undefined : "disposed", ); assert.equal( after.subagents.find((task) => task.id === original.id)?.result, @@ -410,7 +470,9 @@ it.effect.each([ assert.deepEqual(yield* orchestrator.getThreadProjection(childThreadId), childBeforeStop); if (expected === "cancelled") { assert.lengthOf( - (yield* orchestrator.getThreadShell(parentThreadId))?.pendingBackgroundTasks ?? [], + ( + (yield* orchestrator.getThreadShell(parentThreadId))?.pendingBackgroundTasks ?? [] + ).filter((task) => task.kind === "subagent"), 0, ); yield* orchestrator.recoverDelegatedTasks; @@ -423,10 +485,56 @@ it.effect.each([ assert.equal(repeated.subagents.find((task) => task.id === taskId)?.status, "cancelled"); assert.deepEqual(repeated.messages, after.messages); assert.deepEqual(repeated.contextTransfers, after.contextTransfers); + if (type === "subagent.stop") { + // A client that missed the cancel, such as one resumed from an older cache, still + // shows the task as running. Its Stop gets the ended task back. + const eventStore = yield* EventStore.EventStoreV2; + const afterSequence = yield* eventStore.latestSequence({ threadId: parentThreadId }); + yield* orchestrator.dispatch({ + ...stop, + commandId: CommandId.make(`stop-orphan-stale-client:${childState}`), + type, + runId: original.runId!, + subagentId: taskId, + }); + const resent = yield* eventStore + .read({ threadId: parentThreadId, afterSequence }) + .pipe(Stream.runCollect); + assert.deepEqual( + resent.flatMap(({ event }) => + event.type === "subagent.updated" ? [event.payload.status] : [], + ), + ["cancelled"], + ); + } } }).pipe(Effect.provide(layerTest)), ); +it.effect("Stop still stops the thread when a delegated child cannot be read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sql = yield* SqlClient.SqlClient; + const parentThreadId = ThreadId.make("thread:unreadable-child-parent"); + yield* createWatchingThread(parentThreadId, 1); + yield* send(parentThreadId, "parent", "start_immediately"); + const childThreadId = yield* delegate(parentThreadId, "unreadable child"); + yield* sql` + UPDATE orchestration_v2_projection_runs + SET payload_json = '{"obsolete":true}' + WHERE thread_id = ${childThreadId} + `; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop:unreadable-child"), + threadId: parentThreadId, + runId: (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!.id, + holdQueue: true, + }); + assert.deepEqual(yield* threadState(parentThreadId), { runs: ["interrupted"], watched: [] }); + }).pipe(Effect.provide(layerTest)), +); + it.effect("Stop ends watches, holds queues, and stops the delegated tasks under the thread", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -489,76 +597,6 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under }).pipe(Effect.provide(layerTest)), ); -it.effect.each([1, 2, 8])( - "Stop visits each descendant once with %s historical tasks per child", - (tasksPerChild) => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; - const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; - const rootThreadId = ThreadId.make("thread:stop-history"); - yield* createWatchingThread(rootThreadId, 30); - yield* send(rootThreadId, "work", "start_immediately"); - const descendants: Array = []; - let parentThreadId = rootThreadId; - for (let depth = 0; depth < 3; depth++) { - const childThreadId = yield* delegate(parentThreadId, `history child ${depth}`); - const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; - // Published tasks and later follow-ups all retain the same child thread. - for (let history = 1; history < tasksPerChild; history++) { - const taskId = NodeId.make(`node:stop-history:${depth}:${history}`); - yield* projections.apply({ - id: EventId.make(`event:stop-history:${depth}:${history}`), - type: "subagent.updated", - threadId: parentThreadId, - runId: task.runId!, - nodeId: taskId, - occurredAt: yield* DateTime.now, - payload: { ...task, id: taskId, status: "completed" }, - }); - } - descendants.push(childThreadId); - parentThreadId = childThreadId; - } - - const stopped: Array = []; - const read: Array = []; - const measuredOrchestrator = Orchestrator.OrchestratorV2.of({ - ...orchestrator, - dispatch: (command) => - Effect.sync(() => { - if (command.type === "thread.stop") stopped.push(command.threadId); - }).pipe(Effect.andThen(orchestrator.dispatch(command))), - getThreadRecords: (threadId, fields, filter) => - Effect.sync(() => read.push(threadId)).pipe( - Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), - ), - }); - const commandId = CommandId.make("stop-history"); - yield* Effect.gen(function* () { - const threads = yield* ThreadManagementService.ThreadManagementService; - yield* threads.stopDelegatedTasks({ threadId: rootThreadId, commandId }); - }).pipe( - Effect.provide( - Layer.fresh(ThreadManagementService.layer).pipe( - Layer.provide(Layer.succeed(Orchestrator.OrchestratorV2, measuredOrchestrator)), - ), - ), - ); - - // Check real persisted state and receipts before asserting traversal cost. - for (const threadId of descendants) { - assert.deepEqual((yield* threadState(threadId)).runs, ["interrupted"]); - const receipt = yield* receipts.getByCommandId( - CommandId.make(`${commandId}:stop:${threadId}`), - ); - assert.isTrue(Option.isSome(receipt)); - } - assert.deepEqual(stopped, descendants); - assert.deepEqual(read, [rootThreadId, ...descendants]); - }).pipe(Effect.provide(layerTest)), -); - it.effect.each(["thread.stop", "run.interrupt"] as const)( "%s cancels buffered wakes and preserves durable queued messages", (type) => @@ -728,6 +766,62 @@ it.effect( }).pipe(Effect.provide(layerTest)), ); +it.effect.each([false, true])( + "a completed watch records whether it ended through Stop (%s)", + (stopped) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make(`thread:watch-completion:${stopped}`); + yield* createWatchingThread(threadId, 7); + yield* send(threadId, "watch", "start_immediately"); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const now = yield* DateTime.now; + const completedRun = { + ...run, + providerThreadId: run.providerThreadId ?? ProviderThreadId.make("provider-thread:watch"), + status: "completed" as const, + startedAt: now, + completedAt: now, + }; + yield* projections.apply({ + id: EventId.make("event:watch-completed"), + type: "run.updated", + threadId, + occurredAt: now, + payload: completedRun, + }); + assert.isFalse((yield* projections.getThreadShell(threadId))?.latestRunStopRequested); + + yield* orchestrator.dispatch( + stopped + ? { type: "thread.stop", commandId: CommandId.make("stop-watch"), threadId } + : { + type: "thread.pull-request.watch", + commandId: CommandId.make("finish-watch"), + threadId, + ...pullRequest(7), + watching: false, + }, + ); + const shell = yield* projections.getThreadShell(threadId); + const snapshot = (yield* projections.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + const memoryShell = ProjectionStore.threadShellFromProjection( + yield* orchestrator.getThreadProjection(threadId), + ); + for (const result of [shell, snapshot, memoryShell]) { + assert.equal(result?.status, "completed"); + assert.isEmpty(result?.pendingBackgroundTasks ?? []); + assert.equal(result?.latestRunStopRequested, stopped); + } + + yield* send(threadId, "next", "start_immediately"); + assert.isFalse((yield* projections.getThreadShell(threadId))?.latestRunStopRequested); + }).pipe(Effect.provide(layerTest)), +); + it.effect("a run Stop reached cannot delegate or start a watch, even after it ends", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -1147,34 +1241,6 @@ it.effect("a delegated task that cannot be stopped fails the walk after its sibl }).pipe(Effect.provide(layerTest)), ); -it.effect("thread.stop on a finished thread refuses a late agent watch", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; - const threadId = ThreadId.make("thread:stop-finished"); - yield* createWatchingThread(threadId, 9); - yield* send(threadId, "work", "start_immediately"); - const now = yield* DateTime.now; - const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; - yield* projections.apply({ - id: EventId.make("event:stop-finished:completed"), - type: "run.updated", - threadId, - runId: run.id, - occurredAt: now, - payload: { ...run, status: "completed", startedAt: now, completedAt: now }, - }); - - yield* orchestrator.dispatch({ - type: "thread.stop", - commandId: CommandId.make("stop-finished"), - threadId, - }); - assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(threadId, 10)))); - assert.deepEqual(yield* threadState(threadId), { runs: ["completed"], watched: [] }); - }).pipe(Effect.provide(layerTest)), -); - it.effect("thread.stop marks a turn it cannot interrupt so a late agent watch is refused", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index 0c2ef66d7e4f..438a24743a73 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -111,17 +111,6 @@ function timelineProjection(itemCount: number): OrchestrationV2ThreadProjection } describe("decideThreadResume", () => { - it("replays when the gap is zero", () => { - expect( - decideThreadResume({ - afterSequence: 10, - highWater: 10, - replayEventCount: 0, - replayEncodedBytes: 0, - }), - ).toEqual({ mode: "replay", afterSequence: 10, throughSequence: 10 }); - }); - it("replays when the event count is within the bound", () => { expect( decideThreadResume({ @@ -137,17 +126,6 @@ describe("decideThreadResume", () => { }); }); - it("falls back to a snapshot when the event count exceeds the bound", () => { - expect( - decideThreadResume({ - afterSequence: 10, - highWater: 20_000, - replayEventCount: THREAD_RESUME_MAX_REPLAY_EVENTS + 1, - replayEncodedBytes: 1, - }), - ).toEqual({ mode: "snapshot" }); - }); - it("falls back to a snapshot when encoded replay bytes exceed the bound", () => { expect( decideThreadResume({ @@ -159,20 +137,6 @@ describe("decideThreadResume", () => { ).toEqual({ mode: "snapshot" }); }); - it("rejects pathological raw payloads before decoding them", () => { - expect(isThreadReplayRawPayloadSafe(THREAD_RESUME_MAX_RAW_PAYLOAD_BYTES)).toBe(true); - expect(isThreadReplayRawPayloadSafe(THREAD_RESUME_MAX_RAW_PAYLOAD_BYTES + 1)).toBe(false); - - expect( - decideThreadResume({ - afterSequence: 9, - highWater: 10, - replayEventCount: 1, - replayEncodedBytes: 1, - }), - ).toEqual({ mode: "replay", afterSequence: 9, throughSequence: 10 }); - }); - it("falls back to a snapshot when the client cursor is ahead of the store", () => { expect( decideThreadResume({ @@ -184,12 +148,6 @@ describe("decideThreadResume", () => { ).toEqual({ mode: "snapshot" }); }); - it("counts UTF-8 bytes across projected stream items", () => { - expect(threadReplayEncodedBytes([{ value: "a" }, { value: "🦊" }])).toBe( - Buffer.byteLength('{"value":"a"}', "utf8") + Buffer.byteLength('{"value":"🦊"}', "utf8"), - ); - }); - it("builds socket fallback snapshots with a bounded timeline and history cursor", () => { const snapshot = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, @@ -326,35 +284,4 @@ describe("compact bounded snapshots", () => { } expect(boundedSnapshotProjection(decodedCompact)).toEqual(decodedFull.projection); }); - - it("keeps the retained interrupt request outside the visible window", () => { - const compact = buildBoundedThreadStreamSnapshot({ - snapshotSequence: 1, - projection: forkProjection(), - compactTurnItems: true, - }); - expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); - expect(boundedSnapshotProjection(compact).turnItems.map((item) => String(item.id))).toEqual([ - "item-3", - "item-4", - "item-5", - "interrupt-request", - ]); - }); - - it("omits the marker when the window has no local rows", () => { - const fork = forkProjection(); - const inheritedOnly = { - ...fork, - turnItems: fork.turnItems.slice(0, 1), - visibleTurnItems: fork.visibleTurnItems.slice(0, 3), - }; - const compact = buildBoundedThreadStreamSnapshot({ - snapshotSequence: 1, - projection: inheritedOnly, - compactTurnItems: true, - }); - expect("turnItemsOmitLocalVisible" in compact).toBe(false); - expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); - }); }); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 34ab4ed71610..78ca87f8abf3 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -1,18 +1,13 @@ import { assert, describe, it, vi } from "@effect/vitest"; import { - type ChatAttachment, CommandId, MessageId, ProjectId, ProviderDriverKind, ProviderInstanceId, ThreadId, - TextGenerationError, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -166,31 +161,6 @@ function armRegeneration(input: { readonly command: string; readonly threadId: T } describe("formatThreadTitleContext", () => { - const attachment = (id: string): ChatAttachment => ({ - type: "image", - id, - name: `${id}.png`, - mimeType: "image/png", - sizeBytes: 1, - }); - - it("builds a newest-first digest, skipping system messages and empty sections", () => { - const context = ThreadTitleRegeneration.formatThreadTitleContext([ - { role: "user", text: "First question" }, - { role: "system", text: "Hidden instructions" }, - { role: "assistant", text: "" }, - { role: "assistant", text: "Second answer", attachments: [attachment("shot")] }, - ]); - assert.equal( - context.message, - "USER:\nFirst question\n\nASSISTANT:\nSecond answer\n[Attachments: shot.png]", - ); - assert.deepEqual( - context.attachments.map((entry) => entry.name), - ["shot.png"], - ); - }); - it("pins the first user message ahead of the retained tail once content stops fitting", () => { const context = ThreadTitleRegeneration.formatThreadTitleContext([ { role: "user", text: `Ancient context that anchors the topic ${"x".repeat(600)}` }, @@ -201,32 +171,6 @@ describe("formatThreadTitleContext", () => { assert.isTrue(context.message.includes("[Earlier content truncated]\n\n")); assert.isTrue(context.message.includes("y".repeat(100))); }); - - it("truncates an oversized pinned first user message", () => { - const context = ThreadTitleRegeneration.formatThreadTitleContext([ - { role: "user", text: `Topic anchor ${"a".repeat(4_000)}` }, - { role: "assistant", text: "y".repeat(9_000) }, - { role: "user", text: "z".repeat(1_500) }, - ]); - assert.isTrue(context.message.includes("USER:\nTopic anchor")); - assert.isTrue(context.message.includes("[Content truncated]")); - assert.isTrue(context.message.includes("[Earlier content truncated]\n\n")); - }); - - it("retains at most four attachments from the newest messages", () => { - const context = ThreadTitleRegeneration.formatThreadTitleContext([ - { role: "user", text: "older", attachments: [attachment("a"), attachment("b")] }, - { - role: "user", - text: "newer", - attachments: [attachment("c"), attachment("d"), attachment("e")], - }, - ]); - assert.deepEqual( - context.attachments.map((entry) => entry.name), - ["a.png", "c.png", "d.png", "e.png"], - ); - }); }); describe("ThreadTitleRegenerationService", () => { @@ -334,65 +278,6 @@ describe("ThreadTitleRegenerationService", () => { }), ); - it.effect("keeps the current title when generation returns the fallback", () => - Effect.gen(function* () { - const harness = makeHarness({ - generateTitle: () => Effect.succeed({ title: "New thread" }), - }); - yield* Effect.gen(function* () { - const threads = yield* ThreadManagement.ThreadManagementService; - const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const threadId = yield* createThread({ - command: "command:title:fallback:create", - thread: "thread:title:fallback", - }); - yield* dispatchUserMessage({ - command: "command:title:fallback:message", - threadId, - text: "Some conversation", - }); - const requestId = yield* armRegeneration({ command: "command:title:fallback:1", threadId }); - - yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); - - const projection = yield* threads.getThreadProjection(threadId); - assert.equal(projection.thread.title, "Seed title"); - assert.isNotOk(projection.thread.titleRegeneration); - }).pipe(Effect.provide(harness.layer)); - }), - ); - - it.effect("keeps the current title when regeneration reproduces it", () => - Effect.gen(function* () { - const harness = makeHarness({ - generateTitle: () => Effect.succeed({ title: "Seed title" }), - }); - yield* Effect.gen(function* () { - const threads = yield* ThreadManagement.ThreadManagementService; - const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const threadId = yield* createThread({ - command: "command:title:unchanged:create", - thread: "thread:title:unchanged", - }); - yield* dispatchUserMessage({ - command: "command:title:unchanged:message", - threadId, - text: "Some conversation", - }); - const requestId = yield* armRegeneration({ - command: "command:title:unchanged:1", - threadId, - }); - - yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); - - const projection = yield* threads.getThreadProjection(threadId); - assert.equal(projection.thread.title, "Seed title"); - assert.isNotOk(projection.thread.titleRegeneration); - }).pipe(Effect.provide(harness.layer)); - }), - ); - it.effect("clears the marker and keeps the title when generation fails", () => Effect.gen(function* () { const harness = makeHarness({ @@ -421,97 +306,4 @@ describe("ThreadTitleRegenerationService", () => { }).pipe(Effect.provide(harness.layer)); }), ); - - it.effect("completes without generating when the initial message is unavailable", () => - Effect.gen(function* () { - const harness = makeHarness(); - yield* Effect.gen(function* () { - const threads = yield* ThreadManagement.ThreadManagementService; - const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const threadId = yield* createThread({ - command: "command:title:missing:create", - thread: "thread:title:missing", - }); - const requestId = yield* armRegeneration({ command: "command:title:missing:1", threadId }); - - yield* titleRegeneration.execute({ - threadId, - requestId, - kind: { type: "initial", messageId: MessageId.make("message:title:missing") }, - }); - - assert.equal(harness.generateThreadTitle.mock.calls.length, 0); - const projection = yield* threads.getThreadProjection(threadId); - assert.equal(projection.thread.title, "Seed title"); - assert.isNotOk(projection.thread.titleRegeneration); - }).pipe(Effect.provide(harness.layer)); - }), - ); }); - -it.effect.each(["success", "exhausted", "stale", "interrupted"] as const)( - "initial title retry: %s", - (outcome) => - Effect.gen(function* () { - const attempted = yield* Deferred.make(); - let attempts = 0; - const harness = makeHarness({ - generateTitle: () => - Effect.gen(function* () { - attempts += 1; - yield* Deferred.succeed(attempted, undefined); - if (outcome === "success" && attempts === 3) return { title: "Recovered title" }; - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Temporary failure", - }); - }), - }); - yield* Effect.gen(function* () { - const threads = yield* ThreadManagement.ThreadManagementService; - const service = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const threadId = yield* createThread({ - command: `create:${outcome}`, - thread: `thread:${outcome}`, - }); - const messageCommand = `message:${outcome}`; - yield* dispatchUserMessage({ command: messageCommand, threadId, text: "Fix the title" }); - const requestId = yield* armRegeneration({ command: `title:${outcome}`, threadId }); - const fiber = yield* service - .execute({ - threadId, - requestId, - kind: { type: "initial", messageId: MessageId.make(`${messageCommand}:message`) }, - }) - .pipe(Effect.forkChild); - yield* Deferred.await(attempted); - if (outcome === "interrupted") { - yield* Fiber.interrupt(fiber); - } else { - if (outcome === "stale") - yield* threads.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("manual-title"), - threadId, - title: "Manual title", - }); - yield* TestClock.adjust("2 seconds"); - if (outcome !== "stale") yield* TestClock.adjust("4 seconds"); - yield* Fiber.join(fiber); - } - assert.equal(attempts, outcome === "success" || outcome === "exhausted" ? 3 : 1); - const projection = yield* threads.getThreadProjection(threadId); - assert.equal( - projection.thread.title, - outcome === "success" - ? "Recovered title" - : outcome === "stale" - ? "Manual title" - : "Seed title", - ); - if (outcome === "interrupted") - assert.equal(projection.thread.titleRegeneration?.requestId, requestId); - else assert.isNotOk(projection.thread.titleRegeneration); - }).pipe(Effect.provide(harness.layer)); - }), -); diff --git a/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts b/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts index 2ffb6c5ceadb..7eb434176a13 100644 --- a/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts @@ -1,13 +1,9 @@ import { EventId, OrchestrationV2RpcSchemas, - ProjectId, - ProviderInstanceId, ThreadId, TurnItemId, - type OrchestrationV2ProjectedTurnItem, type OrchestrationV2DomainEvent, - type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadStreamItem, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; @@ -16,21 +12,12 @@ import * as Schema from "effect/Schema"; import type * as RpcMessage from "effect/rpc/RpcMessage"; import { describe, expect, it } from "vite-plus/test"; -import { buildBoundedThreadStreamSnapshot } from "./ThreadStream.ts"; -import { buildBoundedThreadProjection } from "./threadHistoryPaging.ts"; import { projectDomainEventForWire } from "./WireProjection.ts"; -const SNAPSHOT_SEQUENCE = 600; -const ROW_COUNT = 600; const OUTPUT_BYTES_PER_ROW = 8_192; const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); const THREAD_ID = ThreadId.make("thread-perf"); -const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("codex"); -const HISTORICAL_FULL_SNAPSHOT_APPLICATION_BYTES = 10_375_079; -const FULL_SNAPSHOT_RPC_JSON_BYTES = 10_375_121; -const PRE_OMISSION_BOUNDED_SNAPSHOT_RPC_JSON_BYTES = 1_038_647; -const MAX_PROJECTED_BOUNDED_SNAPSHOT_RPC_JSON_BYTES = 131_072; const PRE_OMISSION_COMMAND_EVENT_RPC_JSON_BYTES = 8_790; const MAX_PROJECTED_COMMAND_EVENT_RPC_JSON_BYTES = 1_024; @@ -74,69 +61,6 @@ function makeCommandItem(index: number): OrchestrationV2TurnItem { }; } -function makeProjection(): OrchestrationV2ThreadProjection { - const visibleTurnItems: OrchestrationV2ProjectedTurnItem[] = Array.from( - { length: ROW_COUNT }, - (_, index) => { - const item = makeCommandItem(index); - return { - position: index, - visibility: "local", - sourceThreadId: THREAD_ID, - sourceItemId: item.id, - item, - }; - }, - ); - - return { - thread: { - id: THREAD_ID, - projectId: ProjectId.make("project-perf"), - title: "Thread", - providerInstanceId: PROVIDER_INSTANCE_ID, - modelSelection: { instanceId: PROVIDER_INSTANCE_ID, model: "gpt-5" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: THREAD_ID, - }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - createdAt: NOW, - updatedAt: NOW, - archivedAt: null, - deletedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - }, - runs: [], - attempts: [], - nodes: [], - subagents: [], - providerSessions: [], - providerThreads: [], - providerTurns: [], - runtimeRequests: [], - messages: [], - plans: [], - turnItems: visibleTurnItems.map((row) => row.item), - checkpointScopes: [], - checkpoints: [], - contextHandoffs: [], - contextTransfers: [], - visibleTurnItems, - updatedAt: NOW, - }; -} - function makeTurnItemEvent(item: OrchestrationV2TurnItem): OrchestrationV2DomainEvent { return { id: EventId.make(`event-${item.id}`), @@ -148,51 +72,6 @@ function makeTurnItemEvent(item: OrchestrationV2TurnItem): OrchestrationV2Domain } describe("thread transport payload budget", () => { - it("records full, bounded, and payload-omitting snapshot improvements", () => { - const projection = makeProjection(); - const historicalFullSnapshot: OrchestrationV2ThreadStreamItem = { - kind: "snapshot", - snapshotSequence: SNAPSHOT_SEQUENCE, - projection, - }; - const preOmissionBounded = buildBoundedThreadProjection({ - projection, - snapshotSequence: SNAPSHOT_SEQUENCE, - }); - const preOmissionBoundedSnapshot: OrchestrationV2ThreadStreamItem = { - kind: "snapshot", - snapshotSequence: SNAPSHOT_SEQUENCE, - projection: preOmissionBounded.projection, - historyCursor: preOmissionBounded.historyCursor, - hasMoreHistory: preOmissionBounded.hasMoreHistory, - latestLocalTurnOrdinal: preOmissionBounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: preOmissionBounded.payloadBudgetExceeded, - }; - const projectedBoundedSnapshot = buildBoundedThreadStreamSnapshot({ - projection, - snapshotSequence: SNAPSHOT_SEQUENCE, - }); - - const historicalApplicationBytes = encodedBytes(historicalFullSnapshot); - const fullRpcJsonBytes = encodedThreadStreamChunkBytes(historicalFullSnapshot); - const preOmissionBoundedRpcJsonBytes = encodedThreadStreamChunkBytes( - preOmissionBoundedSnapshot, - ); - const projectedBoundedRpcJsonBytes = encodedThreadStreamChunkBytes(projectedBoundedSnapshot); - - expect(historicalApplicationBytes).toBe(HISTORICAL_FULL_SNAPSHOT_APPLICATION_BYTES); - expect(fullRpcJsonBytes).toBe(FULL_SNAPSHOT_RPC_JSON_BYTES); - expect(preOmissionBoundedRpcJsonBytes).toBe(PRE_OMISSION_BOUNDED_SNAPSHOT_RPC_JSON_BYTES); - expect(projectedBoundedRpcJsonBytes).toBeLessThanOrEqual( - MAX_PROJECTED_BOUNDED_SNAPSHOT_RPC_JSON_BYTES, - ); - expect(projectedBoundedRpcJsonBytes / fullRpcJsonBytes).toBeLessThanOrEqual(0.02); - expect(preOmissionBoundedSnapshot.projection.visibleTurnItems).toHaveLength(60); - expect(projectedBoundedSnapshot.projection.visibleTurnItems).toHaveLength(75); - expect(projectedBoundedSnapshot.hasMoreHistory).toBe(true); - expect(projectedBoundedSnapshot.payloadBudgetExceeded).toBe(false); - }); - it("omits command output from a live event without changing persisted source data", () => { const sourceItem = makeCommandItem(0); const sourceEvent = makeTurnItemEvent(sourceItem); @@ -217,31 +96,4 @@ describe("thread transport payload budget", () => { expect(sourceItem).toHaveProperty("output", "x".repeat(OUTPUT_BYTES_PER_ROW)); expect(projectedEvent.payload).not.toHaveProperty("output"); }); - - it("omits ordinary dynamic tool output even when it is small", () => { - const sourceItem: OrchestrationV2TurnItem = { - id: TurnItemId.make("item-dynamic"), - type: "dynamic_tool", - threadId: THREAD_ID, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "completed", - title: "Dynamic tool", - toolName: "lookup", - input: { query: "status" }, - output: { content: "small raw result" }, - startedAt: NOW, - completedAt: NOW, - updatedAt: NOW, - }; - const projectedEvent = projectDomainEventForWire(makeTurnItemEvent(sourceItem)); - - expect(sourceItem).toHaveProperty("output", { content: "small raw result" }); - expect(projectedEvent.payload).not.toHaveProperty("output"); - }); }); diff --git a/apps/server/src/orchestration-v2/UserFacingErrors.test.ts b/apps/server/src/orchestration-v2/UserFacingErrors.test.ts index 6eb75f31fbb1..1d69d1c88a3a 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.test.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.test.ts @@ -11,69 +11,6 @@ import { toPersistenceSqlError } from "../persistence/Errors.ts"; import { userFacingDispatchErrorMessage } from "./UserFacingErrors.ts"; describe("userFacingDispatchErrorMessage", () => { - it("returns the deepest actionable domain error instead of generic dispatch wrappers", () => { - const message = userFacingDispatchErrorMessage({ - message: "Failed to dispatch orchestration command checkpoint.rollback (command-1).", - cause: { - message: "Provider adapter failed while dispatching orchestration command command-1.", - cause: { - message: - "claudeAgent cannot satisfy rollback for command command-1: provider conversation rollback is unavailable", - }, - }, - }); - - assert.equal( - message, - "claudeAgent cannot satisfy rollback for command command-1: provider conversation rollback is unavailable", - ); - }); - - it("translates policy capability rejections into provider-named prose", () => { - assert.equal( - userFacingDispatchErrorMessage({ - message: "Failed to dispatch orchestration command checkpoint.rollback (command-1).", - cause: { - _tag: "CommandPolicyCapabilityUnsupportedError", - commandId: "command-1", - threadId: "thread-1", - providerInstanceId: "pi", - capability: "rollback_snapshot", - detail: "rollback must return a provider thread snapshot", - message: - "pi cannot satisfy rollback_snapshot for command command-1: rollback must return a provider thread snapshot", - }, - }), - "Pi did not report its rewound conversation state, so the checkpoint was not restored.", - ); - assert.equal( - userFacingDispatchErrorMessage({ - message: "Failed to dispatch orchestration command checkpoint.rollback (command-2).", - cause: { - _tag: "CommandPolicyCapabilityUnsupportedError", - commandId: "command-2", - threadId: "thread-1", - providerInstanceId: "grok", - capability: "rollback", - detail: "provider conversation rollback is unavailable", - }, - }), - "Grok cannot rewind its conversation, so this checkpoint cannot be restored on this thread.", - ); - }); - - it("uses explicit detail fields as user-facing messages", () => { - assert.equal( - userFacingDispatchErrorMessage({ - message: "Failed to dispatch orchestration command message.dispatch (command-1).", - cause: { - detail: "Claude provider thread provider-thread-1 has no live query.", - }, - }), - "Claude provider thread provider-thread-1 has no live query.", - ); - }); - it.effect("explains a real database open failure through dispatch and persistence wrappers", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -132,23 +69,6 @@ describe("userFacingDispatchErrorMessage", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); - it.each([ - [5, "SQLITE_BUSY", "Wait a moment, then retry"], - [6, "SQLITE_LOCKED", "Wait a moment, then retry"], - [10 | (3 << 8), "SQLITE_IOERR", "free disk space and disk health"], - [14 | (6 << 8), "SQLITE_CANTOPEN", "exist and are writable"], - [11, "SQLITE_CORRUPT", "Preserve the server data directory"], - [26, "SQLITE_NOTADB", "configured database path"], - ] as const)("maps SQLite code %i to %s with recovery guidance", (errcode, code, guidance) => { - const cause = Object.assign(new Error("private-query-data"), { errcode, errno: errcode }); - const error = new SqlError({ reason: classifySqliteError(cause) }); - const message = userFacingDispatchErrorMessage({ cause: error }); - - assert.include(message, code); - assert.include(message, guidance); - assert.notInclude(message, "private-query-data"); - }); - it("keeps unknown SQL failures contextual without exposing driver text or parameters", () => { const cause = Object.assign(new Error("private-query-data"), { errcode: 1 }); const error = new SqlError({ reason: classifySqliteError(cause) }); diff --git a/apps/server/src/orchestration-v2/V1ImportBoundary.test.ts b/apps/server/src/orchestration-v2/V1ImportBoundary.test.ts deleted file mode 100644 index 3869eebe3ae2..000000000000 --- a/apps/server/src/orchestration-v2/V1ImportBoundary.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Static architecture test scans source files. -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -import { assert, it } from "@effect/vitest"; - -const sourceRoot = NodePath.resolve(import.meta.dirname, ".."); -const forbiddenImport = - /from\s+["'][^"']*(?:ProviderService|ProviderSessionDirectory|ProviderSessionReaper|ProviderCommandReactor|ProviderRuntimeIngestion)[^"']*["']/; -const legacyTable = - /\bprojection_(?:threads|thread_messages|thread_activities|thread_proposed_plans|thread_pull_requests|thread_sessions|turns|pending_approvals|state)\b/; -const legacyReaders = ["orchestration-v2/legacy/", "persistence/Migrations/"] as const; -const legacyReaderFiles: Record = { - "serverSettings.ts": "one-time provider history for settings migration", -}; -const retiredPaths = [ - "orchestration", - "orchestration/Layers/ProviderCommandReactor.ts", - "orchestration/Layers/ProviderRuntimeIngestion.ts", - "orchestration/Services/ProviderCommandReactor.ts", - "orchestration/Services/ProviderRuntimeIngestion.ts", - "persistence/Services/ProjectionThreads.ts", - "persistence/Services/ProjectionProjects.ts", -] as const; - -function productionTypeScriptFiles(directory: string): ReadonlyArray { - return NodeFS.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const path = NodePath.join(directory, entry.name); - if (entry.isDirectory()) return productionTypeScriptFiles(path); - return entry.isFile() && entry.name.endsWith(".ts") && !entry.name.includes(".test.") - ? [path] - : []; - }); -} - -const relativeSources = productionTypeScriptFiles(sourceRoot).map((path) => ({ - path: NodePath.relative(sourceRoot, path).split(NodePath.sep).join("/"), - source: NodeFS.readFileSync(path, "utf8"), -})); - -it("keeps the V1 agent runtime and engine deleted", () => { - for (const relativePath of retiredPaths) { - assert.isFalse(NodeFS.existsSync(NodePath.join(sourceRoot, relativePath)), relativePath); - } - const violations = relativeSources - .filter(({ path, source }) => !path.includes("/legacy/") && forbiddenImport.test(source)) - .map(({ path }) => path); - assert.deepEqual(violations, []); -}); - -it("reads the V1 tables only from the legacy importer", () => { - const readers = relativeSources - .filter(({ source }) => legacyTable.test(source)) - .map(({ path }) => path) - .filter( - (path) => - !legacyReaders.some((directory) => path.startsWith(directory)) && - legacyReaderFiles[path] === undefined, - ); - assert.deepEqual(readers, []); - for (const path of Object.keys(legacyReaderFiles)) { - const file = relativeSources.find((candidate) => candidate.path === path); - assert.isTrue(file !== undefined && legacyTable.test(file.source), path); - } -}); - -it("keeps the legacy importer out of reach of new code", () => { - const importers = relativeSources - .filter( - ({ path, source }) => - !path.startsWith("orchestration-v2/legacy/") && /from\s+["'][^"']*\/legacy\//.test(source), - ) - .map(({ path }) => path) - .toSorted(); - assert.deepEqual(importers, [ - "orchestration-v2/ThreadManagementService.ts", - "orchestration-v2/runtimeLayer.ts", - "project/ProjectService.ts", - "serverRuntimeStartup.ts", - ]); -}); diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 21f8cfb058a5..17213d37810a 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -94,23 +94,6 @@ describe("orchestration V2 wire projection", () => { expect(item.output.data).toBe("private-image-data"); }); - it("preserves provider notices in bounded items and live events", () => { - const item = { - ...base, - type: "system_notice" as const, - message: "Safeguards flagged this message. Switched to Opus 4.8.", - }; - expect(projectTurnItemForWire(item)).toEqual(item); - const event = { - id: EventId.make("notice-wire-event"), - type: "turn-item.updated" as const, - threadId: item.threadId, - occurredAt: item.updatedAt, - payload: item, - }; - expect(projectDomainEventForWire(event)).toEqual(event); - }); - it("keeps transcript bodies out of shell rows", () => { const now = DateTime.makeUnsafe("2026-08-13T00:00:00.000Z"); const threadId = ThreadId.make("thread-shell-budget"); @@ -166,43 +149,12 @@ describe("orchestration V2 wire projection", () => { expect(JSON.stringify(shell).length).toBeLessThan(2_000); }); - it("omits oversized dynamic tool results without mutating persistence data", () => { - const output = { content: [{ type: "text", text: "first line\n" + "x".repeat(100_000) }] }; - const item = { ...base, output } satisfies OrchestrationV2TurnItem; - const projected = projectTurnItemForWire(item); - - expect(item.output).toBe(output); - expect(projected).not.toBe(item); - expect(JSON.stringify(projected).length).toBeLessThan(2_000); - expect(projected).not.toHaveProperty("output"); - }); - it("omits even small dynamic tool results while retaining input", () => { const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem; expect(projectTurnItemForWire(item)).toEqual({ ...base, outputOmitted: true }); expect(item.output).toEqual({ ok: true }); }); - it("keeps undefined dynamic input intact", () => { - const item = { ...base, input: undefined } satisfies OrchestrationV2TurnItem; - expect(projectTurnItemForWire(item)).toEqual(item); - }); - - it.each([ - [" \t\r\n\n first\t line \r\n" + "x\n".repeat(10_000), "first line"], - [" \n".repeat(10_000), "Large tool output"], - ["a".repeat(160) + "\n" + "x".repeat(20_000), "a".repeat(160)], - ["a".repeat(161) + "\n" + "x".repeat(20_000), "a".repeat(159) + "…"], - ["a".repeat(159) + "\t b" + "x".repeat(20_000), "a".repeat(159) + "…"], - [" café\u00a0\u2003😀 \t\r\n" + "x".repeat(20_000), "café 😀"], - ])("preserves bounded input summary normalization for case %#", (input, summary) => { - const projected = projectTurnItemForWire({ ...base, input }); - expect(projected.type === "dynamic_tool" ? projected.input : null).toEqual({ - summary, - truncated: true, - }); - }); - it("uses encoded JSON bytes for strings near the dynamic-value limit", () => { const small = '"'.repeat(8_191); const large = '"'.repeat(8_192); @@ -215,21 +167,6 @@ describe("orchestration V2 wire projection", () => { }); }); - it("serializes an oversized structured input only once for its summary", () => { - let serializations = 0; - const input = { - toJSON() { - serializations += 1; - return { text: "x".repeat(100_000) }; - }, - }; - const projected = projectTurnItemForWire({ ...base, input }); - expect(serializations).toBe(1); - expect(projected.type === "dynamic_tool" ? projected.input : null).toMatchObject({ - truncated: true, - }); - }); - it("truncates detail at a UTF-8 boundary without changing the source", () => { const progress = "a".repeat(32_767) + "😀" + "x".repeat(100_000); const item = { @@ -251,30 +188,6 @@ describe("orchestration V2 wire projection", () => { expect(item.progress).toBe(progress); }); - it.each(["", "small output", "x".repeat(1_048_576)])( - "omits command output of every size, case %#", - (output) => { - const item = { ...base, type: "command_execution" as const, input: "test", output }; - const projected = projectTurnItemForWire(item); - expect(projected).not.toHaveProperty("output"); - expect(projected).toMatchObject({ input: "test", status: "completed" }); - expect(projected.type === "command_execution" ? projected.outputOmitted : null).toBe( - output ? true : undefined, - ); - expect(item.output).toBe(output); - }, - ); - - it.each([ - ["echo ok", "echo ok"], - ["a".repeat(262_143) + "😀", "a".repeat(262_143) + "\n… output truncated for transport"], - ])("bounds fetched command input without changing persistence, case %#", (input, expected) => { - const item = { ...base, type: "command_execution" as const, input, output: "ok" }; - const projected = projectTurnItemForDetail(item); - expect(projected).toMatchObject({ input: expected, output: "ok" }); - expect(item.input).toBe(input); - }); - it("leaves a screenshot's bytes out of the fetched tool output", () => { const data = "A".repeat(600_000); const item = { @@ -388,13 +301,4 @@ describe("orchestration V2 wire projection", () => { expect(event.payload.output).toBe(output); expect(projectDomainEventForWire(projected)).toEqual(projected); }); - - it("does not serialize raw dynamic output to discard it", () => { - const output = { - toJSON: () => { - throw new Error("Raw output must not be serialized"); - }, - }; - expect(projectTurnItemForWire({ ...base, output })).not.toHaveProperty("output"); - }); }); diff --git a/apps/server/src/orchestration-v2/assistantStreaming.test.ts b/apps/server/src/orchestration-v2/assistantStreaming.test.ts index 8e36a5abd75e..d71042312bfb 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.test.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.test.ts @@ -1,68 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; -import { MessageId, NodeId, ProviderDriverKind, ThreadId, TurnItemId } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import { makeAssistantStreamingFilter, splitBufferedAssistantText } from "./assistantStreaming.ts"; -import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; - -const message = (text: string, streaming = true): ProviderAdapter.ProviderAdapterV2Event => ({ - type: "message.updated", - driver: ProviderDriverKind.make("codex"), - message: { - id: MessageId.make("message"), - threadId: ThreadId.make("thread"), - runId: null, - nodeId: null, - createdBy: "agent", - creationSource: "provider", - updatedAt: DateTime.makeUnsafe("2026-09-14T00:00:00Z"), - role: "assistant", - text, - attachments: [], - createdAt: DateTime.makeUnsafe("2026-09-14T00:00:00Z"), - streaming, - }, -}); - -const turnItem = ( - text: string, - streaming = true, - type: "assistant_message" | "reasoning" = "assistant_message", -): ProviderAdapter.ProviderAdapterV2Event => ({ - type: "turn_item.updated", - driver: ProviderDriverKind.make("codex"), - turnItem: { - id: TurnItemId.make("item"), - threadId: ThreadId.make("thread"), - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 0, - status: streaming ? "running" : "completed", - title: null, - startedAt: null, - completedAt: null, - updatedAt: DateTime.makeUnsafe("2026-09-14T00:00:00Z"), - ...(type === "assistant_message" ? { type, messageId: MessageId.make("message") } : { type }), - text, - streaming, - }, -}); +import { splitBufferedAssistantText } from "./assistantStreaming.ts"; describe("V2 assistant streaming", () => { - it("delivers completed paragraphs, coalesces rapid updates, and flushes final text", () => { - const filter = makeAssistantStreamingFilter("paragraph"); - expect(filter(message("First"), 0)).toBeNull(); - expect(filter(message("First\n\nSec"), 10)).toMatchObject({ message: { text: "First\n\n" } }); - expect(filter(message("First\n\nSecond\n\nThi"), 100)).toBeNull(); - expect(filter(message("First\n\nSecond\n\nThird"), 410)).toMatchObject({ - message: { text: "First\n\nSecond\n\n" }, - }); - const final = message("First\n\nSecond\n\nThird", false); - expect(filter(final, 420)).toBe(final); - }); it("keeps code fences intact", () => { expect(splitBufferedAssistantText("Intro\n\n```ts\nx()\n\n")).toEqual({ ready: "Intro\n\n", @@ -73,69 +12,6 @@ describe("V2 assistant streaming", () => { rest: "rest", }); }); - it("holds streaming text until the full response completes", () => { - const running = message("partial"); - const final = message("complete", false); - const buffered = makeAssistantStreamingFilter("turn"); - expect(buffered(running, 0)).toBeNull(); - expect(buffered(message("First\n\nSecond\n\n"), 500)).toBeNull(); - expect(buffered(final, 501)).toBe(final); - }); - - it.each(["assistant_message", "reasoning"] as const)( - "buffers %s at paragraph boundaries and flushes the final item", - (type) => { - const item = (text: string, streaming = true) => turnItem(text, streaming, type); - const filter = makeAssistantStreamingFilter("paragraph"); - expect(filter(item("First"), 0)).toBeNull(); - expect(filter(item("First\n\nSec"), 10)).toMatchObject({ - turnItem: { text: "First\n\n", streaming: true, type }, - }); - expect(filter(item("First\n\nSecond\n\nThi"), 100)).toBeNull(); - expect(filter(item("First\n\nSecond\n\nThi"), 410)).toMatchObject({ - turnItem: { text: "First\n\nSecond\n\n", streaming: true }, - }); - const final = item("First\n\nSecond\n\nThird", false); - expect(filter(final, 420)).toBe(final); - - const buffered = makeAssistantStreamingFilter("turn"); - expect(buffered(item("First\n\nSecond"), 0)).toBeNull(); - expect(buffered(final, 1)).toBe(final); - }, - ); - - it.each(["turn", "paragraph"] as const)( - "suppresses running assistant nodes in %s mode while delivering tool and completed nodes", - (mode) => { - const filter = makeAssistantStreamingFilter(mode); - const running: Extract = { - type: "node.updated", - driver: ProviderDriverKind.make("codex"), - node: { - id: NodeId.make("node"), - threadId: ThreadId.make("thread"), - runId: null, - parentNodeId: null, - rootNodeId: NodeId.make("root"), - kind: "assistant_message", - status: "running", - countsForRun: false, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: null, - completedAt: null, - }, - }; - expect(filter(running, 0)).toBeNull(); - const completed = { ...running, node: { ...running.node, status: "completed" as const } }; - expect(filter(completed, 1)).toBe(completed); - const tool = { ...running, node: { ...running.node, kind: "tool_call" as const } }; - expect(filter(tool, 2)).toBe(tool); - }, - ); }); it("holds a streamed section heading until its content has a boundary", () => { diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts deleted file mode 100644 index 77cd6f3933d5..000000000000 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ProviderDriverKind } from "@t3tools/contracts"; - -import { - AcpRegistryAdapterV2Driver, - type AcpRegistryAdapterV2DriverEnv, -} from "@t3tools/provider-acp-registry/server"; -import { - ClaudeAdapterV2Driver, - type ClaudeAdapterV2DriverEnv, -} from "./Adapters/ClaudeAdapterV2.ts"; -import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/CodexAdapterV2.ts"; -import { - CursorAdapterV2Driver, - type CursorAdapterV2DriverEnv, -} from "@t3tools/provider-cursor/server"; -import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "@t3tools/provider-grok/server"; -import { - OpenCodeAdapterV2Driver, - type OpenCodeAdapterV2DriverEnv, -} from "@t3tools/provider-opencode/server"; -import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server"; -import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver"; - -export type BuiltInProviderAdapterDriversV2Env = - | AcpRegistryAdapterV2DriverEnv - | ClaudeAdapterV2DriverEnv - | CodexAdapterV2DriverEnv - | CursorAdapterV2DriverEnv - | GrokAdapterV2DriverEnv - | OpenCodeAdapterV2DriverEnv - | PiAdapterV2DriverEnv; - -const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< - AnyProviderAdapterDriver -> = [ - CodexAdapterV2Driver, - ClaudeAdapterV2Driver, - CursorAdapterV2Driver, - OpenCodeAdapterV2Driver, - GrokAdapterV2Driver, - PiAdapterV2Driver, - AcpRegistryAdapterV2Driver, -]; - -export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet = new Set( - BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.map((driver) => driver.driverKind), -); diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index dc29e62330ee..7a5542e65bad 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -559,18 +559,29 @@ const makeCodexAdapter = (capturedTurns: Ref.Ref>) = const waitForIdle = Effect.fn("LegacyV1Cutover.waitForIdle")(function* (threadId: ThreadId) { const orchestrator = yield* Orchestrator.OrchestratorV2; - for (let attempt = 0; attempt < 1_000; attempt += 1) { - const projection = yield* orchestrator.getThreadProjection(threadId); - if ( - projection.runs.every( - (run) => !["queued", "starting", "running", "waiting"].includes(run.status), - ) - ) { - return projection; - } - yield* Effect.sleep("5 millis"); + const eventSink = yield* EventSink.EventSinkV2; + const isIdle = (projection: OrchestrationV2ThreadProjection) => + projection.runs.every( + (run) => !["queued", "starting", "running", "waiting"].includes(run.status), + ); + const afterSequence = yield* eventSink.latestSequence(); + const current = yield* orchestrator.getThreadProjection(threadId); + if (isIdle(current)) { + return current; } - return yield* Effect.die(new Error("Cutover test timed out waiting for idle")); + // Projections commit with their events, so each later event is the only time to re-read. + const [idle] = yield* eventSink.stream({ afterSequence }).pipe( + Stream.mapEffect(() => orchestrator.getThreadProjection(threadId)), + Stream.filter(isIdle), + Stream.take(1), + Stream.runCollect, + ); + if (idle === undefined) { + return yield* Effect.die( + new Error("Cutover test event stream ended before the thread went idle"), + ); + } + return idle; }); const layerBoot = (input: { diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts index f10013467de3..3b2351b28a1e 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts @@ -9,7 +9,6 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; @@ -115,6 +114,7 @@ it.effect( }).pipe(Effect.provide(layerTest)), ); +// regression: #15920 one bad legacy message stopped the server from starting it.effect("propagates interruption from transcript reads and retry-marker writes", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -151,48 +151,6 @@ it.effect("propagates interruption from transcript reads and retry-marker writes ); it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { - it.effect("uses the created-thread index for startup migration checks", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const statements: string[] = []; - 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); - const query = span.attributes.get("db.query.text"); - if ( - typeof query === "string" && - query.includes("FROM projection_threads AS thread") && - query.includes("FROM orchestration_events AS event") - ) { - statements.push(query); - } - }; - return span; - }, - }); - - assert.equal(yield* importer.pendingThreadCount.pipe(Effect.withTracer(tracer)), 0); - assert.deepStrictEqual(yield* importer.reconcileShells.pipe(Effect.withTracer(tracer)), { - importedThreadCount: 0, - importedMessageCount: 0, - }); - assert.lengthOf(statements, 2); - for (const statement of statements) { - const plan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statement}`, - ); - assert.match( - plan.map((row) => row.detail).join("\n"), - /SEARCH event USING INDEX orchestration_events_v2_created_threads_idx \(stream_id=\?\)/, - ); - } - }), - ); - it.effect("imports lightweight shells, hydrates transcripts, and remains idempotent", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -697,72 +655,3 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { }), ); }); - -it.layer(layerTest)("LegacyV1ThreadImporter transcript lookup", (it) => { - it.effect("finds imported messages on the V2 thread index, past the thread's V1 events", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const threadId = ThreadId.make("thread:legacy-lookup"); - yield* sql` - INSERT INTO projection_projects ( - project_id, title, workspace_root, default_model_selection_json, scripts_json, - created_at, updated_at, deleted_at - ) VALUES ( - 'project:legacy-lookup', 'Legacy project', '/tmp/legacy-lookup', - '{"instanceId":"codex","model":"gpt-5.4"}', '[]', - '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL - ) - `; - yield* sql` - INSERT INTO projection_threads ( - thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, - created_at, updated_at - ) VALUES ( - ${threadId}, 'project:legacy-lookup', 'Migrated conversation', - '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', 'default', - '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z' - ) - `; - yield* sql` - INSERT INTO projection_thread_messages ( - message_id, thread_id, role, text, is_streaming, created_at, updated_at - ) VALUES ( - 'message:legacy-lookup:1', ${threadId}, 'user', 'Question', 0, - '2026-01-01T01:00:00.000Z', '2026-01-01T01:00:00.000Z' - ) - `; - yield* importer.reconcileShells; - - const statements: string[] = []; - 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); - const query = span.attributes.get("db.query.text"); - if ( - typeof query === "string" && - /^\s*SELECT event_id\s+FROM orchestration_events/.test(query) - ) { - statements.push(query); - } - }; - return span; - }, - }); - yield* importer.ensureTranscript(threadId).pipe(Effect.withTracer(tracer)); - - assert.lengthOf(statements, 1); - const plan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statements[0]}`, - [threadId, "migration:v1:message:%"], - ); - assert.match( - plan.map((row) => row.detail).join("\n"), - /USING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/, - ); - }), - ); -}); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index faf8a05b9ef2..4ab9e8b6d67b 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -5,11 +5,7 @@ import type { } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import { - PULL_REQUEST_WATCH_WAKE_LIMIT, - evaluatePullRequestWatch, - pullRequestWatchMessage, -} from "./pullRequestWatch.ts"; +import { PULL_REQUEST_WATCH_WAKE_LIMIT, evaluatePullRequestWatch } from "./pullRequestWatch.ts"; const STARTED = "2026-10-02T12:00:00.000Z"; @@ -128,6 +124,7 @@ describe("evaluatePullRequestWatch", () => { ]); }); + // regression: #16208 PR watches burned GitHub's rate limit and gave up it("does not report passed again for a new passed check where none is required", () => { const first = evaluatePullRequestWatch( watch(), @@ -256,28 +253,3 @@ describe("evaluatePullRequestWatch", () => { assert.equal(pushed.next.wakes, 1); }); }); - -describe("pullRequestWatchMessage", () => { - it("tells the agent what changed and marks failures for the timeline", () => { - const report = evaluatePullRequestWatch( - watch(), - detail({ checks: [check("lint", "failure")] }), - [remark("reviewer", "2026-10-02T12:10:00Z", "Needs a test.")], - ); - const message = pullRequestWatchMessage({ - number: 12, - url: "https://github.com/o/r/pull/12", - baseBranch: "main", - headSha: report.next.headSha, - report, - }); - assert.include(message.text, "- Checks failed on aaaaaaa:\n - lint https://ci.example/lint"); - assert.include(message.text, ' - reviewer on src/index.ts: "Needs a test."'); - assert.include(message.text, "unwatch_pull_request"); - assert.deepEqual(message.notification, { - source: { kind: "monitor" }, - outcome: "failed", - summary: "#12: checks failed, new comments", - }); - }); -}); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c295011736c2..9db0564cced0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -36,7 +36,6 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -69,7 +68,6 @@ import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; import { PULL_REQUEST_WATCH_WAKE_LIMIT } from "./pullRequestWatch.ts"; -import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -638,50 +636,6 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll( ); it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { - it.effect("emits model updates separately from provider switches", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("runtime-layer-model-selection-events"); - - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("runtime-layer-model-selection-events-create"), - threadId, - projectId: ProjectId.make("runtime-layer-model-selection-events-project"), - title: "Model selection events", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - - const sameInstance = yield* orchestrator.dispatch({ - type: "thread.model-selection.set", - commandId: CommandId.make("runtime-layer-model-selection-events-update"), - threadId, - modelSelection: { ...modelSelection, model: "gpt-5.5" }, - }); - assert.deepEqual( - sameInstance.storedEvents.map((stored) => stored.event.type), - ["thread.model-selection-updated"], - ); - - const differentInstance = yield* orchestrator.dispatch({ - type: "thread.model-selection.set", - commandId: CommandId.make("runtime-layer-model-selection-events-switch"), - threadId, - modelSelection: { instanceId: alternateInstanceId, model: "gpt-5.5" }, - }); - assert.deepEqual( - differentInstance.storedEvents.map((stored) => stored.event.type), - ["thread.provider-switched"], - ); - }), - ); - const seedFailingRollbackThread = (name: string) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -788,44 +742,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }), ); - it.effect("ignores a late failure from a rollback that a newer one superseded", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const { threadId, rollback } = yield* seedFailingRollbackThread( - "runtime-rollback-superseded", - ); - - const olderCommandId = CommandId.make("runtime-rollback-superseded-older"); - yield* rollback(olderCommandId); - for (let attempt = 0; attempt < 4; attempt++) { - yield* worker.drain(); - yield* TestClock.adjust("30 seconds"); - } - const newerCommandId = CommandId.make("runtime-rollback-superseded-newer"); - yield* rollback(newerCommandId); - yield* worker.drain(); - - const [olderEffect] = yield* outbox.listByCommandId(olderCommandId); - assert.equal(olderEffect?.status, "failed"); - const superseded = yield* orchestrator.getThreadProjection(threadId); - assert.isNull(superseded.thread.rollbackFailure); - - for (let attempt = 0; attempt < 5; attempt++) { - yield* TestClock.adjust("30 seconds"); - yield* worker.drain(); - } - const [newerEffect] = yield* outbox.listByCommandId(newerCommandId); - assert.equal(newerEffect?.status, "failed"); - const failed = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(failed.thread.rollbackFailure, { - requestId: newerCommandId, - message: ROLLBACK_FAILED_MESSAGE, - }); - }), - ); - it.effect("rejects non-ready rollback targets before persisting events or effects", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2113,67 +2029,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("persists linked pull requests through projection rebuilds and unlinking", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - const threadId = ThreadId.make("runtime-layer-linked-pull-request-thread"); - const linkedPullRequest = { - projectId: ProjectId.make("runtime-layer-linked-pull-request-project"), - repository: "pingdotgg/t3code", - number: 8160, - url: "https://github.com/pingdotgg/t3code/pull/8160", - } as const; - - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("runtime-layer-linked-pull-request-create"), - threadId, - projectId: linkedPullRequest.projectId, - title: "Linked pull request thread", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("runtime-layer-linked-pull-request-link"), - threadId, - linkedPullRequest, - }); - - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).thread.linkedPullRequest, - linkedPullRequest, - ); - const linkedShell = yield* orchestrator.getThreadShell(threadId); - assert.isNotNull(linkedShell); - assert.deepEqual(linkedShell.linkedPullRequest, linkedPullRequest); - - const rebuilt = yield* maintenance.rebuild; - assert.isTrue(rebuilt.valid); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).thread.linkedPullRequest, - linkedPullRequest, - ); - - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make("runtime-layer-linked-pull-request-unlink"), - threadId, - linkedPullRequest: null, - }); - assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.linkedPullRequest); - const unlinkedShell = yield* orchestrator.getThreadShell(threadId); - assert.isNotNull(unlinkedShell); - assert.isNull(unlinkedShell.linkedPullRequest); - }), - ); - it.effect("keeps the branch pull request when linking another pull request", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2668,149 +2523,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect( - "reads a watched pull request once a pass, waits out rate limits, and skips quiet passes", - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("pr-watch-shared-project"); - yield* seedProject({ - projectId, - title: "Watch shared", - workspaceRoot: "/workspace/watch-shared", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - const threadIds = ["one", "two"].map((name) => - ThreadId.make(`runtime-pull-request-watch-shared-${name}`), - ); - const watchFrom = (threadId: ThreadId) => - Effect.gen(function* () { - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`create:${threadId}`), - threadId, - projectId, - title: "Watch shared", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make(`watch:${threadId}`), - threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 9, - watching: true, - link: { url: "https://github.com/pingdotgg/t3code/pull/9", source: "agent" }, - }); - }); - for (const threadId of threadIds) yield* watchFrom(threadId); - const rateLimited = new PullRequestOperationError({ - operation: "getChangeRequest", - detail: "github requests are paused until the rate limit resets", - cause: new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequest", - reason: "rate-limited", - detail: "paused", - }), - }); - let host: "rate-limited" | "open" | "closed" = "rate-limited"; - let reads = 0; - const reactor = yield* PullRequestWatchReactor.make.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.suspend(() => { - reads += 1; - return host === "rate-limited" - ? Effect.fail(rateLimited) - : Effect.succeed({ - ...watchedPullRequestDetail({ - projectId, - number: 9, - at: "2026-10-02T12:00:00.000Z", - }), - state: host, - checks: [ - { name: "lint", status: "success", description: null, url: null }, - ], - }); - }), - activity: () => - host === "rate-limited" - ? Effect.fail(rateLimited) - : Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }), - }), - ), - ), - ); - const summaries = (threadId: ThreadId) => - Effect.map(orchestrator.getThreadRecords(threadId, ["messages"]), ({ messages }) => - messages.flatMap((message) => message.notification?.summary ?? []), - ); - const watching = (threadId: ThreadId) => - Effect.map( - orchestrator.getThreadShell(threadId), - (thread) => thread?.pullRequests?.[0]?.watch !== undefined, - ); - - for (let pass = 0; pass < 20; pass += 1) yield* reactor.sweep; - assert.equal(reads, 20); - for (const threadId of threadIds) { - assert.isTrue(yield* watching(threadId)); - assert.deepEqual(yield* summaries(threadId), []); - } - - host = "open"; - yield* reactor.sweep; - assert.equal(reads, 21); - for (const threadId of threadIds) { - assert.deepEqual(yield* summaries(threadId), ["#9: checks passed"]); - } - - yield* reactor.sweep; - assert.equal(reads, 21); - - const late = ThreadId.make("runtime-pull-request-watch-shared-late"); - yield* watchFrom(late); - yield* reactor.sweep; - assert.equal(reads, 22); - assert.deepEqual(yield* summaries(late), ["#9: checks passed"]); - for (const threadId of threadIds) { - assert.deepEqual(yield* summaries(threadId), ["#9: checks passed"]); - } - threadIds.push(late); - - host = "closed"; - yield* TestClock.adjust("10 minutes"); - yield* reactor.sweep; - assert.equal(reads, 23); - for (const threadId of threadIds) { - assert.isFalse(yield* watching(threadId)); - assert.deepEqual(yield* summaries(threadId), [ - "#9: checks passed", - "#9: closed, stopped watching", - ]); - } - }), - ); - it.effect("wakes once for a state it reported, across a restart and a new watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2910,6 +2622,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // regression: #21155 watching a pull request again repeated news the thread already got it.effect("a watch that ends on the comment limit keeps what its last pass saw", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -3033,6 +2746,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // regression: #21155 watching a pull request again repeated news the thread already got it.effect("watching a dismissed stack layer again keeps what its last watch told", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -3100,505 +2814,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("reports how long an ended watch was quiet", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("runtime-pull-request-watch-ended"); - const projectId = ProjectId.make("pr-watch-ended-project"); - yield* seedProject({ - projectId, - title: "Watch ended", - workspaceRoot: "/workspace/watch-ended", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("pr-watch-ended-create"), - threadId, - projectId, - title: "Watch ended", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - const key = { host: "github.com", repository: "pingdotgg/t3code", number: 11 }; - const watching = (on: boolean, id: string) => - orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-ended-${id}`), - threadId, - ...key, - watching: on, - link: { url: "https://github.com/pingdotgg/t3code/pull/11", source: "agent" }, - }); - yield* watching(true, "start"); - - let headSha = "aaaaaaa"; - const ended: Array = []; - const capture = Logger.make(({ message }) => { - const [text, fields] = Array.isArray(message) ? message : [message]; - if (text === "pull request watch ended") ended.push(fields); - }); - const reactor = yield* PullRequestWatchReactor.make.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.sync(() => ({ - ...watchedPullRequestDetail({ projectId, number: key.number, at: "2026-10-02" }), - headSha, - checks: [], - })), - activity: () => - Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }), - }), - ), - ), - ); - const sweep = reactor.sweep.pipe(Effect.provide(Logger.layer([capture]))); - - yield* sweep; - yield* TestClock.adjust("6 hours"); - headSha = "bbbbbbb"; - yield* sweep; - yield* TestClock.adjust("2 hours"); - yield* sweep; - yield* watching(false, "stop"); - yield* sweep; - yield* sweep; - - assert.deepEqual(ended, [ - { - threadId, - pullRequest: "github.com/pingdotgg/t3code#11", - reason: "stopped", - minutes: 480, - quietMinutes: 120, - longestQuietMinutes: 360, - wakes: 0, - reads: 3, - partial: false, - }, - ]); - }), - ); - - it.effect("with a host fingerprint, a watch reads only what moved", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("pr-watch-fingerprint-project"); - const threadId = ThreadId.make("runtime-pull-request-watch-fingerprint"); - yield* seedProject({ - projectId, - title: "Watch fingerprint", - workspaceRoot: "/workspace/watch-fingerprint", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("pr-watch-fingerprint-create"), - threadId, - projectId, - title: "Watch fingerprint", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make("pr-watch-fingerprint-start"), - threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 11, - watching: true, - link: { url: "https://github.com/pingdotgg/t3code/pull/11", source: "agent" }, - }); - - let fingerprint: { status: string; remarks: string } | "rate-limited" = { - status: "OPEN pending", - remarks: "0", - }; - type CheckStatus = "pending" | "success" | "failure"; - let checks: { lint: CheckStatus; test: CheckStatus } = { lint: "pending", test: "pending" }; - let cachedChecks: typeof checks | null = null; - let comments: Array = []; - let detailReads = 0; - let activityReads = 0; - const reactor = yield* PullRequestWatchReactor.make.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - watchFingerprint: () => - Effect.suspend(() => - fingerprint === "rate-limited" - ? Effect.fail( - new PullRequestOperationError({ - operation: "watchFingerprint", - detail: "paused", - cause: new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestWatchFingerprint", - reason: "rate-limited", - detail: "paused", - }), - }), - ) - : Effect.succeed(fingerprint), - ), - invalidate: () => - Effect.sync(() => { - cachedChecks = null; - }), - detail: () => - Effect.sync(() => { - detailReads += 1; - const answer = cachedChecks ?? checks; - cachedChecks = null; - return { - ...watchedPullRequestDetail({ - projectId, - number: 11, - at: "2026-10-02T12:00:00.000Z", - }), - checks: Object.entries(answer).map(([name, status]) => ({ - name, - status, - description: null, - url: null, - })), - }; - }), - activity: () => - Effect.sync(() => { - activityReads += 1; - return { - comments, - commentCount: comments.length, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ), - ), - ); - const reads = () => ({ detail: detailReads, activity: activityReads }); - const summaries = Effect.map( - orchestrator.getThreadRecords(threadId, ["messages"]), - ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), - ); - - yield* reactor.sweep; - assert.deepEqual(reads(), { detail: 1, activity: 1 }); - - checks = { lint: "failure", test: "pending" }; - yield* reactor.sweep; - assert.deepEqual(reads(), { detail: 2, activity: 1 }); - assert.deepEqual(yield* summaries, ["#11: checks failed"]); - - yield* TestClock.adjust("1 millis"); - cachedChecks = checks; - checks = { lint: "failure", test: "success" }; - fingerprint = { status: "OPEN settled", remarks: "0" }; - yield* reactor.sweep; - assert.deepEqual(reads(), { detail: 3, activity: 1 }); - - for (let pass = 0; pass < 5; pass += 1) yield* reactor.sweep; - assert.deepEqual(reads(), { detail: 3, activity: 1 }); - - yield* TestClock.adjust("1 millis"); - comments = [ - { - id: "comment-1", - kind: "issue-comment", - author: { login: "reviewer", name: null, avatarUrl: null }, - body: "Please rename this.", - createdAt: "2999-01-01T00:00:00.000Z", - url: null, - path: null, - reviewState: null, - }, - ]; - fingerprint = { status: "OPEN settled", remarks: "1" }; - yield* reactor.sweep; - assert.deepEqual(reads(), { detail: 4, activity: 2 }); - assert.deepEqual(yield* summaries, ["#11: checks failed", "#11: new comments"]); - - fingerprint = "rate-limited"; - yield* reactor.sweep; - assert.deepEqual(reads(), { detail: 4, activity: 2 }); - assert.isDefined((yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch); - - fingerprint = { status: "OPEN settled", remarks: "1" }; - yield* TestClock.adjust("30 minutes"); - yield* reactor.sweep; - assert.deepEqual(reads(), { detail: 5, activity: 3 }); - assert.deepEqual(yield* summaries, ["#11: checks failed", "#11: new comments"]); - }), - ); - - it.effect.each(["deadline", "fingerprint lost", "fingerprint changed", "mixed-age remarks"])( - "delivers edited cached review replies: %s", - (mode) => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; - const projectId = ProjectId.make(`pr-watch-tail-project-${mode}`); - const threadId = ThreadId.make(`runtime-pr-watch-tail-${mode}`); - const startedAt = "2026-10-02T12:00:00.000Z"; - yield* TestClock.setTime(Date.parse(startedAt)); - yield* seedProject({ - projectId, - title: "Watch edited replies", - workspaceRoot: "/workspace/watch-tail", - defaultModelSelection: null, - createdAt: startedAt, - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`pr-watch-tail-create-${mode}`), - threadId, - projectId, - title: "Watch edited replies", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-tail-start-${mode}`), - threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 12, - watching: true, - link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, - }); - const reply: PullRequestComment = { - id: "reply-11", - kind: "review-comment", - author: { login: "reviewer", name: null, avatarUrl: null }, - body: "Original feedback.", - createdAt: "2026-10-02T11:00:00.000Z", - url: null, - path: "src/index.ts", - reviewState: null, - }; - let replies = Array.from( - { length: mode === "fingerprint changed" ? 2 : 1 }, - (_, index) => ({ - ...reply, - id: `reply-${index}-11`, - }), - ); - const reviewThreads = replies.map((reply, index) => ({ - id: `review-thread-${index}`, - path: "src/index.ts", - line: index + 1, - side: "right" as const, - isResolved: false, - isOutdated: false, - comments: Array.from({ length: 10 }, (_, index) => ({ - ...reply, - id: `${reply.id}-preview-${index}`, - })), - commentCount: 11, - nextCommentsCursor: "after-10", - })); - let fingerprint: string | null = mode === "mixed-age remarks" ? null : "before"; - let failPage = false; - let activityReads = 0; - const pageReads: Array<{ threadId: string; cursor: string }> = []; - const expectedPages = reviewThreads.map((thread) => ({ - threadId: thread.id, - cursor: "after-10", - })); - const reactor = yield* PullRequestWatchReactor.make.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThreadsWithPullRequests: () => - projections - .getThreadsWithPullRequests() - .pipe( - Effect.map((threads) => threads.filter((thread) => thread.id === threadId)), - ), - }), - Layer.mock(PullRequestService.PullRequestService)({ - watchFingerprint: () => - Effect.succeed( - fingerprint === null ? null : { status: "OPEN pending", remarks: fingerprint }, - ), - invalidate: () => Effect.void, - detail: () => - Effect.succeed({ - ...watchedPullRequestDetail({ projectId, number: 12, at: startedAt }), - checks: [{ name: "test", status: "pending", description: null, url: null }], - }), - activity: () => - Effect.sync(() => { - activityReads += 1; - return { - comments: reviewThreads.flatMap((thread) => thread.comments), - commentCount: reviewThreads.length * 11, - commentsTruncated: true, - reviewThreadsTruncated: false, - reviewThreads, - commits: [], - }; - }), - threadComments: (input) => - Effect.suspend(() => { - const index = reviewThreads.findIndex((thread) => thread.id === input.threadId); - assert.isAtLeast(index, 0); - pageReads.push({ threadId: input.threadId, cursor: input.cursor }); - return failPage && index === 1 - ? Effect.fail( - new PullRequestOperationError({ - operation: "threadComments", - detail: "Page unavailable", - }), - ) - : Effect.succeed({ comments: [replies[index]!], nextCursor: null }); - }), - }), - ), - ), - ); - const messages = orchestrator - .getThreadRecords(threadId, ["messages"]) - .pipe(Effect.map((records) => records.messages)); - const watch = orchestrator - .getThreadShell(threadId) - .pipe(Effect.map((thread) => thread?.pullRequests?.[0]?.watch)); - yield* reactor.sweep; - const baseline = yield* watch; - replies = replies.map((reply) => ({ - ...reply, - body: "Edited feedback.", - editedAt: "2026-10-02T12:00:01.000Z", - })); - - if (mode === "mixed-age remarks") { - const thread = reviewThreads[0]!; - const visibleReply = { - ...thread.comments[0]!, - body: "Edited visible feedback.", - editedAt: "2026-10-02T12:02:00.000Z", - }; - thread.comments[0] = visibleReply; - yield* TestClock.adjust("5 minutes"); - yield* reactor.sweep; - const intermediateWatch = yield* watch; - const intermediatePageReads = [...pageReads]; - yield* TestClock.adjust("5 minutes"); - yield* reactor.sweep; - const wakes = yield* messages; - assert.isTrue(wakes.every((wake) => wake.notification?.summary === "#12: new comments")); - assert.equal((yield* watch)?.remarksThrough, visibleReply.editedAt); - assert.deepEqual((yield* watch)?.remarkIds, [visibleReply.id]); - assert.deepEqual(pageReads, [...expectedPages, ...expectedPages]); - const wakeText = wakes.map((wake) => wake.text).join("\n"); - for (const reply of [visibleReply, ...replies]) { - assert.include(wakeText, reply.body); - assert.equal(wakeText.split(reply.body).length - 1, 1); - } - // A watermark past the deep edit requires a fresh tail, even before its deadline. - if (intermediateWatch?.remarksThrough === visibleReply.editedAt) { - assert.deepEqual(intermediatePageReads, [...expectedPages, ...expectedPages]); - } else { - assert.equal(intermediateWatch?.remarksThrough, baseline?.remarksThrough); - } - yield* reactor.sweep; - yield* TestClock.adjust("30 minutes"); - yield* reactor.sweep; - assert.deepEqual(yield* messages, wakes); - assert.equal((yield* watch)?.remarksThrough, visibleReply.editedAt); - assert.deepEqual(pageReads, [...expectedPages, ...expectedPages, ...expectedPages]); - return; - } - - if (mode === "fingerprint changed") { - yield* TestClock.adjust("1 minute"); - fingerprint = "after"; - failPage = true; - yield* reactor.sweep; - assert.deepEqual(yield* messages, []); - assert.deepEqual(yield* watch, baseline); - assert.deepEqual(pageReads, [...expectedPages, ...expectedPages]); - yield* TestClock.adjust("2 seconds"); - // Neither deep edit changes the host fingerprint or the thread's comment count. - replies = replies.map((reply, index) => ({ - ...reply, - body: `Latest feedback ${index + 1}.`, - editedAt: `2026-10-02T12:01:0${index + 1}.000Z`, - })); - failPage = false; - } else { - if (mode === "fingerprint lost") fingerprint = null; - yield* TestClock.adjust(mode === "deadline" ? "29 minutes" : "5 minutes"); - yield* reactor.sweep; - assert.deepEqual(pageReads, expectedPages); - assert.deepEqual(yield* messages, []); - // An activity read at five minutes must not move the tail's ten-minute deadline. - assert.equal(activityReads, mode === "deadline" ? 1 : 2); - yield* TestClock.adjust(mode === "deadline" ? "1 minute" : "5 minutes"); - } - yield* reactor.sweep; - const wakes = yield* messages; - assert.deepEqual( - wakes.map((message) => message.notification?.summary), - ["#12: new comments"], - ); - for (const reply of replies) { - assert.include(wakes[0]?.text ?? "", reply.body); - } - assert.notInclude(wakes[0]?.text ?? "", "Original feedback."); - assert.deepEqual( - pageReads, - Array.from( - { length: mode === "fingerprint changed" ? 3 : 2 }, - () => expectedPages, - ).flat(), - ); - const latestReply = replies.at(-1)!; - assert.equal((yield* watch)?.remarksThrough, latestReply.editedAt); - assert.deepEqual((yield* watch)?.remarkIds, [latestReply.id]); - yield* reactor.sweep; - assert.lengthOf(yield* messages, 1); - assert.lengthOf(pageReads, mode === "fingerprint changed" ? 6 : 2); - yield* TestClock.adjust("30 minutes"); - yield* reactor.sweep; - assert.lengthOf(yield* messages, 1); - assert.lengthOf(pageReads, mode === "fingerprint changed" ? 8 : 3); - }), - ); - it.effect.each([ "single page", "paginated", @@ -4380,88 +3595,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("cancels queued work when a thread is archived", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make("runtime-layer-archive-queued-thread"); - - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("runtime-layer-archive-queued-create"), - threadId, - projectId: ProjectId.make("runtime-layer-archive-queued-project"), - title: "Archive queued work", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: "/tmp/runtime-layer-archive-queued", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("runtime-layer-archive-queued-active-message"), - threadId, - messageId: MessageId.make("runtime-layer-archive-queued-active-message"), - text: "Keep the provider occupied.", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("runtime-layer-archive-queued-next-message"), - threadId, - messageId: MessageId.make("runtime-layer-archive-queued-next-message"), - text: "Do not run this after archive.", - attachments: [], - modelSelection, - dispatchMode: { type: "queue_after_active" }, - }); - - const beforeArchive = yield* orchestrator.getThreadProjection(threadId); - const activeRun = beforeArchive.runs.find((run) => run.status === "starting"); - const queuedRun = beforeArchive.runs.find((run) => run.status === "queued"); - assert.isDefined(activeRun); - assert.isDefined(queuedRun); - - yield* orchestrator.dispatch({ - type: "thread.archive", - commandId: CommandId.make("runtime-layer-archive-queued-archive"), - threadId, - }); - - const archived = yield* orchestrator.getThreadProjection(threadId); - assert.isNotNull(archived.thread.archivedAt); - assert.equal(archived.runs.find((run) => run.id === queuedRun.id)?.status, "cancelled"); - assert.equal( - archived.attempts.find((attempt) => attempt.runId === queuedRun.id)?.status, - "cancelled", - ); - assert.equal(archived.nodes.find((node) => node.runId === queuedRun.id)?.status, "cancelled"); - assert.equal(yield* orchestrator.resumeQueuedRuns, 0); - - const promoteError = yield* orchestrator - .dispatch({ - type: "queued-message.promote-to-steer", - commandId: CommandId.make("runtime-layer-archive-queued-promote"), - threadId, - queuedRunId: queuedRun.id, - targetRunId: activeRun.id, - }) - .pipe(Effect.flip); - assert.equal(promoteError._tag, "OrchestratorDispatchError"); - - const afterPromotion = yield* orchestrator.getThreadProjection(threadId); - assert.equal(afterPromotion.runs.find((run) => run.id === queuedRun.id)?.status, "cancelled"); - }), - ); - it.effect.each([false, true])( "promotes only one queued run after each terminal run (notification: %s)", (automatic) => @@ -6500,20 +5633,13 @@ it.layer(layerTest)("usage-limit recovery", (it) => { it.effect.each([ "resume", "queued-resume", - "cancel", "rearm", "snooze-race", - "new-message", "archive", - "settle", "replacement", "manual-snooze", - "manual-snooze-after-recovery", "invalid-snooze", - "snooze-only", - "snooze-resume", "cancel-resume-keep-snooze", - "wake-preserve-resume", "independent-patches", "expired-snooze", "wake", @@ -6666,15 +5792,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.isNull(current.thread.snoozedUntil); return; } - const snooze = [ - "snooze-only", - "manual-snooze-after-recovery", - "snooze-resume", - "wake", - "cancel-resume-keep-snooze", - "wake-preserve-resume", - ].includes(scenario); - const autoResume = scenario !== "snooze-only" && scenario !== "wake"; + const snooze = scenario === "wake" || scenario === "cancel-resume-keep-snooze"; + const autoResume = scenario !== "wake"; const arm = limitRecoveryCommand(shell, autoResume, DateTime.toEpochMillis(now), snooze); assert(arm?.type === "thread.metadata.update" && arm.limitRecovery != null); const automaticArm = { @@ -6750,7 +5869,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { }); if (snooze) assert.equal(DateTime.toEpochMillis(armedShell.snoozedUntil!), Date.parse(resetAt)); - if (scenario === "cancel-resume-keep-snooze" || scenario === "wake-preserve-resume") { + if (scenario === "cancel-resume-keep-snooze") { yield* TestClock.adjust("10 seconds"); yield* orchestrator.dispatch({ type: "thread.metadata.update", @@ -6759,25 +5878,19 @@ it.layer(layerTest)("usage-limit recovery", (it) => { limitRecovery: { runId: run.id, resetAt, - autoResume: scenario === "wake-preserve-resume", - snooze: scenario === "cancel-resume-keep-snooze", + autoResume: false, + snooze: true, }, }); armedShell = (yield* orchestrator.getShellSnapshot()).threads.find( (thread) => thread.id === threadId, )!; - if (scenario === "cancel-resume-keep-snooze") { - assert.equal(DateTime.toEpochMillis(armedShell.snoozedUntil!), Date.parse(resetAt)); - assert.equal( - DateTime.toEpochMillis(armedShell.snoozedAt!), - DateTime.toEpochMillis(armedShell.updatedAt), - ); - assert.isFalse(armedShell.limitRecovery!.autoResume); - } else { - assert.isNull(armedShell.snoozedUntil); - assert.isNull(armedShell.snoozedAt); - assert.isTrue(armedShell.limitRecovery!.autoResume); - } + assert.equal(DateTime.toEpochMillis(armedShell.snoozedUntil!), Date.parse(resetAt)); + assert.equal( + DateTime.toEpochMillis(armedShell.snoozedAt!), + DateTime.toEpochMillis(armedShell.updatedAt), + ); + assert.isFalse(armedShell.limitRecovery!.autoResume); } if (scenario === "independent-patches") { yield* TestClock.adjust("10 seconds"); @@ -6817,7 +5930,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.isTrue(armedShell.limitRecovery!.autoResume); assert.isTrue(armedShell.limitRecovery!.snooze); } - if (scenario === "manual-snooze" || scenario === "manual-snooze-after-recovery") { + if (scenario === "manual-snooze") { yield* orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make(`recovery:manual-snooze:${scenario}`), @@ -6919,7 +6032,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.isTrue(current.thread.limitRecovery!.autoResume); } - if (scenario === "cancel" || scenario === "rearm") + if (scenario === "rearm") yield* orchestrator.dispatch({ type: "thread.metadata.update", commandId: CommandId.make(`recovery:cancel:${scenario}`), @@ -6932,24 +6045,6 @@ it.layer(layerTest)("usage-limit recovery", (it) => { commandId: CommandId.make(`recovery:archive:${scenario}`), threadId, }); - if (scenario === "new-message") - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`recovery:new-message:${scenario}`), - threadId, - messageId: MessageId.make(`recovery:new-message:${scenario}`), - text: "I will continue manually.", - attachments: [], - dispatchMode: { type: "defer_start" }, - createdBy: "user", - creationSource: "web", - }); - if (scenario === "settle") - yield* orchestrator.dispatch({ - type: "thread.settle", - commandId: CommandId.make(`recovery:settle:${scenario}`), - threadId, - }); if (scenario === "stop") { yield* orchestrator.dispatch({ type: "thread.stop", @@ -7021,8 +6116,6 @@ it.layer(layerTest)("usage-limit recovery", (it) => { (scenario === "resume" || stopBeforeArm || scenario === "queued-resume" || - scenario === "snooze-resume" || - scenario === "wake-preserve-resume" || scenario === "independent-patches" || scenario === "expired-snooze" ? 1 @@ -7034,8 +6127,6 @@ it.layer(layerTest)("usage-limit recovery", (it) => { (scenario === "resume" || stopBeforeArm || scenario === "queued-resume" || - scenario === "snooze-resume" || - scenario === "wake-preserve-resume" || scenario === "independent-patches" || scenario === "expired-snooze" ? 1 diff --git a/apps/server/src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..e0a9a233bfd9 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts @@ -0,0 +1,213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderReplayTranscript, +} from "@t3tools/contracts"; +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 * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts"; +import * as EffectWorker from "../EffectWorker.ts"; +import * as Orchestrator from "../Orchestrator.ts"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { materializeFixtureInput, SIMPLE_PROMPT } from "./fixtures/shared.ts"; +import { + itRunsReplayFixtures, + readReplayFixtureTranscript, + runReplayFixture, +} from "./ProviderReplayFixtures.ts"; +import { layerProviderReplay } from "./ProviderReplayHarness.ts"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; + +describe("ACP registry replay fixtures", () => { + it.effect.each([ + { name: "open", firstTurn: false, code: -32602 }, + { name: "switch", firstTurn: true, code: -32602 }, + { name: "authentication", firstTurn: false, code: -32000 }, + ])("preserves the ACP model-write $name failure through orchestration", (testCase) => + Effect.gen(function* () { + const name = `acp_model_write_${testCase.name}`; + const sessionId = "grok-replay-session-1"; + const rejectedModel = "replay-model-b"; + const modelSelection = { + instanceId: ProviderInstanceId.make("acpRegistry"), + model: rejectedModel, + }; + const recorded = yield* readReplayFixtureTranscript( + new URL("./fixtures/simple/registry_transcript.ndjson", import.meta.url), + ); + const sessionIndex = recorded.entries.findIndex( + (entry) => "label" in entry && entry.label === "session.created", + ); + assert.isAtLeast(sessionIndex, 0); + const transcript = { + ...recorded, + scenario: name, + entries: [ + ...recorded.entries.slice(0, sessionIndex), + { + type: "emit_inbound", + label: "session.created", + frame: { + kind: "response", + method: "session/new", + result: { + sessionId, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "replay-model-a", + options: [ + { value: "replay-model-a", name: "Model A" }, + { value: rejectedModel, name: "Model B" }, + ], + }, + ], + }, + }, + }, + ...(testCase.firstTurn + ? recorded.entries + .slice(sessionIndex + 1) + .filter((entry) => entry.type !== "runtime_exit") + : []), + { + type: "expect_outbound", + label: "session.set_model", + frame: { + kind: "request", + method: "session/set_config_option", + params: { sessionId, configId: "model", value: rejectedModel }, + }, + }, + { + type: "emit_inbound", + label: "session.set_model.rejected", + frame: { + kind: "response", + method: "session/set_config_option", + error: { + code: testCase.code, + message: testCase.code === -32602 ? "Invalid provider" : "Authentication required", + }, + }, + }, + ], + } satisfies ProviderReplayTranscript; + const materialized = yield* materializeFixtureInput({ + scenario: name, + fixtureInput: { + steps: Array.from({ length: testCase.firstTurn ? 2 : 1 }, () => ({ + type: "message" as const, + text: SIMPLE_PROMPT, + })), + }, + driver: ProviderDriverKind.make("acpRegistry"), + modelSelection: { + ...modelSelection, + model: testCase.firstTurn ? "replay-model-a" : rejectedModel, + }, + }); + const scenario = { + ...materialized, + name, + transcript: yield* AcpRegistryOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptRuntimeInstructions(transcript, { + driver: ProviderDriverKind.make("acpRegistry"), + model: "replay-model-a", + }), + ), + runtimePolicyOverride: { cwd: yield* checkpointWorkspace(name) }, + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + if (testCase.firstTurn) yield* EffectWorker.runDaemon.pipe(Effect.forkScoped); + for (const [index, command] of materialized.commands.entries()) { + const isFirstTurn = + testCase.firstTurn && command.type === "message.dispatch" && index === 1; + yield* orchestrator.dispatch( + command.type === "message.dispatch" && !isFirstTurn + ? { ...command, modelSelection } + : command, + ); + if (isFirstTurn) { + // Wait for the committed terminal event before switching models. + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => event.type === "run.updated" && event.payload.status === "completed", + ), + Stream.runHead, + ); + } + } + if (testCase.firstTurn) { + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => event.type === "run.updated" && event.payload.status === "failed", + ), + Stream.runHead, + ); + } else { + // Failed opens retry on the outbox schedule. Advance to each recorded + // deadline, draining effects instead of polling the projection. + for (let attempt = 0; attempt < 10; attempt++) { + yield* worker.drain(); + const next = yield* worker.nextClaimableAt; + if (Option.isNone(next)) break; + const now = yield* DateTime.now; + yield* TestClock.adjust( + Math.max(0, DateTime.toEpochMillis(next.value) - DateTime.toEpochMillis(now)), + ); + } + } + const projection = yield* orchestrator.getThreadProjection( + materialized.projectionThreadIds[0]!, + ); + assert.deepEqual( + projection.runs.map((run) => run.status), + testCase.firstTurn ? ["completed", "failed"] : ["failed"], + ); + const errors = projection.turnItems.filter((item) => item.type === "error"); + assert.lengthOf(errors, 1); + assert.equal(errors[0]?.failure.class, "provider_error"); + if (testCase.code === -32602) { + assert.equal( + errors[0]?.failure.message, + 'The agent did not accept the model "replay-model-b". Choose another model for this provider, then retry.', + ); + } else { + assert.match( + errors[0]?.failure.message ?? "", + /^Failed to open acpRegistry provider session \S+\.$/u, + ); + } + }).pipe( + Effect.provide( + layerProviderReplay(scenario, AcpRegistryOrchestratorReplayHarness, { + runEffectWorker: false, + }), + ), + ); + }).pipe( + Effect.scoped, + provideDeterministicTestRuntime, + Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + ), + ); + + itRunsReplayFixtures("acpRegistry", (input) => + runReplayFixture({ ...input, harness: AcpRegistryOrchestratorReplayHarness }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts index 803ac7cd86b5..8886daad3e01 100644 --- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts @@ -5,34 +5,30 @@ import { isProviderNativeSubagentThread, MessageId, ProviderDriverKind, - type ProviderReplayTranscript, + type ProviderReplayEntry, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { classifyClaudeNativeTool } from "../Adapters/ClaudeAdapterV2.ts"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../Orchestrator.ts"; import { userFacingDispatchErrorMessage } from "../UserFacingErrors.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; -import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; import { subagentInput } from "./fixtures/subagent/input.ts"; import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; import * as ProviderReplayHarness from "./ProviderReplayHarness.ts"; import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; -import { CLAUDE_MODEL_SELECTION, materializeFixtureInput } from "./fixtures/shared.ts"; import { - THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER, - THREAD_FORK_NATIVE_CONTINUE_RECALL, - THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER, - THREAD_MERGE_BACK_FORK_MARKER, - THREAD_MERGE_BACK_RECALL, - THREAD_MERGE_BACK_SIBLINGS_FIRST_MARKER, - THREAD_MERGE_BACK_SIBLINGS_RECALL, - THREAD_MERGE_BACK_SIBLINGS_SECOND_MARKER, - THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER, - THREAD_MERGE_BACK_SOURCE_MARKER, + CLAUDE_MODEL_SELECTION, + materializeFixtureInput, + projectionFor, } from "./fixtures/shared.ts"; +import { + findReplayFixture, + itRunsReplayFixtures, + runReplayFixture, + type ReplayFixtureCase, +} from "./ProviderReplayFixtures.ts"; import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; const readTranscript = Effect.fn("readClaudeReplayFixture")(function* (file: URL) { @@ -43,95 +39,13 @@ function readClaudeTranscriptFixture(path: string) { return readTranscript(new URL(`./fixtures/${path}/claude_transcript.ndjson`, import.meta.url)); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function metadataString(transcript: ProviderReplayTranscript, key: string): string { - const value = transcript.metadata?.[key]; - if (typeof value !== "string") { - throw new Error(`${transcript.scenario} metadata.${key} must be a string.`); - } - return value; -} - -function metadataStringArray( - transcript: ProviderReplayTranscript, - key: string, -): ReadonlyArray { - const value = transcript.metadata?.[key]; - if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { - throw new Error(`${transcript.scenario} metadata.${key} must be a string array.`); - } - return value; -} - -type FramedReplayEntry = Extract< - ProviderReplayTranscript["entries"][number], - { readonly frame: unknown } ->; - -function frameRecord(entry: FramedReplayEntry): Record { - if (!isRecord(entry.frame)) { - throw new Error("Replay entry frame must be an object."); - } - return entry.frame; -} - -function findEntryFrame( - transcript: ProviderReplayTranscript, - label: string, -): Record { - const entry = transcript.entries.find( - (candidate): candidate is FramedReplayEntry => - "label" in candidate && candidate.label === label && "frame" in candidate, - ); - assert.isDefined(entry, `${transcript.scenario} must include replay entry ${label}`); - return frameRecord(entry); -} - -function successResultTexts(transcript: ProviderReplayTranscript): ReadonlyArray { - return transcript.entries.flatMap((entry) => { - if (entry.type !== "emit_inbound" || !isRecord(entry.frame)) { - return []; - } - if (entry.frame.type !== "result" || entry.frame.subtype !== "success") { - return []; - } - return typeof entry.frame.result === "string" ? [entry.frame.result] : []; - }); -} - -function claudeToolUseNamesFromTranscript( - transcript: ProviderReplayTranscript, -): ReadonlyArray { - return transcript.entries.flatMap((entry) => { - if ( - entry.type !== "emit_inbound" || - !isRecord(entry.frame) || - entry.frame.type !== "assistant" - ) { - return []; - } - - const message = entry.frame.message; - const content = isRecord(message) ? message.content : undefined; - if (!Array.isArray(content)) { - return []; - } - - return content.flatMap((part) => - isRecord(part) && - typeof part.id === "string" && - typeof part.name === "string" && - "input" in part - ? [part.name] - : [], - ); - }); +function runClaudeReplayFixture(input: ReplayFixtureCase) { + return runReplayFixture({ ...input, harness: ClaudeOrchestratorReplayHarness }); } describe("Claude Agent SDK replay fixtures", () => { + itRunsReplayFixtures("claudeAgent", runClaudeReplayFixture); + it.effect("refuses messages to a native subagent thread without touching it", () => Effect.gen(function* () { const raw = yield* readClaudeTranscriptFixture("subagent"); @@ -195,186 +109,211 @@ describe("Claude Agent SDK replay fixtures", () => { }), ); - it.effect("classifies every Claude fixture tool use through the native tool table", () => - Effect.gen(function* () { - const unknownToolNames = new Set(); - const seenToolNames = new Set(); - - for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { - for (const provider of fixture.providers) { - if (provider.driver !== "claudeAgent") { - continue; - } + const afterRootFixture = findReplayFixture("claude_background_subagent_after_root"); + const afterRootProvider = afterRootFixture?.providers[0]; + if (afterRootFixture !== undefined && afterRootProvider !== undefined) { + const OPEN_TOOL_ID = "toolu_01QDa5jV5g1H9h6QyDfeJohD"; + const SUBAGENT_TASK_ID = "a2995bfced8019363"; - const transcript = yield* readTranscript(provider.transcriptFile); - for (const toolName of claudeToolUseNamesFromTranscript(transcript)) { - seenToolNames.add(toolName); - const classification = classifyClaudeNativeTool(toolName); - if (!classification.known && !toolName.startsWith("mcp__")) { - unknownToolNames.add(`${fixture.name}:${toolName}`); - } - } - } - } - - assert.isAtLeast(seenToolNames.size, 1, "expected Claude fixtures to contain tool uses"); - assert.deepEqual([...unknownToolNames], []); - }), - ); - - it.effect("keeps unregistered native conversation-state transcripts reviewable", () => - Effect.gen(function* () { - const rollback = yield* readClaudeTranscriptFixture("thread_rollback"); - assert.equal(rollback.metadata?.queryMode, "resume_at_cursor"); - const rollbackCursor = metadataString(rollback, "resumeSessionAt"); - const rollbackResumeFrame = findEntryFrame(rollback, "query.open:resume_at_cursor"); - const rollbackResumeOptions = rollbackResumeFrame.options; - if (!isRecord(rollbackResumeOptions)) { - throw new Error("Rollback resume query.open options must be an object."); - } - assert.equal(rollbackResumeOptions.resumeSessionAt, rollbackCursor); - const rollbackFinalText = successResultTexts(rollback).at(-1) ?? ""; - assert.include(rollbackFinalText, "rollback fixture first turn complete"); - assert.notInclude(rollbackFinalText, "rollback fixture second turn complete"); - - const latestFork = yield* readClaudeTranscriptFixture("thread_fork_native"); - assert.equal(latestFork.metadata?.queryMode, "fork_session"); - const latestForkedSessionId = metadataString(latestFork, "forkedNativeSessionId"); - const latestForkedFrame = findEntryFrame(latestFork, "session.forked"); - assert.equal(latestForkedFrame.sessionId, latestForkedSessionId); - - const priorFork = yield* readClaudeTranscriptFixture("thread_fork_native_prior_turn"); - assert.equal(priorFork.metadata?.queryMode, "fork_session_prior_turn"); - const priorForkCursor = metadataString(priorFork, "forkUpToMessageId"); - const priorForkFrame = findEntryFrame(priorFork, "session.fork"); - const priorForkOptions = priorForkFrame.options; - if (!isRecord(priorForkOptions)) { - throw new Error("Prior-turn fork options must be an object."); - } - assert.equal(priorForkOptions.upToMessageId, priorForkCursor); - const priorForkFinalText = successResultTexts(priorFork).at(-1) ?? ""; - assert.include(priorForkFinalText, "fork boundary alpha"); - assert.notInclude(priorForkFinalText, "fork boundary beta"); - - const continuedFork = yield* readClaudeTranscriptFixture("thread_fork_native_continue"); - assert.equal(continuedFork.metadata?.queryMode, "fork_session_continue"); - const continuedForkSessionId = metadataString(continuedFork, "forkedNativeSessionId"); - const continuedForkOpenFrame = findEntryFrame(continuedFork, "query.open:fork"); - const continuedForkOptions = continuedForkOpenFrame.options; - if (!isRecord(continuedForkOptions)) { - throw new Error("Continued fork query.open options must be an object."); - } - assert.equal(continuedForkOptions.resume, continuedForkSessionId); - const continuedForkResults = successResultTexts(continuedFork); - assert.deepEqual(continuedForkResults.slice(0, 2), [ - "source marker stored", - "fork marker stored", - ]); - assert.equal( - continuedForkResults.at(-1)?.replace(/\s*\|\s*/u, "|"), - THREAD_FORK_NATIVE_CONTINUE_RECALL, - ); - const recallPromptFrame = findEntryFrame(continuedFork, "prompt.offer:3"); - const recallMessage = recallPromptFrame.message; - const recallMessageBody = isRecord(recallMessage) ? recallMessage.message : undefined; - const recallPrompt = - isRecord(recallMessageBody) && typeof recallMessageBody.content === "string" - ? recallMessageBody.content - : ""; - assert.notInclude(recallPrompt, THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER); - assert.notInclude(recallPrompt, THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER); - - const siblingForks = yield* readClaudeTranscriptFixture("thread_fork_native_siblings"); - assert.equal(siblingForks.metadata?.queryMode, "fork_session_siblings"); - const siblingSessionIds = metadataStringArray(siblingForks, "forkedNativeSessionIds"); - assert.lengthOf(siblingSessionIds, 2); - assert.notEqual(siblingSessionIds[0], siblingSessionIds[1]); - const siblingResults = successResultTexts(siblingForks).map((text) => - text.replace(/\s*\|\s*/u, "|"), - ); - assert.deepEqual(siblingResults, [ - "sibling source stored", - "sibling-source-8R3D|sibling-first-5L2P", - "sibling-source-8R3D|sibling-second-9N6C", - ]); - assert.notInclude(siblingResults[1] ?? "", "sibling-second-9N6C"); - assert.notInclude(siblingResults[2] ?? "", "sibling-first-5L2P"); - - const mergeBack = yield* readClaudeTranscriptFixture("thread_merge_back_continue"); - assert.equal(mergeBack.metadata?.queryMode, "fork_session_merge_back"); - const mergeBackSourceSessionId = metadataString(mergeBack, "nativeSessionId"); - const mergeBackContinuationFrame = findEntryFrame( - mergeBack, - "query.open:source-continuation", + const moveOpenToolBeforeRootResult = ( + move: boolean, + entries: ReadonlyArray, + ): ReadonlyArray => { + if (!move) return entries; + const toolIndex = entries.findIndex( + (entry) => + entry.type === "emit_inbound" && + (entry.frame as Record).message?.content?.[0]?.id === OPEN_TOOL_ID, ); - const mergeBackContinuationOptions = mergeBackContinuationFrame.options; - if (!isRecord(mergeBackContinuationOptions)) { - throw new Error("Merge-back source continuation options must be an object."); - } - assert.equal(mergeBackContinuationOptions.resume, mergeBackSourceSessionId); - const mergeBackRecallFrame = findEntryFrame(mergeBack, "prompt.offer:4"); - const mergeBackRecallMessage = mergeBackRecallFrame.message; - const mergeBackRecallBody = isRecord(mergeBackRecallMessage) - ? mergeBackRecallMessage.message - : undefined; - const mergeBackRecallPrompt = - isRecord(mergeBackRecallBody) && typeof mergeBackRecallBody.content === "string" - ? mergeBackRecallBody.content - : ""; - assert.notInclude(mergeBackRecallPrompt, THREAD_MERGE_BACK_SOURCE_MARKER); - assert.notInclude(mergeBackRecallPrompt, THREAD_MERGE_BACK_FORK_MARKER); - assert.equal( - successResultTexts(mergeBack) - .at(-1) - ?.replace(/\s*\|\s*/gu, "|"), - THREAD_MERGE_BACK_RECALL, - ); - - const siblingMergeBack = yield* readClaudeTranscriptFixture("thread_merge_back_siblings"); - assert.equal(siblingMergeBack.metadata?.queryMode, "fork_session_merge_back_siblings"); - const siblingMergeSessionIds = metadataStringArray( - siblingMergeBack, - "forkedNativeSessionIds", - ); - assert.lengthOf(siblingMergeSessionIds, 2); - assert.notEqual(siblingMergeSessionIds[0], siblingMergeSessionIds[1]); - const siblingMergeRecallFrame = findEntryFrame(siblingMergeBack, "prompt.offer:6"); - const siblingMergeRecallMessage = siblingMergeRecallFrame.message; - const siblingMergeRecallBody = isRecord(siblingMergeRecallMessage) - ? siblingMergeRecallMessage.message - : undefined; - const siblingMergeRecallPrompt = - isRecord(siblingMergeRecallBody) && typeof siblingMergeRecallBody.content === "string" - ? siblingMergeRecallBody.content - : ""; - assert.notInclude(siblingMergeRecallPrompt, THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER); - assert.notInclude(siblingMergeRecallPrompt, THREAD_MERGE_BACK_SIBLINGS_FIRST_MARKER); - assert.notInclude(siblingMergeRecallPrompt, THREAD_MERGE_BACK_SIBLINGS_SECOND_MARKER); - assert.equal( - successResultTexts(siblingMergeBack) - .at(-1) - ?.replace(/\s*\|\s*/gu, "|"), - THREAD_MERGE_BACK_SIBLINGS_RECALL, + const resultIndex = entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "result", ); + if (toolIndex < 0 || resultIndex < 0) throw new Error("transcript shape changed"); + const toolEntry = entries[toolIndex]!; + const rest = entries.filter((_, index) => index !== toolIndex); + return [...rest.slice(0, resultIndex), toolEntry, ...rest.slice(resultIndex)]; + }; + // A subagent's foreground Bash run during the root's turn is tailed like a + // root call's, so the child's command row streams live. + const SUBAGENT_BASH_ID = "toolu_01JMN3SSQCHzUTNua9AWdkiH"; + it.effect("streams a subagent's foreground Bash output live during the root turn", () => + runClaudeReplayFixture({ + fixtureName: afterRootFixture.name, + buildInput: afterRootFixture.buildInput, + driver: { + ...afterRootProvider, + assertOutput: (result) => { + const childThreadId = projectionFor(result, afterRootFixture.name).subagents[0] + ?.childThreadId; + assert.exists(childThreadId); + const command = result.projections + .get(childThreadId) + ?.turnItems.find( + (item) => + item.type === "command_execution" && + JSON.stringify(item.input).includes("SUB_DONE_1"), + ); + assert.exists(command); + const streamed = result.commandOutput?.get(command.id); + assert.exists(streamed, "the subagent's command must stream its output"); + assert.include(streamed.output.text, "SUB_DONE_1"); + }, + }, + transformTranscript: (transcript) => { + const isBashFrame = (entry: ProviderReplayEntry) => { + if (entry.type !== "emit_inbound") return false; + const frame = entry.frame as Record; + return ( + frame.message?.content?.[0]?.id === SUBAGENT_BASH_ID || + frame.message?.content?.[0]?.tool_use_id === SUBAGENT_BASH_ID || + (frame.type === "system" && frame.tool_use_id === SUBAGENT_BASH_ID) + ); + }; + // Recorded without the structured result the replay writes output files from. + const bashFrames = transcript.entries.filter(isBashFrame).map((entry) => { + const frame = (entry as { readonly frame: Record }).frame; + return frame.type === "user" + ? { + ...entry, + frame: { ...frame, tool_use_result: { stdout: "SUB_DONE_1\n", stderr: "" } }, + } + : entry; + }); + const rest = transcript.entries.filter((entry) => !isBashFrame(entry)); + const resultIndex = rest.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "result", + ); + assert.lengthOf(bashFrames, 4, "transcript shape changed"); + assert.isAtLeast(resultIndex, 0, "transcript shape changed"); + return { + ...transcript, + entries: [...rest.slice(0, resultIndex), ...bashFrames, ...rest.slice(resultIndex)], + }; + }, + }), + ); - const forkLocalRollback = yield* readClaudeTranscriptFixture( - "thread_fork_native_fork_local_rollback", - ); - assert.equal(forkLocalRollback.metadata?.queryMode, "fork_session_resume_at_fork_cursor"); - const forkLocalRollbackCursor = metadataString(forkLocalRollback, "resumeSessionAt"); - const forkLocalRollbackFrame = findEntryFrame( - forkLocalRollback, - "query.open:fork-resume-at-cursor", - ); - const forkLocalRollbackOptions = forkLocalRollbackFrame.options; - if (!isRecord(forkLocalRollbackOptions)) { - throw new Error("Fork-local rollback resume query.open options must be an object."); - } - assert.equal(forkLocalRollbackOptions.resumeSessionAt, forkLocalRollbackCursor); - const forkLocalRollbackFinalText = successResultTexts(forkLocalRollback).at(-1) ?? ""; - assert.include(forkLocalRollbackFinalText, "fork local source alpha"); - assert.include(forkLocalRollbackFinalText, "fork local first"); - assert.notInclude(forkLocalRollbackFinalText, "fork local second"); - }), - ); + // regression: #16486 a background subagent's tool left open while its parent is idle never settled + it.effect.each([ + ["WebFetch", "while root is idle", "stopped"], + ["Write", "before root settles", "failed"], + ] as const)( + "ends a background subagent's open %s (opened %s) when its notification is %s", + ([tool, opens, notificationStatus]) => + runClaudeReplayFixture({ + fixtureName: afterRootFixture.name, + buildInput: afterRootFixture.buildInput, + driver: { + ...afterRootProvider, + assertOutput: (result) => { + const parent = projectionFor(result, afterRootFixture.name); + assert.deepEqual( + parent.runs.map((run) => run.status), + ["completed", "completed"], + ); + const subagent = parent.subagents[0]; + assert.equal( + subagent?.status, + notificationStatus === "stopped" ? "cancelled" : "failed", + ); + const childThreadId = subagent?.childThreadId; + assert.exists(childThreadId); + const child = result.projections.get(childThreadId); + assert.exists(child); + const fetches = child.turnItems.filter( + (item) => item.type === (tool === "WebFetch" ? "web_search" : "file_change"), + ); + assert.lengthOf(fetches, 1, `the ${tool} reached the child thread`); + assert.deepEqual( + { + openTool: fetches.map((item) => item.status), + openChildNodes: child.nodes + .filter( + (node) => + node.status === "running" || + node.status === "pending" || + node.status === "waiting", + ) + .map((node) => `${node.kind}:${node.status}`), + }, + { + openTool: [notificationStatus === "stopped" ? "interrupted" : "failed"], + openChildNodes: [], + }, + ); + }, + }, + transformTranscript: (transcript) => ({ + ...transcript, + entries: moveOpenToolBeforeRootResult( + opens === "before root settles", + transcript.entries.flatMap((entry): ReadonlyArray => { + if (entry.type !== "emit_inbound") return [entry]; + const frame = entry.frame as Record; + if ( + frame.type === "assistant" && + frame.message?.content?.[0]?.id === OPEN_TOOL_ID + ) { + return [ + { + ...entry, + frame: { + ...frame, + message: { + ...frame.message, + content: [ + { + ...frame.message.content[0], + name: tool, + input: + tool === "WebFetch" + ? { url: "https://example.com", prompt: "Summarize" } + : { file_path: "notes.txt", content: "SUB_DONE_2" }, + }, + ], + }, + }, + }, + ]; + } + if ( + (frame.type === "system" && + (frame.subtype === "task_started" || frame.subtype === "task_notification") && + frame.tool_use_id === OPEN_TOOL_ID) || + (frame.type === "user" && + frame.message?.content?.[0]?.tool_use_id === OPEN_TOOL_ID) || + (frame.type === "assistant" && + frame.parent_tool_use_id !== null && + frame.message?.content?.[0]?.text === "SUB_FINAL_REPORT") + ) { + return []; + } + if (frame.type === "system" && frame.subtype === "task_updated") { + return [ + { + ...entry, + frame: { + ...frame, + patch: { + ...frame.patch, + status: notificationStatus === "stopped" ? "killed" : "failed", + }, + }, + }, + ]; + } + if ( + frame.type === "system" && + frame.subtype === "task_notification" && + frame.task_id === SUBAGENT_TASK_ID + ) { + return [{ ...entry, frame: { ...frame, status: notificationStatus } }]; + } + return [entry]; + }), + ), + }), + }), + ); + } }); diff --git a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts index 80d417f2f628..c0593351f20d 100644 --- a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts @@ -1,780 +1,17 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { ProviderReplayTranscript } from "@t3tools/contracts"; -import * as CodexReplay from "effect-codex-app-server/replay"; +import { describe } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; -import { - THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER, - THREAD_FORK_NATIVE_CONTINUE_RECALL, - THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER, - THREAD_MERGE_BACK_FORK_MARKER, - THREAD_MERGE_BACK_RECALL, - THREAD_MERGE_BACK_SIBLINGS_FIRST_MARKER, - THREAD_MERGE_BACK_SIBLINGS_RECALL, - THREAD_MERGE_BACK_SIBLINGS_SECOND_MARKER, - THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER, - THREAD_MERGE_BACK_SOURCE_MARKER, -} from "./fixtures/shared.ts"; -import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; - -const PROVIDER_THREAD_RESUME_FIRST_FINAL = "provider thread resume fixture first turn complete"; -const PROVIDER_THREAD_RESUME_SECOND_FINAL = "provider thread resume fixture second turn complete"; - -type ProtocolReplayEntry = Extract< - ProviderReplayTranscript["entries"][number], - { readonly type: "expect_outbound" | "emit_inbound" } ->; - -interface CodexReplayFixtureRegistration { - readonly registrationScenario: string; - readonly recordedScenario: string; - readonly transcriptFile: URL; -} - -const CODEX_REPLAY_FIXTURE_REGISTRATIONS = ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => - fixture.providers - .filter((provider) => provider.driver === "codex") - .map((provider) => ({ - registrationScenario: fixture.name, - recordedScenario: provider.recordedScenario ?? fixture.name, - transcriptFile: provider.transcriptFile, - })), -).concat([ - { - registrationScenario: "provider_thread_resume", - recordedScenario: "provider_thread_resume", - transcriptFile: new URL( - "./fixtures/provider_thread_resume/codex_transcript.ndjson", - import.meta.url, - ), - }, - { - registrationScenario: "thread_fork_native_continue", - recordedScenario: "thread_fork_native_continue", - transcriptFile: new URL( - "./fixtures/thread_fork_native_continue/codex_transcript.ndjson", - import.meta.url, - ), - }, - { - registrationScenario: "thread_fork_native_siblings", - recordedScenario: "thread_fork_native_siblings", - transcriptFile: new URL( - "./fixtures/thread_fork_native_siblings/codex_transcript.ndjson", - import.meta.url, - ), - }, - { - registrationScenario: "thread_merge_back_continue", - recordedScenario: "thread_merge_back_continue", - transcriptFile: new URL( - "./fixtures/thread_merge_back_continue/codex_transcript.ndjson", - import.meta.url, - ), - }, - { - registrationScenario: "thread_merge_back_siblings", - recordedScenario: "thread_merge_back_siblings", - transcriptFile: new URL( - "./fixtures/thread_merge_back_siblings/codex_transcript.ndjson", - import.meta.url, - ), - }, -]); - -function uniqueCanonicalTranscripts( - registrations: ReadonlyArray, -): ReadonlyArray { - const canonicalTranscripts = new Map(); - for (const registration of registrations) { - const canonicalUrl = registration.transcriptFile.href; - const existing = canonicalTranscripts.get(canonicalUrl); - if (existing !== undefined && existing.recordedScenario !== registration.recordedScenario) { - throw new Error( - `Codex replay transcript ${canonicalUrl} has conflicting recorded scenarios ${existing.recordedScenario} (${existing.registrationScenario}) and ${registration.recordedScenario} (${registration.registrationScenario}).`, - ); - } - canonicalTranscripts.set(canonicalUrl, existing ?? registration); - } - return Array.from(canonicalTranscripts.values()); -} - -const CODEX_REPLAY_TRANSCRIPTS = uniqueCanonicalTranscripts(CODEX_REPLAY_FIXTURE_REGISTRATIONS); - -const scenarioExpectations = { - simple: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["thread/started", "turn/started", "turn/completed"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - tool_call_read_only_on_request: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["item/commandExecution/requestApproval", "serverRequest/resolved", "turn/completed"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 1, - }, - tool_call_workspace_never: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/completed"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - tool_call_restricted_granular: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["item/fileChange/requestApproval", "serverRequest/resolved", "turn/completed"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 1, - }, - subagent: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 1, - turnCompletedCount: 3, - approvalRequestCount: 0, - }, - subagent_continue: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "item/completed", "turn/completed"], - turnStartCount: 2, - turnCompletedCount: 4, - approvalRequestCount: 0, - }, - subagent_v2: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "item/completed", "item/agentMessage/delta", "turn/completed"], - turnStartCount: 1, - turnCompletedCount: 2, - approvalRequestCount: 0, - }, - subagent_v2_approval: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: [ - "item/commandExecution/requestApproval", - "serverRequest/resolved", - "item/completed", - "turn/completed", - ], - turnStartCount: 1, - turnCompletedCount: 2, - approvalRequestCount: 1, - }, - subagent_v2_nested: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "item/completed", "item/agentMessage/delta", "turn/completed"], - turnStartCount: 1, - turnCompletedCount: 4, - approvalRequestCount: 0, - }, - subagent_v2_nested_approval: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: [ - "item/commandExecution/requestApproval", - "serverRequest/resolved", - "item/completed", - "turn/completed", - ], - turnStartCount: 1, - turnCompletedCount: 3, - approvalRequestCount: 1, - }, - multi_turn: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 2, - turnCompletedCount: 2, - approvalRequestCount: 0, - }, - plan_questions: { - outgoing: [ - "initialize", - "initialized", - "thread/start", - "turn/start", - "item/tool/requestUserInput", - ], - incoming: [ - "turn/started", - "turn/completed", - "item/tool/requestUserInput", - "serverRequest/resolved", - "item/agentMessage/delta", - ], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - proposed_plan: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "turn/completed", "item/plan/delta"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - provider_thread_resume: { - outgoing: ["initialize", "initialized", "thread/start", "thread/resume", "turn/start"], - incoming: ["thread/started", "turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 2, - turnCompletedCount: 2, - approvalRequestCount: 0, - }, - queued_turn: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 2, - turnCompletedCount: 2, - approvalRequestCount: 0, - }, - message_steering: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start", "turn/steer"], - incoming: ["turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - turn_interrupt: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start", "turn/interrupt"], - incoming: ["turn/started", "turn/completed"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - command_output_streaming: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: [ - "turn/started", - "item/started", - "item/commandExecution/outputDelta", - "item/completed", - "turn/completed", - ], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - turn_interrupt_mid_tool: { - outgoing: [ - "initialize", - "initialized", - "thread/start", - "turn/start", - "turn/interrupt", - "thread/backgroundTerminals/terminate", - ], - incoming: ["turn/started", "item/started", "turn/completed"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - thread_rollback: { - outgoing: [ - "initialize", - "initialized", - "thread/start", - "turn/start", - "thread/read", - "thread/turns/list", - "thread/revert", - ], - incoming: ["turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 3, - turnCompletedCount: 3, - approvalRequestCount: 0, - }, - thread_rollback_after_restart: { - outgoing: [ - "initialize", - "initialized", - "thread/start", - "turn/start", - "thread/read", - "thread/resume", - "thread/turns/list", - "thread/revert", - ], - incoming: ["turn/started", "turn/completed", "item/agentMessage/delta", "thread/reverted"], - turnStartCount: 3, - turnCompletedCount: 3, - approvalRequestCount: 0, - }, - thread_rollback_to_stopped_turn: { - outgoing: [ - "initialize", - "initialized", - "thread/start", - "turn/start", - "turn/interrupt", - "thread/backgroundTerminals/terminate", - "thread/read", - "thread/turns/list", - "thread/revert", - ], - incoming: ["turn/started", "item/started", "turn/completed", "thread/reverted"], - turnStartCount: 4, - turnCompletedCount: 4, - approvalRequestCount: 0, - }, - thread_fork_native_continue: { - outgoing: ["initialize", "initialized", "thread/start", "thread/fork", "turn/start"], - incoming: ["thread/started", "turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 3, - turnCompletedCount: 3, - approvalRequestCount: 0, - }, - thread_fork_native_siblings: { - outgoing: ["initialize", "initialized", "thread/start", "thread/fork", "turn/start"], - incoming: ["thread/started", "turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 3, - turnCompletedCount: 3, - approvalRequestCount: 0, - }, - thread_merge_back_continue: { - outgoing: ["initialize", "initialized", "thread/start", "thread/fork", "turn/start"], - incoming: ["thread/started", "turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 4, - turnCompletedCount: 4, - approvalRequestCount: 0, - }, - thread_merge_back_siblings: { - outgoing: ["initialize", "initialized", "thread/start", "thread/fork", "turn/start"], - incoming: ["thread/started", "turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 6, - turnCompletedCount: 6, - approvalRequestCount: 0, - }, - todo_list: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "turn/completed", "turn/plan/updated", "item/agentMessage/delta"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, - web_search: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start"], - incoming: ["turn/started", "turn/completed", "item/agentMessage/delta"], - turnStartCount: 1, - turnCompletedCount: 1, - approvalRequestCount: 0, - }, -} as const; - -const decodeCodexTranscript = Schema.decodeUnknownEffect( - CodexReplay.CodexAppServerReplayTranscript, -); -const readTranscript = Effect.fn("readCodexReplayFixture")(function* (file: URL) { - return yield* readProviderReplayTranscript(file); -}, Effect.provide(NodeServices.layer)); - -function labels( - transcript: ProviderReplayTranscript, - type: "expect_outbound" | "emit_inbound", -): ReadonlyArray { - return transcript.entries.flatMap((entry) => { - if (entry.type !== type || entry.label === undefined) { - return []; - } - return [entry.label]; - }); -} - -function countLabel( - transcript: ProviderReplayTranscript, - type: "expect_outbound" | "emit_inbound", - label: string, -) { - return labels(transcript, type).filter((entryLabel) => entryLabel === label).length; -} - -function countApprovalRequests(transcript: ProviderReplayTranscript) { - return labels(transcript, "emit_inbound").filter((label) => label.endsWith("/requestApproval")) - .length; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readPath(value: unknown, path: ReadonlyArray): unknown { - let current = value; - for (const segment of path) { - if (typeof segment === "number") { - if (!Array.isArray(current)) { - throw new Error(`Expected array while reading ${path.join(".")}.`); - } - current = current[segment]; - continue; - } - - if (!isRecord(current)) { - throw new Error(`Expected object while reading ${path.join(".")}.`); - } - current = current[segment]; - } - return current; -} - -function readString(value: unknown, path: ReadonlyArray): string { - const current = readPath(value, path); - if (typeof current !== "string") { - throw new Error(`Expected string at ${path.join(".")}.`); - } - return current; -} - -function readArray(value: unknown, path: ReadonlyArray): ReadonlyArray { - const current = readPath(value, path); - if (!Array.isArray(current)) { - throw new Error(`Expected array at ${path.join(".")}.`); - } - return current; -} - -function findProtocolEntry( - transcript: ProviderReplayTranscript, - type: "expect_outbound" | "emit_inbound", - label: string, - occurrence = 0, -): ProtocolReplayEntry { - const matches = transcript.entries.filter( - (entry): entry is ProtocolReplayEntry => entry.type === type && entry.label === label, - ); - const entry = matches[occurrence]; - if (!entry) { - throw new Error(`Missing ${type} ${label} occurrence ${occurrence}.`); - } - return entry; -} - -function agentMessageTexts(transcript: ProviderReplayTranscript): ReadonlyArray { - return transcript.entries.flatMap((entry) => { - if (entry.type !== "emit_inbound" || entry.label !== "item/completed") { - return []; - } - - const item = readPath(entry.frame, ["params", "item"]); - if (!isRecord(item) || item.type !== "agentMessage" || typeof item.text !== "string") { - return []; - } - return [item.text]; - }); -} - -function assertScenarioExpectations(transcript: ProviderReplayTranscript) { - const expectation = - scenarioExpectations[transcript.scenario as keyof typeof scenarioExpectations]; - const outgoingLabels = labels(transcript, "expect_outbound"); - const incomingLabels = labels(transcript, "emit_inbound"); - - assert.isDefined(expectation, `missing scenario expectation for ${transcript.scenario}`); - for (const label of expectation.outgoing) { - assert.include(outgoingLabels, label, `${transcript.scenario} missing outgoing ${label}`); - } - for (const label of expectation.incoming) { - assert.include(incomingLabels, label, `${transcript.scenario} missing incoming ${label}`); - } - - assert.equal(countLabel(transcript, "expect_outbound", "turn/start"), expectation.turnStartCount); - assert.equal( - countLabel(transcript, "emit_inbound", "turn/completed"), - expectation.turnCompletedCount, - ); - assert.equal(countApprovalRequests(transcript), expectation.approvalRequestCount); -} - -function assertProviderThreadResumeSemantics(transcript: ProviderReplayTranscript) { - if (transcript.scenario !== "provider_thread_resume") { - return; - } - - const startThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/start").frame, - ["result", "thread", "id"], - ); - const resumeRequestedThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "thread/resume").frame, - ["params", "threadId"], - ); - const resumeRequest = findProtocolEntry(transcript, "expect_outbound", "thread/resume").frame; - const resumedThreadFrame = findProtocolEntry(transcript, "emit_inbound", "thread/resume").frame; - const resumedThreadId = readString(resumedThreadFrame, ["result", "thread", "id"]); - const secondTurnThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 1).frame, - ["params", "threadId"], - ); - const texts = agentMessageTexts(transcript); - const secondFinalText = texts[1] ?? ""; - - assert.equal( - resumeRequestedThreadId, - startThreadId, - "thread/resume must request the provider thread created by thread/start", - ); - assert.equal( - resumedThreadId, - startThreadId, - "thread/resume must return the same provider thread id", - ); - assert.equal( - secondTurnThreadId, - startThreadId, - "turn after resume must run on the resumed provider thread", - ); - assert.equal(readPath(resumeRequest, ["params", "excludeTurns"]), true); - assert.lengthOf(readArray(resumedThreadFrame, ["result", "thread", "turns"]), 0); - assert.equal(texts[0], PROVIDER_THREAD_RESUME_FIRST_FINAL); - assert.include( - secondFinalText, - PROVIDER_THREAD_RESUME_FIRST_FINAL, - "second turn must demonstrate access to resumed conversation history", - ); - assert.include( - secondFinalText, - PROVIDER_THREAD_RESUME_SECOND_FINAL, - "second turn must include its own completion marker", - ); -} - -function assertContinuedForkSemantics(transcript: ProviderReplayTranscript) { - if (transcript.scenario !== "thread_fork_native_continue") { - return; - } - - const sourceThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/start").frame, - ["result", "thread", "id"], - ); - const forkRequestThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "thread/fork").frame, - ["params", "threadId"], - ); - const forkThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/fork").frame, - ["result", "thread", "id"], - ); - const firstForkTurnThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 1).frame, - ["params", "threadId"], - ); - const secondForkTurnThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 2).frame, - ["params", "threadId"], - ); - const recallPrompt = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 2).frame, - ["params", "input", 0, "text"], - ); - - assert.equal(forkRequestThreadId, sourceThreadId); - assert.notEqual(forkThreadId, sourceThreadId); - assert.equal(firstForkTurnThreadId, forkThreadId); - assert.equal(secondForkTurnThreadId, forkThreadId); - assert.notInclude(recallPrompt, THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER); - assert.notInclude(recallPrompt, THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER); - const texts = agentMessageTexts(transcript); - for (const expected of [ - "source marker stored", - "fork marker stored", - THREAD_FORK_NATIVE_CONTINUE_RECALL, - ]) { - assert.include(texts, expected); - } - assert.equal( - texts.at(-1), - THREAD_FORK_NATIVE_CONTINUE_RECALL, - "the second fork-local turn must recall both the inherited source marker and fork-local marker", - ); -} - -function assertSiblingForkSemantics(transcript: ProviderReplayTranscript) { - if (transcript.scenario !== "thread_fork_native_siblings") { - return; - } - - const sourceThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/start").frame, - ["result", "thread", "id"], - ); - const firstForkFrame = findProtocolEntry(transcript, "emit_inbound", "thread/fork", 0).frame; - const secondForkFrame = findProtocolEntry(transcript, "emit_inbound", "thread/fork", 1).frame; - const firstForkId = readString(firstForkFrame, ["result", "thread", "id"]); - const secondForkId = readString(secondForkFrame, ["result", "thread", "id"]); - const firstForkParentId = readString(firstForkFrame, ["result", "thread", "forkedFromId"]); - const secondForkParentId = readString(secondForkFrame, ["result", "thread", "forkedFromId"]); - const firstForkTurnThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 1).frame, - ["params", "threadId"], - ); - const secondForkTurnThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 2).frame, - ["params", "threadId"], - ); - const texts = agentMessageTexts(transcript); - const firstRecall = texts[1]?.replace(/\s*\|\s*/u, "|") ?? ""; - const secondRecall = texts[2]?.replace(/\s*\|\s*/u, "|") ?? ""; - - assert.equal(firstForkParentId, sourceThreadId); - assert.equal(secondForkParentId, sourceThreadId); - assert.notEqual(firstForkId, secondForkId); - assert.equal(firstForkTurnThreadId, firstForkId); - assert.equal(secondForkTurnThreadId, secondForkId); - assert.equal(firstRecall, "sibling-source-8R3D|sibling-first-5L2P"); - assert.notInclude(firstRecall, "sibling-second-9N6C"); - assert.equal(secondRecall, "sibling-source-8R3D|sibling-second-9N6C"); - assert.notInclude(secondRecall, "sibling-first-5L2P"); -} - -function assertMergeBackSemantics(transcript: ProviderReplayTranscript) { - if (transcript.scenario !== "thread_merge_back_continue") { - return; - } - - const sourceThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/start").frame, - ["result", "thread", "id"], - ); - const forkThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/fork").frame, - ["result", "thread", "id"], - ); - const mergeThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 2).frame, - ["params", "threadId"], - ); - const recallFrame = findProtocolEntry(transcript, "expect_outbound", "turn/start", 3).frame; - const recallThreadId = readString(recallFrame, ["params", "threadId"]); - const recallPrompt = readString(recallFrame, ["params", "input", 0, "text"]); - const texts = agentMessageTexts(transcript); - - assert.notEqual(forkThreadId, sourceThreadId); - assert.equal(mergeThreadId, sourceThreadId); - assert.equal(recallThreadId, sourceThreadId); - assert.notInclude(recallPrompt, THREAD_MERGE_BACK_SOURCE_MARKER); - assert.notInclude(recallPrompt, THREAD_MERGE_BACK_FORK_MARKER); - assert.equal(texts.at(-1)?.replace(/\s*\|\s*/u, "|"), THREAD_MERGE_BACK_RECALL); -} - -function assertSiblingMergeBackSemantics(transcript: ProviderReplayTranscript) { - if (transcript.scenario !== "thread_merge_back_siblings") { - return; - } - - const sourceThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/start").frame, - ["result", "thread", "id"], - ); - const firstForkId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/fork", 0).frame, - ["result", "thread", "id"], - ); - const secondForkId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/fork", 1).frame, - ["result", "thread", "id"], - ); - const firstMergeThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 3).frame, - ["params", "threadId"], - ); - const secondMergeThreadId = readString( - findProtocolEntry(transcript, "expect_outbound", "turn/start", 4).frame, - ["params", "threadId"], - ); - const recallFrame = findProtocolEntry(transcript, "expect_outbound", "turn/start", 5).frame; - const recallPrompt = readString(recallFrame, ["params", "input", 0, "text"]); - const finalText = agentMessageTexts(transcript) - .at(-1) - ?.replace(/\s*\|\s*/gu, "|"); - - assert.notEqual(firstForkId, secondForkId); - assert.equal(firstMergeThreadId, sourceThreadId); - assert.equal(secondMergeThreadId, sourceThreadId); - assert.notInclude(recallPrompt, THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER); - assert.notInclude(recallPrompt, THREAD_MERGE_BACK_SIBLINGS_FIRST_MARKER); - assert.notInclude(recallPrompt, THREAD_MERGE_BACK_SIBLINGS_SECOND_MARKER); - assert.equal(finalText, THREAD_MERGE_BACK_SIBLINGS_RECALL); -} - -function assertSubagentActivityPrecedesChildTurns(transcript: ProviderReplayTranscript) { - if (transcript.scenario !== "subagent_v2" && transcript.scenario !== "subagent_v2_nested") { - return; - } - - const announcedChildren = new Set(); - const startedChildren = new Set(); - const rootThreadId = readString( - findProtocolEntry(transcript, "emit_inbound", "thread/start").frame, - ["result", "thread", "id"], - ); - for (const entry of transcript.entries) { - if (entry.type !== "emit_inbound") continue; - const method = readPath(entry.frame, ["method"]); - if (method === "item/started") { - const item = readPath(entry.frame, ["params", "item"]); - if (isRecord(item) && item.type === "subAgentActivity" && item.kind === "started") { - announcedChildren.add(readString(item, ["agentThreadId"])); - } - } - if (method === "turn/started") { - const threadId = readString(entry.frame, ["params", "threadId"]); - if (threadId === rootThreadId) continue; - assert.isTrue( - announcedChildren.has(threadId), - `${transcript.scenario}: child ${threadId} started a turn before its subAgentActivity`, - ); - startedChildren.add(threadId); - } - } - assert.equal( - startedChildren.size, - transcript.scenario === "subagent_v2" ? 1 : 3, - `${transcript.scenario}: unexpected number of child turns`, - ); -} +import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript"; +import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; +import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; describe("Codex replay fixtures", () => { - it.effect("loads each canonical Codex fixture as an app-server replay transcript", () => - Effect.gen(function* () { - for (const fixture of CODEX_REPLAY_TRANSCRIPTS) { - const transcript = yield* readTranscript(fixture.transcriptFile); - const codexTranscript = yield* decodeCodexTranscript(transcript); - const first = transcript.entries[0]; - - assert.equal(codexTranscript.provider, "codex"); - assert.equal(codexTranscript.protocol, "codex.app-server"); - assert.equal(codexTranscript.scenario, fixture.recordedScenario); - assert.deepEqual(codexTranscript.entries.at(-1), { - type: "runtime_exit", - status: "success", - }); - assert.equal(first?.type, "expect_outbound"); - if (first?.type !== "expect_outbound") { - throw new Error( - `Expected ${fixture.recordedScenario} to start with initialize outbound frame.`, - ); - } - assert.equal(first.label, "initialize"); - - assertScenarioExpectations(transcript); - assertProviderThreadResumeSemantics(transcript); - assertContinuedForkSemantics(transcript); - assertSiblingForkSemantics(transcript); - assertMergeBackSemantics(transcript); - assertSiblingMergeBackSemantics(transcript); - assertSubagentActivityPrecedesChildTurns(transcript); - } + itRunsReplayFixtures("codex", (input) => + runReplayFixture({ + ...input, + harness: CodexOrchestratorReplayHarness, + materializeWorkspace: (transcript, workspace) => + Effect.succeed(materializeReplayTranscriptWorkspace(transcript, workspace)), }), ); - - it("covers the expected replay suite exactly", () => { - assert.deepEqual( - CODEX_REPLAY_TRANSCRIPTS.map((fixture) => fixture.recordedScenario).toSorted(), - Object.keys(scenarioExpectations).toSorted(), - ); - }); }); diff --git a/apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..7557e8b3929f --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "@effect/vitest"; + +import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts"; +import { messageRestartInput } from "./fixtures/message_steering/input.ts"; +import { + findReplayFixture, + itRunsReplayFixtures, + runReplayFixture, +} from "./ProviderReplayFixtures.ts"; + +describe("Cursor replay fixtures", () => { + itRunsReplayFixtures("cursor", (input) => + runReplayFixture({ ...input, harness: CursorOrchestratorReplayHarness }), + ); + + const cursorSteeringProvider = findReplayFixture("message_steering")?.providers.find( + (provider) => provider.driver === "cursor", + ); + if (cursorSteeringProvider !== undefined) { + it.effect("executes explicit Cursor restart_active through the recorded SDK boundary", () => + runReplayFixture({ + fixtureName: "message_steering", + buildInput: messageRestartInput, + driver: cursorSteeringProvider, + harness: CursorOrchestratorReplayHarness, + }), + ); + } +}); diff --git a/apps/server/src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..606539ea7f46 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts @@ -0,0 +1,10 @@ +import { describe } from "@effect/vitest"; + +import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts"; +import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; + +describe("Grok replay fixtures", () => { + itRunsReplayFixtures("grok", (input) => + runReplayFixture({ ...input, harness: GrokOrchestratorReplayHarness }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..fee15966df4e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts @@ -0,0 +1,17 @@ +import { describe } from "@effect/vitest"; + +import { + materializeMuseReplayWorkspace, + MuseOrchestratorReplayHarness, +} from "../Adapters/MuseAdapterV2.testkit.ts"; +import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; + +describe("Muse replay fixtures", () => { + itRunsReplayFixtures("muse", (input) => + runReplayFixture({ + ...input, + harness: MuseOrchestratorReplayHarness, + materializeWorkspace: materializeMuseReplayWorkspace, + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..f9a594042b71 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from "@effect/vitest"; +import type { ProviderReplayEntry } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts"; +import { + OPENCODE2_HTTP_PROTOCOL, + OpenCode2OrchestratorReplayHarness, +} from "../Adapters/OpenCode2AdapterV2.testkit.ts"; +import { + findReplayFixture, + itRunsReplayFixtures, + readReplayFixtureTranscript, + runReplayFixture, + type ReplayFixtureCase, +} from "./ProviderReplayFixtures.ts"; + +// OpenCode fixtures are recorded against either the v1 or the v2 HTTP protocol. +function runOpenCodeReplayFixture(input: ReplayFixtureCase) { + return readReplayFixtureTranscript(input.driver.transcriptFile).pipe( + Effect.flatMap((transcript) => + transcript.protocol === OPENCODE2_HTTP_PROTOCOL + ? runReplayFixture({ ...input, harness: OpenCode2OrchestratorReplayHarness }) + : runReplayFixture({ ...input, harness: OpenCodeOrchestratorReplayHarness }), + ), + ); +} + +describe("OpenCode replay fixtures", () => { + itRunsReplayFixtures("opencode", runOpenCodeReplayFixture); + + it.effect.each( + ( + [ + ["opencode2_subagent", "session.execution.started.2"], + ["opencode2_background", "session.execution.started.3"], + ] as const + ).flatMap(([fixtureName, label]) => { + const fixture = findReplayFixture(fixtureName); + const provider = fixture?.providers[0]; + return fixture === undefined || provider === undefined + ? [] + : [[fixtureName, label, fixture, provider] as const]; + }), + )( + "runs %s when %s is an execution start this build cannot decode", + ([fixtureName, label, fixture, provider]) => + runOpenCodeReplayFixture({ + fixtureName, + buildInput: fixture.buildInput, + driver: provider, + transformTranscript: (transcript) => ({ + ...transcript, + entries: transcript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === label + ? undecodableEvent(entry) + : entry, + ), + }), + }), + ); +}); + +function undecodableEvent(entry: ProviderReplayEntry): ProviderReplayEntry { + if (entry.type !== "emit_inbound") return entry; + const frame = entry.frame as { readonly event: Record }; + return { ...entry, frame: { ...frame, event: { ...frame.event, durable: "not-an-envelope" } } }; +} diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts deleted file mode 100644 index 539443cf11b5..000000000000 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { OrchestrationV2Command, ProviderInstanceId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; -import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; -import { materializeFixtureInput } from "./fixtures/shared.ts"; -import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; - -const decodeCommand = Schema.decodeUnknownEffect(OrchestrationV2Command); -const readTranscript = Effect.fn("readOrchestratorReplayContractTranscript")(function* (file: URL) { - return yield* readProviderReplayTranscript(file); -}, Effect.provide(NodeServices.layer)); - -function assertUnique(values: ReadonlyArray, label: string) { - assert.deepEqual(new Set(values).size, values.length, `${label} must be unique`); -} - -describe("orchestrator replay fixture contract", () => { - it.effect( - "defines one stable input and provider-specific replay/output contracts per scenario", - () => - Effect.gen(function* () { - assertUnique( - ORCHESTRATOR_REPLAY_FIXTURES.map((fixture) => fixture.name), - "fixture names", - ); - - for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { - assert.isAtLeast(fixture.providers.length, 1, `${fixture.name} must have providers`); - assertUnique( - fixture.providers.map((provider) => provider.driver), - `${fixture.name} provider variants`, - ); - - for (const provider of fixture.providers) { - const transcript = yield* readTranscript(provider.transcriptFile); - const replayGateLabels = fixture - .buildInput() - .steps.flatMap((step) => - step.type === "release_replay_gate" || - step.type === "release_replay_gate_after_waiting" - ? [step.label] - : [], - ); - const materialized = yield* materializeFixtureInput({ - scenario: fixture.name, - fixtureInput: fixture.buildInput(), - driver: provider.driver, - modelSelection: provider.modelSelection, - }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); - const firstCommand = materialized.commands[0]; - - assert.equal(transcript.scenario, provider.recordedScenario ?? fixture.name); - if (provider.driver === "acpRegistry") { - assert.include( - ["acpRegistry", "grok"], - transcript.provider, - "ACP Registry may retarget protocol-standard Grok ACP evidence", - ); - } else { - assert.equal(transcript.provider, provider.driver); - } - assert.equal( - provider.modelSelection.instanceId, - ProviderInstanceId.make(provider.driver), - ); - assert.isDefined(materialized.projectionThreadIds[0]); - assert.equal(firstCommand?.type, "thread.create"); - if (firstCommand?.type !== "thread.create") { - throw new Error(`${fixture.name}/${provider.driver} must start with thread.create`); - } - assert.equal(firstCommand.threadId, materialized.projectionThreadIds[0]); - const commandProducingSteps = fixture - .buildInput() - .steps.filter( - (step) => - step.type !== "advance_clock" && - step.type !== "await_run_status" && - step.type !== "finish_held_run" && - step.type !== "capture_shell_snapshot" && - step.type !== "release_replay_gate" && - step.type !== "release_replay_gate_after_waiting", - ); - assert.equal(materialized.commands.length, commandProducingSteps.length + 1); - assert.isAtLeast(materialized.steps.length, materialized.commands.length); - assert.equal(typeof provider.assertOutput, "function"); - for (const label of replayGateLabels) { - assert.isTrue( - transcript.entries.some( - (entry) => entry.type === "emit_inbound" && entry.label === label, - ), - `${fixture.name}/${provider.driver} replay gate ${label} must label an inbound frame`, - ); - } - if (provider.transcriptEntriesThroughLabel !== undefined) { - assert.isTrue( - transcript.entries.some( - (entry) => - entry.type === "emit_inbound" && - entry.label === provider.transcriptEntriesThroughLabel, - ), - `${fixture.name}/${provider.driver} transcript slice must name an inbound frame`, - ); - } - - assertUnique( - materialized.commands.map((command) => command.commandId), - `${fixture.name}/${provider.driver} command IDs`, - ); - - for (const command of materialized.commands) { - yield* decodeCommand(command); - } - - for (const command of materialized.commands) { - assert.isTrue( - materialized.steps.some( - (step) => - (step.type === "dispatch" && step.command === command) || - (step.type === "respond_to_next_runtime_request" && - step.commandId === command.commandId), - ), - `${fixture.name}/${provider.driver} command ${command.commandId} must appear in the timeline`, - ); - } - } - } - }), - ); - - it.effect("keeps Codex fixture transcripts at the codex app-server boundary", () => - Effect.gen(function* () { - for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { - for (const provider of fixture.providers.filter((entry) => entry.driver === "codex")) { - const transcript = yield* readTranscript(provider.transcriptFile); - const first = transcript.entries[0]; - const last = transcript.entries.at(-1); - - assert.equal(transcript.protocol, "codex.app-server"); - assert.equal(first?.type, "expect_outbound"); - if (first?.type === "expect_outbound") { - assert.equal(first.label, "initialize"); - } - assert.deepEqual(last, { - type: "runtime_exit", - status: "success", - }); - } - } - }), - ); -}); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts deleted file mode 100644 index c62e6e8e5b31..000000000000 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ /dev/null @@ -1,706 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import { - ProviderDriverKind, - ProviderInstanceId, - type OrchestrationV2DomainEvent, - type ProviderReplayEntry, - type ProviderReplayTranscript, -} from "@t3tools/contracts"; -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 Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; - -import * as EffectWorker from "../EffectWorker.ts"; -import * as Orchestrator from "../Orchestrator.ts"; - -import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; -import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; -import { CursorOrchestratorReplayHarness } from "../Adapters/CursorAdapterV2.testkit.ts"; -import { AcpRegistryOrchestratorReplayHarness } from "../Adapters/AcpRegistryAdapterV2.testkit.ts"; -import { GrokOrchestratorReplayHarness } from "../Adapters/GrokAdapterV2.testkit.ts"; -import { OpenCodeOrchestratorReplayHarness } from "../Adapters/OpenCodeAdapterV2.testkit.ts"; -import { - OPENCODE2_HTTP_PROTOCOL, - OpenCode2OrchestratorReplayHarness, -} from "../Adapters/OpenCode2AdapterV2.testkit.ts"; -import { - materializeMuseReplayWorkspace, - MuseOrchestratorReplayHarness, -} from "../Adapters/MuseAdapterV2.testkit.ts"; -import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; -import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; -import { messageRestartInput } from "./fixtures/message_steering/input.ts"; -import { - assertProviderNativeSubagentRootTurns, - materializeFixtureInput, - SIMPLE_PROMPT, - projectionFor, - type OrchestratorFixtureInput, - type ProviderOrchestratorReplayVariant, -} from "./fixtures/shared.ts"; -import { - runOrchestratorV2ProviderReplayScenario, - layerProviderReplay, - type OrchestratorV2ProviderReplayHarness, -} from "./ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; -import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; -import { - materializeReplayTranscriptWorkspace, - readProviderReplayTranscript, -} from "@t3tools/provider-testing/replayTranscript"; - -const readTranscript = Effect.fn("readOrchestratorReplayTranscript")(function* (file: URL) { - return yield* readProviderReplayTranscript(file); -}, Effect.provide(NodeServices.layer)); - -function normalizeTestError(cause: unknown): Error { - return cause instanceof Error ? cause : new Error(String(cause)); -} - -function transcriptEntriesThroughLabel( - transcript: ProviderReplayTranscript, - label: string | undefined, -): ProviderReplayTranscript { - if (label === undefined) { - return transcript; - } - const entryIndex = transcript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === label, - ); - if (entryIndex === -1) { - throw new Error(`${transcript.scenario} is missing inbound transcript label ${label}.`); - } - return { - ...transcript, - entries: transcript.entries.slice(0, entryIndex + 1), - }; -} - -function isStreamingAssistantEvent(event: OrchestrationV2DomainEvent): boolean { - switch (event.type) { - case "node.updated": - return event.payload.kind === "assistant_message" && event.payload.status === "running"; - case "message.updated": - return event.payload.role === "assistant" && event.payload.streaming; - case "turn-item.updated": - return event.payload.type === "assistant_message" && event.payload.streaming; - default: - return false; - } -} - -const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < - Transcript extends ProviderReplayTranscript, - Error, ->(input: { - readonly fixtureName: string; - readonly buildInput: () => OrchestratorFixtureInput; - readonly driver: ProviderOrchestratorReplayVariant; - readonly harness: OrchestratorV2ProviderReplayHarness; - readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; -}) { - const recordedTranscript = yield* readTranscript(input.driver.transcriptFile); - const rawTranscript = input.transformTranscript?.(recordedTranscript) ?? recordedTranscript; - const replayTranscript = materializeReplayTranscriptRuntimeInstructions( - transcriptEntriesThroughLabel(rawTranscript, input.driver.transcriptEntriesThroughLabel), - { driver: input.driver.driver, model: input.driver.modelSelection.model }, - ); - const fixtureInput = input.buildInput(); - const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles); - const transcript = yield* input.harness.decodeTranscript( - input.driver.driver === "codex" - ? materializeReplayTranscriptWorkspace(replayTranscript, workspace) - : input.driver.driver === "muse" - ? yield* materializeMuseReplayWorkspace(replayTranscript, workspace) - : replayTranscript, - ); - const materialized = yield* materializeFixtureInput({ - scenario: input.fixtureName, - fixtureInput, - driver: input.driver.driver, - modelSelection: input.driver.modelSelection, - }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); - const scenario = { - name: `${input.fixtureName}/${input.driver.driver}`, - transcript, - commands: materialized.commands, - steps: materialized.steps, - projectionThreadIds: materialized.projectionThreadIds, - runtimePolicyOverride: { - ...input.driver.runtimePolicyOverride, - cwd: workspace, - }, - }; - - const result = yield* runOrchestratorV2ProviderReplayScenario( - scenario, - input.harness, - input.driver.runContinuationWorker === true ? { runContinuationWorker: true } : {}, - ).pipe(provideDeterministicTestRuntime); - input.driver.assertOutput(result, transcript); - assertProviderNativeSubagentRootTurns(result); - const expectedAbsentWorkspacePaths = input.driver.expectedAbsentWorkspacePaths; - if (expectedAbsentWorkspacePaths !== undefined) { - yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - for (const relativePath of expectedAbsentWorkspacePaths) { - assert.isFalse( - yield* fs.exists(path.join(workspace, relativePath)), - `${input.fixtureName}/${input.driver.driver} must not create ${relativePath} in the replay workspace`, - ); - } - }).pipe(Effect.provide(NodeServices.layer)); - } - assert.isFalse( - result.domainEvents.some(isStreamingAssistantEvent), - "buffered delivery must not persist streaming assistant artifacts", - ); - const projectionThreadId = materialized.projectionThreadIds[0]; - assert.isDefined(projectionThreadId); - const projection = result.projections.get(projectionThreadId); - assert.isDefined(projection); - const latestRun = projection.runs.at(-1); - assert.deepEqual(latestRun?.modelSelection, input.driver.modelSelection); - if (projection.runs.some((run) => run.status === "completed")) { - const threadStartCheckpoint = projection.checkpoints.find( - (checkpoint) => checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null, - ); - assert.isDefined( - threadStartCheckpoint, - "completed threads must retain an addressable thread-start checkpoint", - ); - assert.equal(threadStartCheckpoint.status, "ready"); - } - return result; -}); - -function runFixtureProviderWithRegisteredHarness(input: { - readonly fixtureName: string; - readonly buildInput: () => OrchestratorFixtureInput; - readonly driver: ProviderOrchestratorReplayVariant; - readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; -}) { - switch (input.driver.driver) { - case "codex": - return runFixtureProvider({ - ...input, - harness: CodexOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "claudeAgent": - return runFixtureProvider({ - ...input, - harness: ClaudeOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "cursor": - return runFixtureProvider({ - ...input, - harness: CursorOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "grok": - return runFixtureProvider({ - ...input, - harness: GrokOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "acpRegistry": - return runFixtureProvider({ - ...input, - harness: AcpRegistryOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "opencode": - return readTranscript(input.driver.transcriptFile).pipe( - Effect.flatMap((transcript) => - transcript.protocol === OPENCODE2_HTTP_PROTOCOL - ? runFixtureProvider({ ...input, harness: OpenCode2OrchestratorReplayHarness }) - : runFixtureProvider({ ...input, harness: OpenCodeOrchestratorReplayHarness }), - ), - Effect.mapError(normalizeTestError), - Effect.scoped, - ); - case "pi": - return runFixtureProvider({ - ...input, - harness: PiOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - case "muse": - return runFixtureProvider({ - ...input, - harness: MuseOrchestratorReplayHarness, - }).pipe(Effect.mapError(normalizeTestError), Effect.scoped); - default: - return Effect.die( - new Error(`No replay harness registered for provider ${input.driver.driver}.`), - ); - } -} - -describe("orchestrator replay fixtures", () => { - it.effect.each([ - { name: "open", firstTurn: false, code: -32602 }, - { name: "switch", firstTurn: true, code: -32602 }, - { name: "authentication", firstTurn: false, code: -32000 }, - ])("preserves the ACP model-write $name failure through orchestration", (testCase) => - Effect.gen(function* () { - const name = `acp_model_write_${testCase.name}`; - const sessionId = "grok-replay-session-1"; - const rejectedModel = "replay-model-b"; - const modelSelection = { - instanceId: ProviderInstanceId.make("acpRegistry"), - model: rejectedModel, - }; - const recorded = yield* readTranscript( - new URL("./fixtures/simple/registry_transcript.ndjson", import.meta.url), - ); - const sessionIndex = recorded.entries.findIndex( - (entry) => "label" in entry && entry.label === "session.created", - ); - assert.isAtLeast(sessionIndex, 0); - const transcript = { - ...recorded, - scenario: name, - entries: [ - ...recorded.entries.slice(0, sessionIndex), - { - type: "emit_inbound", - label: "session.created", - frame: { - kind: "response", - method: "session/new", - result: { - sessionId, - configOptions: [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "replay-model-a", - options: [ - { value: "replay-model-a", name: "Model A" }, - { value: rejectedModel, name: "Model B" }, - ], - }, - ], - }, - }, - }, - ...(testCase.firstTurn - ? recorded.entries - .slice(sessionIndex + 1) - .filter((entry) => entry.type !== "runtime_exit") - : []), - { - type: "expect_outbound", - label: "session.set_model", - frame: { - kind: "request", - method: "session/set_config_option", - params: { sessionId, configId: "model", value: rejectedModel }, - }, - }, - { - type: "emit_inbound", - label: "session.set_model.rejected", - frame: { - kind: "response", - method: "session/set_config_option", - error: { - code: testCase.code, - message: testCase.code === -32602 ? "Invalid provider" : "Authentication required", - }, - }, - }, - ], - } satisfies ProviderReplayTranscript; - const materialized = yield* materializeFixtureInput({ - scenario: name, - fixtureInput: { - steps: Array.from({ length: testCase.firstTurn ? 2 : 1 }, () => ({ - type: "message" as const, - text: SIMPLE_PROMPT, - })), - }, - driver: ProviderDriverKind.make("acpRegistry"), - modelSelection: { - ...modelSelection, - model: testCase.firstTurn ? "replay-model-a" : rejectedModel, - }, - }); - const scenario = { - ...materialized, - name, - transcript: yield* AcpRegistryOrchestratorReplayHarness.decodeTranscript( - materializeReplayTranscriptRuntimeInstructions(transcript, { - driver: ProviderDriverKind.make("acpRegistry"), - model: "replay-model-a", - }), - ), - runtimePolicyOverride: { cwd: yield* checkpointWorkspace(name) }, - }; - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - if (testCase.firstTurn) yield* EffectWorker.runDaemon.pipe(Effect.forkScoped); - for (const [index, command] of materialized.commands.entries()) { - const isFirstTurn = - testCase.firstTurn && command.type === "message.dispatch" && index === 1; - yield* orchestrator.dispatch( - command.type === "message.dispatch" && !isFirstTurn - ? { ...command, modelSelection } - : command, - ); - if (isFirstTurn) { - // Wait for the committed terminal event before switching models. - yield* orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => event.type === "run.updated" && event.payload.status === "completed", - ), - Stream.runHead, - ); - } - } - if (testCase.firstTurn) { - yield* orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => event.type === "run.updated" && event.payload.status === "failed", - ), - Stream.runHead, - ); - } else { - // Failed opens retry on the outbox schedule. Advance to each recorded - // deadline, draining effects instead of polling the projection. - for (let attempt = 0; attempt < 10; attempt++) { - yield* worker.drain(); - const next = yield* worker.nextClaimableAt; - if (Option.isNone(next)) break; - const now = yield* DateTime.now; - yield* TestClock.adjust( - Math.max(0, DateTime.toEpochMillis(next.value) - DateTime.toEpochMillis(now)), - ); - } - } - const projection = yield* orchestrator.getThreadProjection( - materialized.projectionThreadIds[0]!, - ); - assert.deepEqual( - projection.runs.map((run) => run.status), - testCase.firstTurn ? ["completed", "failed"] : ["failed"], - ); - const errors = projection.turnItems.filter((item) => item.type === "error"); - assert.lengthOf(errors, 1); - assert.equal(errors[0]?.failure.class, "provider_error"); - if (testCase.code === -32602) { - assert.equal( - errors[0]?.failure.message, - 'The agent did not accept the model "replay-model-b". Choose another model for this provider, then retry.', - ); - } else { - assert.match( - errors[0]?.failure.message ?? "", - /^Failed to open acpRegistry provider session \S+\.$/u, - ); - } - }).pipe( - Effect.provide( - layerProviderReplay(scenario, AcpRegistryOrchestratorReplayHarness, { - runEffectWorker: false, - }), - ), - ); - }).pipe( - Effect.scoped, - provideDeterministicTestRuntime, - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), - ), - ); - - it.effect.each( - ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => - fixture.providers.map( - (provider) => [fixture.name, provider.driver, fixture, provider] as const, - ), - ), - )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) => - runFixtureProviderWithRegisteredHarness({ - fixtureName: fixture.name, - buildInput: fixture.buildInput, - driver: provider, - }), - ); - - const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (fixture) => fixture.name === "message_steering", - ); - const cursorSteeringProvider = steeringFixture?.providers.find( - (provider) => provider.driver === "cursor", - ); - if (cursorSteeringProvider !== undefined) { - it.effect("executes explicit Cursor restart_active through the recorded SDK boundary", () => - runFixtureProviderWithRegisteredHarness({ - fixtureName: "message_steering", - buildInput: messageRestartInput, - driver: cursorSteeringProvider, - }), - ); - } - - it.effect.each( - ( - [ - ["opencode2_subagent", "session.execution.started.2"], - ["opencode2_background", "session.execution.started.3"], - ] as const - ).flatMap(([fixtureName, label]) => { - const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (candidate) => candidate.name === fixtureName, - ); - const provider = fixture?.providers[0]; - return fixture === undefined || provider === undefined - ? [] - : [[fixtureName, label, fixture, provider] as const]; - }), - )( - "runs %s when %s is an execution start this build cannot decode", - ([fixtureName, label, fixture, provider]) => - runFixtureProviderWithRegisteredHarness({ - fixtureName, - buildInput: fixture.buildInput, - driver: provider, - transformTranscript: (transcript) => ({ - ...transcript, - entries: transcript.entries.map((entry) => - entry.type === "emit_inbound" && entry.label === label - ? undecodableEvent(entry) - : entry, - ), - }), - }), - ); - - const afterRootFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (candidate) => candidate.name === "claude_background_subagent_after_root", - ); - const afterRootProvider = afterRootFixture?.providers[0]; - if (afterRootFixture !== undefined && afterRootProvider !== undefined) { - const OPEN_TOOL_ID = "toolu_01QDa5jV5g1H9h6QyDfeJohD"; - const SUBAGENT_TASK_ID = "a2995bfced8019363"; - - const moveOpenToolBeforeRootResult = ( - move: boolean, - entries: ReadonlyArray, - ): ReadonlyArray => { - if (!move) return entries; - const toolIndex = entries.findIndex( - (entry) => - entry.type === "emit_inbound" && - (entry.frame as Record).message?.content?.[0]?.id === OPEN_TOOL_ID, - ); - const resultIndex = entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "result", - ); - if (toolIndex < 0 || resultIndex < 0) throw new Error("transcript shape changed"); - const toolEntry = entries[toolIndex]!; - const rest = entries.filter((_, index) => index !== toolIndex); - return [...rest.slice(0, resultIndex), toolEntry, ...rest.slice(resultIndex)]; - }; - // A subagent's foreground Bash run during the root's turn is tailed like a - // root call's, so the child's command row streams live. - const SUBAGENT_BASH_ID = "toolu_01JMN3SSQCHzUTNua9AWdkiH"; - it.effect("streams a subagent's foreground Bash output live during the root turn", () => - runFixtureProviderWithRegisteredHarness({ - fixtureName: afterRootFixture.name, - buildInput: afterRootFixture.buildInput, - driver: { - ...afterRootProvider, - assertOutput: (result) => { - const childThreadId = projectionFor(result, afterRootFixture.name).subagents[0] - ?.childThreadId; - assert.exists(childThreadId); - const command = result.projections - .get(childThreadId) - ?.turnItems.find( - (item) => - item.type === "command_execution" && - JSON.stringify(item.input).includes("SUB_DONE_1"), - ); - assert.exists(command); - const streamed = result.commandOutput?.get(command.id); - assert.exists(streamed, "the subagent's command must stream its output"); - assert.include(streamed.output.text, "SUB_DONE_1"); - }, - }, - transformTranscript: (transcript) => { - const isBashFrame = (entry: ProviderReplayEntry) => { - if (entry.type !== "emit_inbound") return false; - const frame = entry.frame as Record; - return ( - frame.message?.content?.[0]?.id === SUBAGENT_BASH_ID || - frame.message?.content?.[0]?.tool_use_id === SUBAGENT_BASH_ID || - (frame.type === "system" && frame.tool_use_id === SUBAGENT_BASH_ID) - ); - }; - // Recorded without the structured result the replay writes output files from. - const bashFrames = transcript.entries.filter(isBashFrame).map((entry) => { - const frame = (entry as { readonly frame: Record }).frame; - return frame.type === "user" - ? { - ...entry, - frame: { ...frame, tool_use_result: { stdout: "SUB_DONE_1\n", stderr: "" } }, - } - : entry; - }); - const rest = transcript.entries.filter((entry) => !isBashFrame(entry)); - const resultIndex = rest.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "result", - ); - assert.lengthOf(bashFrames, 4, "transcript shape changed"); - assert.isAtLeast(resultIndex, 0, "transcript shape changed"); - return { - ...transcript, - entries: [...rest.slice(0, resultIndex), ...bashFrames, ...rest.slice(resultIndex)], - }; - }, - }), - ); - - it.effect.each( - (["WebFetch", "Write"] as const).flatMap((tool) => - (["before root settles", "while root is idle"] as const).flatMap((opens) => - (["stopped", "failed"] as const).map((status) => [tool, opens, status] as const), - ), - ), - )( - "ends a background subagent's open %s (opened %s) when its notification is %s", - ([tool, opens, notificationStatus]) => - runFixtureProviderWithRegisteredHarness({ - fixtureName: afterRootFixture.name, - buildInput: afterRootFixture.buildInput, - driver: { - ...afterRootProvider, - assertOutput: (result) => { - const parent = projectionFor(result, afterRootFixture.name); - assert.deepEqual( - parent.runs.map((run) => run.status), - ["completed", "completed"], - ); - const subagent = parent.subagents[0]; - assert.equal( - subagent?.status, - notificationStatus === "stopped" ? "cancelled" : "failed", - ); - const childThreadId = subagent?.childThreadId; - assert.exists(childThreadId); - const child = result.projections.get(childThreadId); - assert.exists(child); - const fetches = child.turnItems.filter( - (item) => item.type === (tool === "WebFetch" ? "web_search" : "file_change"), - ); - assert.lengthOf(fetches, 1, `the ${tool} reached the child thread`); - assert.deepEqual( - { - openTool: fetches.map((item) => item.status), - openChildNodes: child.nodes - .filter( - (node) => - node.status === "running" || - node.status === "pending" || - node.status === "waiting", - ) - .map((node) => `${node.kind}:${node.status}`), - }, - { - openTool: [notificationStatus === "stopped" ? "interrupted" : "failed"], - openChildNodes: [], - }, - ); - }, - }, - transformTranscript: (transcript) => ({ - ...transcript, - entries: moveOpenToolBeforeRootResult( - opens === "before root settles", - transcript.entries.flatMap((entry): ReadonlyArray => { - if (entry.type !== "emit_inbound") return [entry]; - const frame = entry.frame as Record; - if ( - frame.type === "assistant" && - frame.message?.content?.[0]?.id === OPEN_TOOL_ID - ) { - return [ - { - ...entry, - frame: { - ...frame, - message: { - ...frame.message, - content: [ - { - ...frame.message.content[0], - name: tool, - input: - tool === "WebFetch" - ? { url: "https://example.com", prompt: "Summarize" } - : { file_path: "notes.txt", content: "SUB_DONE_2" }, - }, - ], - }, - }, - }, - ]; - } - if ( - (frame.type === "system" && - (frame.subtype === "task_started" || frame.subtype === "task_notification") && - frame.tool_use_id === OPEN_TOOL_ID) || - (frame.type === "user" && - frame.message?.content?.[0]?.tool_use_id === OPEN_TOOL_ID) || - (frame.type === "assistant" && - frame.parent_tool_use_id !== null && - frame.message?.content?.[0]?.text === "SUB_FINAL_REPORT") - ) { - return []; - } - if (frame.type === "system" && frame.subtype === "task_updated") { - return [ - { - ...entry, - frame: { - ...frame, - patch: { - ...frame.patch, - status: notificationStatus === "stopped" ? "killed" : "failed", - }, - }, - }, - ]; - } - if ( - frame.type === "system" && - frame.subtype === "task_notification" && - frame.task_id === SUBAGENT_TASK_ID - ) { - return [{ ...entry, frame: { ...frame, status: notificationStatus } }]; - } - return [entry]; - }), - ), - }), - }), - ); - } -}); - -function undecodableEvent(entry: ProviderReplayEntry): ProviderReplayEntry { - if (entry.type !== "emit_inbound") return entry; - const frame = entry.frame as { readonly event: Record }; - return { ...entry, frame: { ...frame, event: { ...frame.event, durable: "not-an-envelope" } } }; -} diff --git a/apps/server/src/orchestration-v2/testkit/PiReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/PiReplayFixtures.integration.test.ts new file mode 100644 index 000000000000..b9db455d1cff --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/PiReplayFixtures.integration.test.ts @@ -0,0 +1,10 @@ +import { describe } from "@effect/vitest"; + +import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; +import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; + +describe("Pi replay fixtures", () => { + itRunsReplayFixtures("pi", (input) => + runReplayFixture({ ...input, harness: PiOrchestratorReplayHarness }), + ); +}); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts new file mode 100644 index 000000000000..8c23e9c90181 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayFixtures.ts @@ -0,0 +1,219 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { OrchestrationV2DomainEvent, ProviderReplayTranscript } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; +import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; +import { + assertProviderNativeSubagentRootTurns, + materializeFixtureInput, + type OrchestratorFixtureInput, + type ProviderOrchestratorReplayVariant, +} from "./fixtures/shared.ts"; +import { + runOrchestratorV2ProviderReplayScenario, + type OrchestratorV2ProviderReplayHarness, +} from "./ProviderReplayHarness.ts"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; + +// Each driver's fixtures run from its own ReplayFixtures.integration.test.ts, +// so a focused run loads one adapter. A fixture for a driver missing here would +// silently never run, so loading this module fails instead. +const DRIVERS_WITH_REPLAY_FIXTURE_FILES = new Set([ + "acpRegistry", + "claudeAgent", + "codex", + "cursor", + "grok", + "muse", + "opencode", + "pi", +]); +for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { + for (const provider of fixture.providers) { + if (!DRIVERS_WITH_REPLAY_FIXTURE_FILES.has(provider.driver)) { + throw new Error( + `${fixture.name}/${provider.driver} has no ReplayFixtures test file to run it.`, + ); + } + } +} + +export const readReplayFixtureTranscript = Effect.fn("readOrchestratorReplayTranscript")(function* ( + file: URL, +) { + return yield* readProviderReplayTranscript(file); +}, Effect.provide(NodeServices.layer)); + +function normalizeTestError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} + +function transcriptEntriesThroughLabel( + transcript: ProviderReplayTranscript, + label: string | undefined, +): ProviderReplayTranscript { + if (label === undefined) { + return transcript; + } + const entryIndex = transcript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === label, + ); + if (entryIndex === -1) { + throw new Error(`${transcript.scenario} is missing inbound transcript label ${label}.`); + } + return { + ...transcript, + entries: transcript.entries.slice(0, entryIndex + 1), + }; +} + +function isStreamingAssistantEvent(event: OrchestrationV2DomainEvent): boolean { + switch (event.type) { + case "node.updated": + return event.payload.kind === "assistant_message" && event.payload.status === "running"; + case "message.updated": + return event.payload.role === "assistant" && event.payload.streaming; + case "turn-item.updated": + return event.payload.type === "assistant_message" && event.payload.streaming; + default: + return false; + } +} + +export interface ReplayFixtureCase { + readonly fixtureName: string; + readonly buildInput: () => OrchestratorFixtureInput; + readonly driver: ProviderOrchestratorReplayVariant; + readonly transformTranscript?: (transcript: ProviderReplayTranscript) => ProviderReplayTranscript; +} + +export interface ReplayFixtureRun< + Transcript extends ProviderReplayTranscript, + Error, + WorkspaceError = never, +> extends ReplayFixtureCase { + readonly harness: OrchestratorV2ProviderReplayHarness; + /** Rewrites recorded workspace paths to the replay workspace, for drivers that record them. */ + readonly materializeWorkspace?: ( + transcript: ProviderReplayTranscript, + workspace: string, + ) => Effect.Effect; +} + +const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < + Transcript extends ProviderReplayTranscript, + Error, + WorkspaceError, +>(input: ReplayFixtureRun) { + const recordedTranscript = yield* readReplayFixtureTranscript(input.driver.transcriptFile); + const rawTranscript = input.transformTranscript?.(recordedTranscript) ?? recordedTranscript; + const replayTranscript = materializeReplayTranscriptRuntimeInstructions( + transcriptEntriesThroughLabel(rawTranscript, input.driver.transcriptEntriesThroughLabel), + { driver: input.driver.driver, model: input.driver.modelSelection.model }, + ); + const fixtureInput = input.buildInput(); + const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles); + const transcript = yield* input.harness.decodeTranscript( + input.materializeWorkspace === undefined + ? replayTranscript + : yield* input.materializeWorkspace(replayTranscript, workspace), + ); + const materialized = yield* materializeFixtureInput({ + scenario: input.fixtureName, + fixtureInput, + driver: input.driver.driver, + modelSelection: input.driver.modelSelection, + }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); + const scenario = { + name: `${input.fixtureName}/${input.driver.driver}`, + transcript, + commands: materialized.commands, + steps: materialized.steps, + projectionThreadIds: materialized.projectionThreadIds, + runtimePolicyOverride: { + ...input.driver.runtimePolicyOverride, + cwd: workspace, + }, + }; + + const result = yield* runOrchestratorV2ProviderReplayScenario( + scenario, + input.harness, + input.driver.runContinuationWorker === true ? { runContinuationWorker: true } : {}, + ).pipe(provideDeterministicTestRuntime); + input.driver.assertOutput(result, transcript); + assertProviderNativeSubagentRootTurns(result); + const expectedAbsentWorkspacePaths = input.driver.expectedAbsentWorkspacePaths; + if (expectedAbsentWorkspacePaths !== undefined) { + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const relativePath of expectedAbsentWorkspacePaths) { + assert.isFalse( + yield* fs.exists(path.join(workspace, relativePath)), + `${input.fixtureName}/${input.driver.driver} must not create ${relativePath} in the replay workspace`, + ); + } + }).pipe(Effect.provide(NodeServices.layer)); + } + assert.isFalse( + result.domainEvents.some(isStreamingAssistantEvent), + "buffered delivery must not persist streaming assistant artifacts", + ); + const projectionThreadId = materialized.projectionThreadIds[0]; + assert.isDefined(projectionThreadId); + const projection = result.projections.get(projectionThreadId); + assert.isDefined(projection); + const latestRun = projection.runs.at(-1); + assert.deepEqual(latestRun?.modelSelection, input.driver.modelSelection); + if (projection.runs.some((run) => run.status === "completed")) { + const threadStartCheckpoint = projection.checkpoints.find( + (checkpoint) => checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null, + ); + assert.isDefined( + threadStartCheckpoint, + "completed threads must retain an addressable thread-start checkpoint", + ); + assert.equal(threadStartCheckpoint.status, "ready"); + } + return result; +}); + +/** Replays one fixture variant through OrchestratorV2 and runs the shared output checks. */ +export function runReplayFixture< + Transcript extends ProviderReplayTranscript, + Error, + WorkspaceError = never, +>(input: ReplayFixtureRun) { + return runFixtureProvider(input).pipe(Effect.mapError(normalizeTestError), Effect.scoped); +} + +export function findReplayFixture(name: string) { + return ORCHESTRATOR_REPLAY_FIXTURES.find((fixture) => fixture.name === name); +} + +/** + * Registers one test per recorded fixture variant of `driver`. Call it inside the + * provider file's `describe`. `run` picks the harness for each variant. + */ +export function itRunsReplayFixtures( + driver: string, + run: (input: ReplayFixtureCase) => Effect.Effect, +) { + it.effect.each( + ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => + fixture.providers + .filter((provider) => provider.driver === driver) + .map((provider) => [fixture.name, provider.driver, fixture, provider] as const), + ), + )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) => + run({ fixtureName: fixture.name, buildInput: fixture.buildInput, driver: provider }), + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 8d9cd5532e0d..72aae60c0d07 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -12,6 +12,7 @@ import { type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurnTokenUsage, + type OrchestrationV2ThreadProjection, ProjectId, ProviderInstanceId, ProviderSessionId, @@ -36,7 +37,6 @@ import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; import { ClaudeAppendSystemPromptFileError, - ClaudeAppendSystemPromptMissingValueError, ClaudeBackgroundWorkBlocksQueryReplacementError, ClaudeProviderCapabilitiesV2, } from "../Adapters/ClaudeAdapterV2.ts"; @@ -45,7 +45,6 @@ import { canReuseCodexContextUsage, } from "../Adapters/CodexAdapterV2.ts"; import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; -import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts"; @@ -59,7 +58,6 @@ import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { CLAUDE_MODEL_SELECTION, CODEX_MODEL_SELECTION, - CURSOR_MODEL_SELECTION, GROK_MODEL_SELECTION, } from "./fixtures/shared.ts"; import * as ProviderReplayHarness from "./ProviderReplayHarness.ts"; @@ -70,12 +68,8 @@ const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const threadId = ThreadId.make("thread:provider-switch"); const projectId = ProjectId.make("project:provider-switch"); -const firstPrompt = "Respond with exactly: codex before switch"; -const claudePrompt = "Respond with exactly: claude switched response"; -const returnPrompt = "Respond with exactly: codex after return"; const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); -const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); const GROK_DRIVER = ProviderDriverKind.make("acp"); interface CapturedTurn { @@ -385,53 +379,43 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* ( targetThreadId: ThreadId, ) { const orchestrator = yield* Orchestrator.OrchestratorV2; - for (let attempt = 0; attempt < 1_000; attempt += 1) { - const projection = yield* orchestrator.getThreadProjection(targetThreadId); - if ( - projection.runs.every( - (run) => !["queued", "starting", "running", "waiting"].includes(run.status), - ) - ) { - return projection; - } - yield* Effect.sleep("5 millis"); + const eventSink = yield* EventSink.EventSinkV2; + const isIdle = (projection: OrchestrationV2ThreadProjection) => + projection.runs.every( + (run) => !["queued", "starting", "running", "waiting"].includes(run.status), + ); + const afterSequence = yield* eventSink.latestSequence(); + const current = yield* orchestrator.getThreadProjection(targetThreadId); + if (isIdle(current)) { + return current; + } + // Projections commit with their events, so each later event is the only time to re-read. + const [idle] = yield* eventSink.stream({ afterSequence }).pipe( + Stream.mapEffect(() => orchestrator.getThreadProjection(targetThreadId)), + Stream.filter(isIdle), + Stream.take(1), + Stream.runCollect, + ); + if (idle === undefined) { + return yield* Effect.die( + new Error("Provider switch test event stream ended before the thread went idle"), + ); } - return yield* Effect.die(new Error("Provider switch test timed out waiting for idle")); + return idle; }); describe("orchestration v2 provider switching", () => { it.live.each([ "compact-native", - "compact-fallback", "compact-legacy", "large-current-input", - "screenshot-native", "screenshot-fallback", - "screenshot-pair-native", - "screenshot-pair-fallback", - "screenshot-eight-native", - "screenshot-eight-fallback", - "screenshot-large-model-native", "screenshot-reported-capacity-native", - "screenshot-model-change-native", - "screenshot-option-change-native", - "screenshot-model-change-retry-native", "screenshot-option-change-retry-native", - "prior-images-reasoning-change-replacement-native", "prior-images-reasoning-change-replacement-small-native", - "prior-images-reasoning-change-native", - "prior-images-turn-usage-native", - "prior-images-turn-usage-replacement-native", - "prior-images-turn-usage-model-change-native", - "prior-images-turn-usage-option-change-retry-native", - "prior-images-reasoning-change-retry-native", - "prior-images-native", - "prior-images-fallback", - "imported-prior-images-native", "imported-prior-images-fallback", "prior-images-telemetry-native", "prior-images-unsent-native", - "prior-images-replacement-native", "prior-images-legacy-replacement-native", "delivery-write-failure", ] as const)("preserves handoffs through %s", (scenario) => @@ -846,22 +830,20 @@ describe("orchestration v2 provider switching", () => { if (scenario.startsWith("screenshot")) { assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); } - if (scenario === "compact-fallback" || scenario === "compact-legacy") { - if (scenario === "compact-legacy") { - const { history: _history, ...legacyHandoff } = handoff; - yield* eventSink.write({ - events: [ - { - id: EventId.make("legacy-handoff-shape"), - type: "context-handoff.updated", - threadId, - runId: handoff.targetRunId, - occurredAt: yield* DateTime.now, - payload: legacyHandoff, - }, - ], - }); - } + if (scenario === "compact-legacy") { + const { history: _history, ...legacyHandoff } = handoff; + yield* eventSink.write({ + events: [ + { + id: EventId.make("legacy-handoff-shape"), + type: "context-handoff.updated", + threadId, + runId: handoff.targetRunId, + occurredAt: yield* DateTime.now, + payload: legacyHandoff, + }, + ], + }); assert.isUndefined(handoff.delivery); yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); yield* wait(3); @@ -1203,10 +1185,6 @@ describe("orchestration v2 provider switching", () => { it.live.each([ ["background work", new ClaudeBackgroundWorkBlocksQueryReplacementError()], ["an unreadable append file", new ClaudeAppendSystemPromptFileError({ path: "missing.md" })], - [ - "a missing append value", - new ClaudeAppendSystemPromptMissingValueError({ flag: "--append-system-prompt" }), - ], ] as const)("keeps the native session after turns refused for %s", ([, refusalCause]) => Effect.scoped( Effect.gen(function* () { @@ -1301,18 +1279,19 @@ describe("orchestration v2 provider switching", () => { ); it.live.each( - (["failed", "interrupted"] as const).flatMap((status) => - [false, true].flatMap((queued) => - [false, true].flatMap((returning) => - [false, true].map( - (native) => - [ - `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, - { status, queued, returning, native }, - ] as const, - ), - ), - ), + ( + [ + ["failed", false, false, false], + ["failed", true, true, true], + ["interrupted", false, true, false], + ["interrupted", true, false, true], + ] as const + ).map( + ([status, queued, returning, native]) => + [ + `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, + { status, queued, returning, native }, + ] as const, ), )("hands off %s", ([, { status, queued, returning, native }]) => Effect.scoped( @@ -2246,10 +2225,9 @@ describe("orchestration v2 provider switching", () => { ), ); + // regression: #21314 a queued PR update must follow the provider that took over the thread it.live.each([ ["automatic", "Claude"], - ["queue.resume", "Claude"], - ["automatic", "compatible Codex"], ["queue.resume", "compatible Codex"], ] as const)( "delivers a queued PR update on the current provider through %s (%s)", @@ -2442,9 +2420,6 @@ describe("orchestration v2 provider switching", () => { if (compatible) { assert.equal(executed.nativeThreadId, turns[0]!.nativeThreadId); assert.isEmpty(after.contextHandoffs); - if (delivery === "automatic") { - assert.equal(delivered.providerThreadId, wake.providerThreadId); - } } assert.equal( after.providerThreads.find((entry) => entry.id === delivered.providerThreadId) @@ -2975,817 +2950,8 @@ describe("orchestration v2 provider switching", () => { assert.include(turns[1]?.text ?? "", failedPrompt); }), ); - - it.live("reissues imported v1 context when switching after the first provider fails", () => - importedFailureScenario(false), - ); it.live( "reissues imported v1 context when a queued provider starts after the first provider fails", () => importedFailureScenario(true), ); - - it.live("uses portable fallback when native resume fails after a provider switch", () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("provider-switch"); - const capturedTurns = yield* Ref.make>([]); - const codexNativeThreadGeneration = yield* Ref.make(0); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeTestAdapter({ - instanceId: ProviderInstanceId.make("codex"), - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: { - 1: "codex before switch", - 3: "codex after return", - }, - capturedTurns, - failResume: true, - nativeThreadGeneration: codexNativeThreadGeneration, - }), - makeTestAdapter({ - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: { 2: "claude switched response" }, - capturedTurns, - }), - ]); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:provider-switch:create"), - threadId, - projectId, - title: "Provider switch", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:provider-switch:codex"), - threadId, - messageId: MessageId.make("message:provider-switch:codex"), - text: firstPrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:provider-switch:claude"), - threadId, - messageId: MessageId.make("message:provider-switch:claude"), - text: claudePrompt, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:provider-switch:return"), - threadId, - messageId: MessageId.make("message:provider-switch:return"), - text: returnPrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - - const projection = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch(commands[0]!); - yield* orchestrator.dispatch(commands[1]!); - yield* waitForIdle(threadId); - yield* orchestrator.dispatch(commands[2]!); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection, - CLAUDE_MODEL_SELECTION, - ); - yield* waitForIdle(threadId); - const codexSession = (yield* orchestrator.getThreadProjection( - threadId, - )).providerSessions.find( - (session) => session.providerInstanceId === CODEX_MODEL_SELECTION.instanceId, - )!; - yield* orchestrator.dispatch({ - type: "provider-session.detach", - commandId: CommandId.make("command:provider-switch:stop-codex"), - threadId, - providerSessionId: codexSession.id, - }); - yield* (yield* EffectWorker.OrchestrationEffectWorkerV2).drain(); - yield* orchestrator.dispatch(commands[3]!); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection, - CODEX_MODEL_SELECTION, - ); - return yield* waitForIdle(threadId); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { - name: "provider-switch", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ), - ), - ); - const turns = yield* Ref.get(capturedTurns); - - assert.deepEqual( - projection.runs.map((run) => [run.providerInstanceId, run.status]), - [ - ["codex", "completed"], - ["claudeAgent", "completed"], - ["codex", "completed"], - ], - ); - assert.lengthOf(projection.providerThreads, 2); - assert.equal(projection.runs[0]?.providerThreadId, projection.runs[2]?.providerThreadId); - assert.notEqual(projection.runs[0]?.providerThreadId, projection.runs[1]?.providerThreadId); - assert.equal(yield* Ref.get(codexNativeThreadGeneration), 2); - const codexThread = projection.providerThreads.find( - (providerThread) => providerThread.id === projection.runs[2]?.providerThreadId, - ); - assert.equal(codexThread?.nativeThreadRef?.nativeId, `codex:${threadId}:1`); - assert.deepEqual( - projection.contextHandoffs.map((handoff) => [ - handoff.strategy, - handoff.coveredRunOrdinals, - handoff.delivery?.status, - handoff.delivery?.nativeThreadId, - ]), - [ - ["full_thread_summary", { from: 1, to: 1 }, "inline", `claudeAgent:${threadId}`], - ["delta_since_target_last_seen", { from: 2, to: 2 }, "inline", `codex:${threadId}:1`], - ["full_thread_summary", { from: 1, to: 2 }, "inline", `codex:${threadId}:1`], - ], - ); - assert.deepEqual( - projection.contextTransfers.map((transfer) => [ - transfer.type, - transfer.status, - transfer.resolution?.strategy, - ]), - [ - ["provider_handoff", "consumed", "portable_context"], - ["provider_handoff", "consumed", "delta_context"], - ["provider_handoff", "resolved_portable", "portable_context"], - ], - ); - assert.deepEqual( - projection.turnItems - .filter((item) => item.type === "user_message") - .map((item) => item.text), - [firstPrompt, claudePrompt, returnPrompt], - ); - assert.deepEqual( - projection.providerThreads.map((providerThread) => [ - providerThread.driver, - providerThread.status, - providerThread.handoffIds.length, - ]), - [ - ["codex", "idle", 1], - ["claudeAgent", "idle", 1], - ], - ); - assert.equal(turns[0]?.text, firstPrompt); - assert.include(turns[1]?.text ?? "", "Context handoff (full_thread_summary):"); - assert.include(turns[1]?.text ?? "", "codex before switch"); - assert.include(turns[1]?.text ?? "", claudePrompt); - assert.include(turns[2]?.text ?? "", "Context handoff (full_thread_summary):"); - assert.include(turns[2]?.text ?? "", "Context handoff (delta_since_target_last_seen):"); - assert.include(turns[2]?.text ?? "", "codex before switch"); - assert.include(turns[2]?.text ?? "", "claude switched response"); - assert.include(turns[2]?.text ?? "", returnPrompt); - assert.equal(turns[0]?.providerThreadId, turns[2]?.providerThreadId); - }), - ), - ); - - it.live("resolves a Claude fork into portable Codex context on first dispatch", () => - Effect.scoped( - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("thread:cross-provider-fork:source"); - const targetThreadId = ThreadId.make("thread:cross-provider-fork:target"); - const sourcePrompt = "Remember that the release color is violet."; - const targetPrompt = "What release color did we choose?"; - const cwd = yield* checkpointWorkspace("cross-provider-fork"); - const capturedTurns = yield* Ref.make>([]); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeTestAdapter({ - instanceId: ProviderInstanceId.make("codex"), - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: { 1: "The release color is violet." }, - capturedTurns, - }), - makeTestAdapter({ - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: { 1: "I will remember violet." }, - capturedTurns, - }), - ]); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-fork:create"), - threadId: sourceThreadId, - projectId, - title: "Cross-provider fork source", - modelSelection: CLAUDE_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-fork:source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-fork:source"), - text: sourcePrompt, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-fork:fork"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Cross-provider fork target", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-fork:target"), - threadId: targetThreadId, - messageId: MessageId.make("message:cross-provider-fork:target"), - text: targetPrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - - const targetProjection = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch(commands[0]!); - yield* orchestrator.dispatch(commands[1]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[2]!); - yield* orchestrator.dispatch(commands[3]!); - return yield* waitForIdle(targetThreadId); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { - name: "cross-provider-fork", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ), - ), - ); - const turns = yield* Ref.get(capturedTurns); - const targetTurn = turns.find((turn) => turn.threadId === targetThreadId); - - assert.deepEqual( - targetProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [["codex", "completed"]], - ); - assert.lengthOf(targetProjection.providerThreads, 1); - assert.equal(targetProjection.providerThreads[0]?.driver, "codex"); - assert.isNull(targetProjection.providerThreads[0]?.forkedFrom); - assert.deepEqual( - targetProjection.contextTransfers.map((transfer) => [ - transfer.type, - transfer.status, - transfer.resolution?.strategy, - ]), - [["fork", "consumed", "portable_context"]], - ); - assert.deepEqual( - targetProjection.contextHandoffs.map((handoff) => handoff.strategy), - ["full_thread_summary"], - ); - assert.equal( - targetProjection.runs[0]?.contextHandoffId, - targetProjection.contextHandoffs[0]?.id, - ); - assert.include(targetTurn?.text ?? "", "Context handoff (full_thread_summary):"); - assert.include(targetTurn?.text ?? "", sourcePrompt); - assert.include(targetTurn?.text ?? "", "I will remember violet."); - assert.include(targetTurn?.text ?? "", targetPrompt); - }), - ), - ); - - it.live("resolves a same-provider Cursor fork with portable context", () => - Effect.scoped( - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("thread:cursor-portable-fork:source"); - const targetThreadId = ThreadId.make("thread:cursor-portable-fork:target"); - const sourcePrompt = "Remember that the deployment marker is indigo."; - const sourceResponse = "I will remember indigo."; - const targetPrompt = "What deployment marker did we choose?"; - const cwd = yield* checkpointWorkspace("cursor-portable-fork"); - const capturedTurns = yield* Ref.make>([]); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeTestAdapter({ - instanceId: ProviderInstanceId.make("cursor"), - driver: CURSOR_DRIVER, - capabilities: CursorProviderCapabilitiesV2, - modelSelection: CURSOR_MODEL_SELECTION, - responseByRunOrdinal: {}, - responseByThreadId: { - [sourceThreadId]: { 1: sourceResponse }, - [targetThreadId]: { 1: "The deployment marker is indigo." }, - }, - capturedTurns, - }), - ]); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-portable-fork:create"), - threadId: sourceThreadId, - projectId, - title: "Cursor portable fork source", - modelSelection: CURSOR_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-portable-fork:source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cursor-portable-fork:source"), - text: sourcePrompt, - attachments: [], - modelSelection: CURSOR_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-portable-fork:fork"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Cursor portable fork target", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cursor-portable-fork:target"), - threadId: targetThreadId, - messageId: MessageId.make("message:cursor-portable-fork:target"), - text: targetPrompt, - attachments: [], - modelSelection: CURSOR_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - - const targetProjection = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch(commands[0]!); - yield* orchestrator.dispatch(commands[1]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[2]!); - yield* orchestrator.dispatch(commands[3]!); - return yield* waitForIdle(targetThreadId); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { - name: "cursor-portable-fork", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ), - ), - ); - const turns = yield* Ref.get(capturedTurns); - const targetTurn = turns.find((turn) => turn.threadId === targetThreadId); - - assert.deepEqual( - targetProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [["cursor", "completed"]], - ); - assert.lengthOf(targetProjection.providerThreads, 1); - assert.equal(targetProjection.providerThreads[0]?.driver, "cursor"); - assert.isNull(targetProjection.providerThreads[0]?.forkedFrom); - assert.deepEqual( - targetProjection.contextTransfers.map((transfer) => [ - transfer.type, - transfer.status, - transfer.resolution?.strategy, - ]), - [["fork", "consumed", "portable_context"]], - ); - assert.deepEqual( - targetProjection.contextHandoffs.map((handoff) => handoff.strategy), - ["full_thread_summary"], - ); - assert.include(targetTurn?.text ?? "", "Context handoff (full_thread_summary):"); - assert.include(targetTurn?.text ?? "", sourcePrompt); - assert.include(targetTurn?.text ?? "", sourceResponse); - assert.include(targetTurn?.text ?? "", targetPrompt); - }), - ), - ); - - it.live.each([false, true])( - "switches providers while consuming a pending cross-provider merge-back (resume failure: %s)", - (failResume) => - Effect.scoped( - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); - const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); - const firstSourcePrompt = "Remember that the first source marker is amber."; - const secondSourcePrompt = "Remember that the second source marker is violet."; - const forkPrompt = "Remember that the fork marker is cobalt."; - const mergePrompt = "Report all three remembered markers."; - const cwd = yield* checkpointWorkspace("cross-provider-merge"); - const capturedTurns = yield* Ref.make>([]); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeTestAdapter({ - instanceId: ProviderInstanceId.make("codex"), - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: {}, - responseByThreadId: { - [sourceThreadId]: { - 1: "I will remember amber.", - 3: "The markers are amber, violet, and cobalt.", - }, - [forkThreadId]: { - 1: "I will remember cobalt.", - }, - }, - capturedTurns, - failResume, - }), - makeTestAdapter({ - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: { 2: "I will remember violet." }, - capturedTurns, - }), - ]); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:create"), - threadId: sourceThreadId, - projectId, - title: "Cross-provider merge source", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:first-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:first-source"), - text: firstSourcePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:second-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:second-source"), - text: secondSourcePrompt, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork"), - sourceThreadId, - targetThreadId: forkThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Cross-provider merge fork", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork-turn"), - threadId: forkThreadId, - messageId: MessageId.make("message:cross-provider-merge:fork-turn"), - text: forkPrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.merge_back", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:merge"), - sourceThreadId: forkThreadId, - targetThreadId: sourceThreadId, - sourcePoint: { type: "latest_stable" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:consume"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:consume"), - text: mergePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - - const projection = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch(commands[0]!); - yield* orchestrator.dispatch(commands[1]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[2]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[3]!); - yield* orchestrator.dispatch(commands[4]!); - yield* waitForIdle(forkThreadId); - yield* orchestrator.dispatch(commands[5]!); - if (failResume) { - const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); - const codexThread = beforeResume.providerThreads.find( - (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, - )!; - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("unloaded-merge-target"), - type: "provider-thread.updated", - threadId: sourceThreadId, - occurredAt: yield* DateTime.now, - payload: { - ...codexThread, - nativeThreadRef: { - ...codexThread.nativeThreadRef!, - nativeId: "unloaded-native-merge-target", - }, - }, - }, - ], - }); - } - yield* orchestrator.dispatch(commands[6]!); - return yield* waitForIdle(sourceThreadId); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { - name: "cross-provider-merge", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ), - ), - ); - const turns = yield* Ref.get(capturedTurns); - const mergedTurn = turns.findLast( - (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", - ); - const mergeTransfer = projection.contextTransfers.find( - (transfer) => transfer.type === "merge_back", - ); - - if (failResume) - assert.isAtLeast( - projection.contextHandoffs.filter( - (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, - ).length, - 2, - ); - assert.isDefined(mergedTurn); - if (failResume) - assert.notEqual( - projection.providerThreads.find((thread) => thread.id === mergedTurn.providerThreadId) - ?.nativeThreadRef?.nativeId, - "unloaded-native-merge-target", - ); - assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); - assert.include(mergedTurn.text, firstSourcePrompt); - assert.include(mergedTurn.text, "I will remember amber."); - assert.include(mergedTurn.text, secondSourcePrompt); - assert.include(mergedTurn.text, "I will remember violet."); - assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); - assert.include(mergedTurn.text, forkPrompt); - assert.include(mergedTurn.text, "I will remember cobalt."); - assert.include(mergedTurn.text, mergePrompt); - assert.isDefined(mergeTransfer); - assert.equal(mergeTransfer.status, "consumed"); - assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); - assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); - }), - ), - ); - - it.live("routes two custom instances of the same driver independently", () => - Effect.scoped( - Effect.gen(function* () { - const personalThreadId = ThreadId.make("thread:custom-codex-personal"); - const workThreadId = ThreadId.make("thread:custom-codex-work"); - const personalSelection = { - instanceId: ProviderInstanceId.make("codex_personal"), - model: "gpt-5.4", - } satisfies ModelSelection; - const workSelection = { - instanceId: ProviderInstanceId.make("codex_work"), - model: "gpt-5.4", - } satisfies ModelSelection; - const cwd = yield* checkpointWorkspace("custom-codex-instances"); - const capturedTurns = yield* Ref.make>([]); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeTestAdapter({ - instanceId: personalSelection.instanceId, - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: personalSelection, - responseByRunOrdinal: { 1: "personal response" }, - capturedTurns, - }), - makeTestAdapter({ - instanceId: workSelection.instanceId, - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: workSelection, - responseByRunOrdinal: { 1: "work response" }, - capturedTurns, - }), - ]); - - const [personal, work] = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - for (const [targetThreadId, selection, suffix] of [ - [personalThreadId, personalSelection, "personal"], - [workThreadId, workSelection, "work"], - ] as const) { - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:custom-codex:${suffix}:create`), - threadId: targetThreadId, - projectId, - title: `Custom Codex ${suffix}`, - modelSelection: selection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:custom-codex:${suffix}:message`), - threadId: targetThreadId, - messageId: MessageId.make(`message:custom-codex:${suffix}`), - text: `${suffix} prompt`, - attachments: [], - modelSelection: selection, - dispatchMode: { type: "start_immediately" }, - }); - yield* waitForIdle(targetThreadId); - } - return yield* Effect.all([ - orchestrator.getThreadProjection(personalThreadId), - orchestrator.getThreadProjection(workThreadId), - ]); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { - name: "custom-codex-instances", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ), - ), - ); - - assert.equal(personal.runs[0]?.providerInstanceId, personalSelection.instanceId); - assert.equal( - personal.providerSessions[0]?.providerInstanceId, - personalSelection.instanceId, - ); - assert.equal(work.runs[0]?.providerInstanceId, workSelection.instanceId); - assert.equal(work.providerSessions[0]?.providerInstanceId, workSelection.instanceId); - assert.notEqual(personal.providerSessions[0]?.id, work.providerSessions[0]?.id); - assert.deepEqual( - (yield* Ref.get(capturedTurns)).map((turn) => [turn.threadId, turn.text]), - [ - [personalThreadId, "personal prompt"], - [workThreadId, "work prompt"], - ], - ); - }), - ), - ); }); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs deleted file mode 100644 index 6ac1f2b172bd..000000000000 --- a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs +++ /dev/null @@ -1,237 +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 withHandoff = mode.startsWith("handoff") || mode === "subagent-result"; -const require = NodeModule.createRequire(root + "/apps/server/package.json"); -const load = (name) => import(NodeURL.pathToFileURL(require.resolve("effect/" + name))); -const [Effect, Layer, FileSystem, Path] = await Promise.all([ - load("Effect"), - load("Layer"), - load("FileSystem"), - load("Path"), -]); -const app = (file) => import(NodeURL.pathToFileURL(root + "/apps/server/src/" + file + ".ts")); -const providerCore = (file) => - import(NodeURL.pathToFileURL(root + "/packages/provider-core/src/server/" + file + ".ts")); -const [ - Start, - Projection, - Run, - Sessions, - Policy, - Id, - Sink, - Handoff, - Git, - Project, - Auth, - Config, - Settings, -] = await Promise.all([ - app("orchestration-v2/ProviderTurnStartService"), - app("orchestration-v2/ProjectionStore"), - app("orchestration-v2/RunExecutionService"), - app("orchestration-v2/ProviderSessionManager"), - app("orchestration-v2/RuntimePolicy"), - providerCore("IdAllocator"), - app("orchestration-v2/EventSink"), - app("orchestration-v2/ContextHandoffService"), - app("git/GitWorkflowService"), - app("project/ProjectService"), - app("provider/ProviderAuthService"), - app("config"), - app("serverSettings"), -]); -let current; -let fullReads = 0; -let threadReads = 0; -const liveRuns = []; -const refs = []; -const checkpoints = []; -const count = Number(process.argv[4] ?? 4); -const bytes = Number(process.argv[5] ?? 32768); -const session = { - driver: "codex", - providerSession: { id: "session", driver: "codex" }, - resumeThread: ({ providerThread }) => Effect.succeed(providerThread), - startTurn: () => - mode === "handoff-failure" ? Effect.fail("Synthetic startup failure") : Effect.void, - compactThread: () => Effect.void, -}; -const dependencies = Layer.mergeAll( - Layer.mock(Handoff.ContextHandoffServiceV2)({}), - Id.layer, - FileSystem.layerNoop({}), - Path.layer, - Layer.mock(Git.GitWorkflowService)({}), - Layer.mock(Project.ProjectService)({}), - Layer.mock(Auth.ProviderAuthService)({}), - Layer.mock(Config.ServerConfig)({ worktreesDir: "/synthetic-worktrees" }), - Settings.layerTest(), - Layer.mock(Projection.ProjectionStoreV2)({ - getThreadProjection: () => - Effect.sync(() => { - fullReads++; - throw new Error("full transcript read"); - }), - hasUnpairedRunInterruptRequest: () => Effect.succeed(false), - getThreadRecords: (threadId, fields) => - Effect.sync(() => { - NodeAssert.equal(threadId, current.thread.id); - NodeAssert.deepEqual(fields, []); - threadReads++; - return { thread: current.thread }; - }), - getTurnStartContext: () => - Effect.sync(() => { - fullReads++; - return { ...current, hasConversation: true }; - }), - getTurnStartHistory: () => Effect.sync(() => current.turnItems), - getRuntimeRecoveryProjection: () => Effect.sync(() => current), - }), - Layer.mock(Sessions.ProviderSessionManagerV2)({ open: () => Effect.succeed(session) }), - Layer.mock(Policy.RuntimePolicyV2)({ - resolve: () => - Effect.succeed({ cwd: "/synthetic", interactionMode: "default", runtimeMode: "full-access" }), - }), - Layer.mock(Run.RunExecutionServiceV2)({ - startRootRun: (input) => - Effect.gen(function* () { - liveRuns.push(input); - const turnInput = { ...input, message: input.message }; - const result = yield* Effect.exit( - mode === "handoff-compact" - ? input.session.compactThread(turnInput) - : input.session.startTurn(turnInput), - ); - NodeAssert.equal(result._tag, mode === "handoff-failure" ? "Failure" : "Success"); - }), - }), - Layer.mock(Sink.EventSinkV2)({ - write: () => Effect.succeed([]), - writeIfRunCurrent: ({ events }) => - Effect.sync(() => { - for (const event of events) - if (event.type === "run.updated") current.runs = [event.payload]; - return { committed: true, storedEvents: [] }; - }), - }), -); -function fixture(index) { - const runId = "run:" + index; - const history = JSON.parse( - JSON.stringify({ - id: "historical:" + index, - threadId: "thread", - runId: null, - providerThreadId: "provider", - status: "completed", - type: "command_execution", - output: NodeCrypto.randomBytes(bytes).toString("hex"), - }), - ); - refs.push(new WeakRef(history)); - return { - thread: { id: "thread", projectId: "project", branch: null, worktreePath: null }, - runs: [ - { - id: runId, - threadId: "thread", - ordinal: 1, - providerInstanceId: "instance", - modelSelection: { instanceId: "instance", model: "synthetic" }, - providerThreadId: "provider", - userMessageId: "message", - rootNodeId: "root", - activeAttemptId: "attempt", - status: "starting", - startedAt: null, - }, - ], - nodes: [{ id: "root", checkpointScopeId: "scope" }], - attempts: [{ id: "attempt", runId, providerThreadId: "provider" }], - providerThreads: [ - { - id: "provider", - driver: "codex", - providerSessionId: "session", - nativeThreadRef: { nativeId: "native", driver: "codex", strength: "strong" }, - contextUsage: - mode === "subagent-result" - ? { usedTokens: 390458, maxTokens: 475000 } - : { usedTokens: 0, maxTokens: 128000 }, - handoffIds: [], - }, - ], - messages: [{ id: "message", text: "Continue", attachments: [] }], - checkpointScopes: [{ id: "scope" }], - contextHandoffs: withHandoff - ? [ - { - id: "handoff", - transferId: mode === "subagent-result" ? "subagent-transfer" : null, - threadId: "thread", - targetRunId: runId, - toProviderThreadId: "provider", - fromProviderThreadIds: ["provider"], - coveredRunOrdinals: { from: 1, to: 1 }, - status: "ready", - strategy: mode === "subagent-result" ? "manual_context" : "full_thread_summary", - summaryText: "Synthetic summary", - handoffIds: [], - }, - ] - : [], - contextTransfers: - mode === "subagent-result" ? [{ id: "subagent-transfer", type: "subagent_result" }] : [], - providerSessions: [], - providerTurns: [], - subagents: [], - turnItems: [history], - }; -} -await Effect.runPromise( - Effect.gen(function* () { - const service = yield* Start.ProviderTurnStartServiceV2; - for (let i = 0; i < count; i++) { - current = fixture(i); - yield* service.start({ threadId: "thread", runId: "run:" + i }); - const controls = liveRuns.at(-1); - current = { ...current, turnItems: [] }; - NodeAssert.equal(yield* controls.shouldStartProviderTurn(), true); - NodeAssert.equal(yield* controls.shouldFinalizeRun(), true); - const liveRun = current.runs[0]; - current = { ...current, runs: [{ ...liveRun, activeAttemptId: "replacement" }] }; - NodeAssert.equal(yield* controls.shouldStartProviderTurn(), false); - NodeAssert.equal(yield* controls.shouldFinalizeRun(), false); - current = { ...current, runs: [{ ...liveRun, status: "completed" }] }; - NodeAssert.equal(yield* controls.shouldFinalizeRun(), false); - NodeAssert.deepEqual(yield* controls.loadInheritedBackgroundTurnItems(), []); - NodeAssert.equal(fullReads, i + 1); - NodeAssert.equal(threadReads, i + 1); - NodeAssert.equal(yield* controls.hasUnpairedRunInterruptRequest(), false); - NodeAssert.equal(fullReads, i + 1); - NodeAssert.equal(threadReads, i + 1); - current = null; - 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({ - runs: liveRuns.length, - payloadMiB: ((i + 1) * bytes * 2) / 1048576, - retained: refs.filter((ref) => ref.deref()).length, - heapMiB: Math.round(process.memoryUsage().heapUsed / 1048576), - }); - }); - } - } - }).pipe(Effect.provide(Start.layer.pipe(Layer.provide(dependencies)))), -); -console.log(JSON.stringify({ checkpoints })); diff --git a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts index 3f1c1af63681..d90886835a23 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts @@ -41,7 +41,6 @@ const CLAUDE_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", } as const; -const TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native/codex_transcript.ndjson`; const PRIOR_TURN_TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson`; const CLAUDE_TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native/claude_transcript.ndjson`; const CLAUDE_PRIOR_TURN_TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson`; @@ -100,7 +99,7 @@ const makeCheckpointWorkspace = Effect.gen(function* () { return cwd; }); -function readTranscript(transcriptPath: string = TRANSCRIPT_PATH) { +function readTranscript(transcriptPath: string) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const text = yield* fs.readFileString(transcriptPath); @@ -139,185 +138,6 @@ function userAndAssistantText( } describe("orchestration V2 thread fork", () => { - it.effect( - "creates an idle app fork and resolves it with Codex native thread/fork on first dispatch", - () => - Effect.gen(function* () { - const rawTranscript = yield* readTranscript(); - const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) => - Effect.service(FileSystem.FileSystem).pipe( - Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })), - Effect.orDie, - ), - ); - const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( - materializeReplayTranscriptWorkspace(rawTranscript, cwd), - ); - - const materialized = yield* Effect.gen(function* () { - const ids = yield* IdAllocator.IdAllocatorV2; - const projectId = yield* ids.allocate.project({ fixtureName: "thread-fork-native" }); - const sourceThreadId = yield* ids.allocate.thread({ - fixtureName: "thread-fork-native-source", - projectId, - }); - const targetThreadId = ThreadId.make("thread-fork-native-target"); - - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native", - commandName: "thread-create-source", - }), - threadId: sourceThreadId, - projectId, - title: "Source thread", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native", - commandName: "source-message", - }), - threadId: sourceThreadId, - messageId: MessageId.make("message-thread-fork-native-source"), - text: THREAD_FORK_NATIVE_SOURCE_PROMPT, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command-thread-fork-native"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Forked thread", - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command-thread-fork-native"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Forked thread", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native", - commandName: "target-message", - }), - threadId: targetThreadId, - messageId: MessageId.make("message-thread-fork-native-target"), - text: THREAD_FORK_NATIVE_TARGET_PROMPT, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - - return { - sourceThreadId, - targetThreadId, - commands, - }; - }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); - - const result = yield* runOrchestratorV2ProviderReplayScenario( - { - name: "thread_fork_native/codex", - transcript, - commands: materialized.commands, - steps: [ - { type: "dispatch", command: materialized.commands[0]!, await: true }, - { type: "advance_clock", duration: "1 millis" }, - { type: "dispatch", command: materialized.commands[1]!, await: true }, - { type: "await_thread_idle", threadId: materialized.sourceThreadId }, - { type: "dispatch", command: materialized.commands[2]!, await: true }, - { type: "dispatch", command: materialized.commands[3]!, await: true }, - { type: "dispatch", command: materialized.commands[4]!, await: true }, - { type: "await_thread_idle", threadId: materialized.targetThreadId }, - ], - projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId], - runtimePolicyOverride: { cwd }, - }, - CodexOrchestratorReplayHarness, - ).pipe(provideDeterministicTestRuntime); - - const sourceProjection = result.projections.get(materialized.sourceThreadId); - const targetProjection = result.projections.get(materialized.targetThreadId); - assert.isDefined(sourceProjection); - assert.isDefined(targetProjection); - assert.equal(targetProjection.thread.lineage.parentThreadId, materialized.sourceThreadId); - assert.equal(targetProjection.thread.lineage.relationshipToParent, "fork"); - assert.lengthOf(targetProjection.providerSessions, 1); - assert.lengthOf(targetProjection.providerThreads, 1); - assert.equal( - targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId, - "native-fork-thread", - ); - assert.equal( - targetProjection.providerThreads[0]?.forkedFrom?.providerThreadId, - sourceProjection.providerThreads[0]?.id, - ); - - const transfers = targetProjection.contextTransfers.filter( - (transfer) => transfer.targetThreadId === materialized.targetThreadId, - ); - assert.lengthOf(transfers, 1); - assert.equal(transfers[0]?.status, "consumed"); - assert.equal(transfers[0]?.resolution?.strategy, "native_fork"); - assert.isUndefined(sourceProjection.providerTurns[0]?.tokenUsage); - assert.isUndefined(transfers[0]?.sourceTokenUsage); - assert.equal(transfers[0]?.targetRunId, targetProjection.runs[0]?.id); - - const transferCreatedIndex = result.domainEvents.findIndex( - (event) => event.type === "context-transfer.created", - ); - const targetProviderSessionIndex = result.domainEvents.findIndex( - (event) => - event.threadId === materialized.targetThreadId && - event.type === "provider-session.updated", - ); - const targetRunCreatedIndex = result.domainEvents.findIndex( - (event) => event.threadId === materialized.targetThreadId && event.type === "run.created", - ); - assert.isAtLeast(transferCreatedIndex, 0); - assert.isAbove(targetProviderSessionIndex, transferCreatedIndex); - assert.isAbove( - targetProviderSessionIndex, - targetRunCreatedIndex, - "provider runtime state must be materialized only after the target run commits", - ); - - const forkEvents = result.domainEvents.filter( - (event) => event.type === "context-transfer.created", - ); - assert.lengthOf( - forkEvents, - 1, - "duplicate fork command must return the receipt without creating another transfer", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect( "creates an idle app fork and resolves it with Claude native session fork on first dispatch", () => @@ -640,159 +460,6 @@ describe("orchestration V2 thread fork", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("forks a Claude native session from an earlier completed source turn", () => - Effect.gen(function* () { - const rawTranscript = yield* readTranscript(CLAUDE_PRIOR_TURN_TRANSCRIPT_PATH); - const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript(rawTranscript); - const forkedNativeSessionId = metadataString(transcript, "forkedNativeSessionId"); - const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) => - Effect.service(FileSystem.FileSystem).pipe( - Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })), - Effect.orDie, - ), - ); - - const materialized = yield* Effect.gen(function* () { - const ids = yield* IdAllocator.IdAllocatorV2; - const projectId = yield* ids.allocate.project({ - fixtureName: "thread-fork-native-prior-turn", - }); - const sourceThreadId = yield* ids.allocate.thread({ - fixtureName: "thread-fork-native-prior-turn-source", - projectId, - }); - const targetThreadId = ThreadId.make("thread-fork-native-prior-turn-target"); - const firstRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 1 }); - - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "thread-create-source", - }), - threadId: sourceThreadId, - projectId, - title: "Source thread", - modelSelection: CLAUDE_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "source-message-alpha", - }), - threadId: sourceThreadId, - messageId: MessageId.make("message-thread-fork-native-prior-turn-alpha"), - text: THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "source-message-beta", - }), - threadId: sourceThreadId, - messageId: MessageId.make("message-thread-fork-native-prior-turn-beta"), - text: THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command-thread-fork-native-prior-turn"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "run", runId: firstRunId }, - title: "Forked from first response", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "target-message-repeat", - }), - threadId: targetThreadId, - messageId: MessageId.make("message-thread-fork-native-prior-turn-repeat"), - text: THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - - return { - sourceThreadId, - targetThreadId, - commands, - }; - }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); - - const result = yield* runOrchestratorV2ProviderReplayScenario( - { - name: "thread_fork_native_prior_turn/claude", - transcript, - commands: materialized.commands, - steps: [ - { type: "dispatch", command: materialized.commands[0]!, await: true }, - { type: "advance_clock", duration: "1 millis" }, - { type: "dispatch", command: materialized.commands[1]!, await: true }, - { type: "await_thread_idle", threadId: materialized.sourceThreadId }, - { type: "dispatch", command: materialized.commands[2]!, await: true }, - { type: "await_thread_idle", threadId: materialized.sourceThreadId }, - { type: "dispatch", command: materialized.commands[3]!, await: true }, - { type: "dispatch", command: materialized.commands[4]!, await: true }, - { type: "await_thread_idle", threadId: materialized.targetThreadId }, - ], - projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId], - runtimePolicyOverride: { cwd }, - }, - ClaudeOrchestratorReplayHarness, - ).pipe(provideDeterministicTestRuntime); - - const targetProjection = result.projections.get(materialized.targetThreadId); - assert.isDefined(targetProjection); - assert.equal( - targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId, - forkedNativeSessionId, - ); - const targetAssistantText = targetProjection.turnItems - .filter((item) => item.type === "assistant_message") - .map((item) => item.text) - .join("\n"); - assert.include(targetAssistantText, "fork boundary alpha"); - assert.notInclude( - targetAssistantText, - "fork boundary beta", - "forking from the first source run must not preserve later source turns in native Claude context", - ); - assert.equal(targetProjection.contextTransfers[0]?.resolution?.strategy, "native_fork"); - const sourceProjection = result.projections.get(materialized.sourceThreadId); - const sourceUsage = sourceProjection?.providerTurns[0]?.tokenUsage; - assert.isDefined(sourceUsage); - assert.notDeepEqual(sourceUsage, sourceProjection?.providerTurns[1]?.tokenUsage); - assert.deepEqual(targetProjection.contextTransfers[0]?.sourceTokenUsage, sourceUsage); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("keeps a Claude native fork stable when the source thread rolls back", () => Effect.gen(function* () { const rawTranscript = yield* readTranscript(CLAUDE_PRIOR_TURN_TRANSCRIPT_PATH); diff --git a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts index 058841fd8524..836e06e74fbd 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts @@ -23,10 +23,6 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { CODEX_MODEL_SELECTION, - THREAD_MERGE_BACK_FORK_PROMPT, - THREAD_MERGE_BACK_HANDOFF_PROMPT, - THREAD_MERGE_BACK_RECALL, - THREAD_MERGE_BACK_RECALL_PROMPT, THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT, THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT, THREAD_MERGE_BACK_SIBLINGS_RECALL, @@ -34,7 +30,6 @@ import { THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT, THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT, THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT, - THREAD_MERGE_BACK_SOURCE_PROMPT, } from "./fixtures/shared.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; @@ -68,7 +63,6 @@ const CodexHistoryReplayHarness: typeof CodexOrchestratorReplayHarness = { assert.lengthOf(userTexts, 1); assert.include( [ - THREAD_MERGE_BACK_FORK_PROMPT, THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT, THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT, ], @@ -99,8 +93,6 @@ const CLAUDE_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", } as const; -const MERGE_BACK_USER_TEXT = - "Retain the transferred fork marker for later. Respond with exactly: merge delta stored"; const FIRST_SIBLING_MERGE_USER_TEXT = "Retain the first transferred marker for later. Respond with exactly: first merge delta stored"; const SECOND_SIBLING_MERGE_USER_TEXT = @@ -254,204 +246,6 @@ function makeCreateCommand(input: { } describe("orchestration V2 merge-back provider replay", () => { - it.effect.each(PROVIDERS)( - "merges one fork delta back into the original $driver thread", - (variant) => - Effect.gen(function* () { - const rawTranscript = yield* readTranscript("thread_merge_back_continue", variant.driver); - const materialized = yield* Effect.gen(function* () { - const ids = yield* IdAllocator.IdAllocatorV2; - const projectId = yield* ids.allocate.project({ - fixtureName: `thread-merge-back-${variant.driver}`, - }); - const sourceThreadId = yield* ids.allocate.thread({ - fixtureName: `thread-merge-back-${variant.driver}-source`, - projectId, - }); - const forkThreadId = ThreadId.make(`thread-merge-back-${variant.driver}-fork`); - const forkRunId = ids.derive.run({ threadId: forkThreadId, ordinal: 1 }); - const commands = [ - makeCreateCommand({ - commandId: yield* ids.allocate.command({ - fixtureName: `thread-merge-back-${variant.driver}`, - commandName: "create", - }), - threadId: sourceThreadId, - projectId, - modelSelection: variant.modelSelection, - }), - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: `thread-merge-back-${variant.driver}`, - commandName: "source", - }), - threadId: sourceThreadId, - messageId: MessageId.make(`message-merge-source-${variant.driver}`), - text: THREAD_MERGE_BACK_SOURCE_PROMPT, - attachments: [], - modelSelection: variant.modelSelection, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command-merge-fork-${variant.driver}`), - sourceThreadId, - targetThreadId: forkThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Merge-back fork", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: `thread-merge-back-${variant.driver}`, - commandName: "fork-delta", - }), - threadId: forkThreadId, - messageId: MessageId.make(`message-merge-fork-${variant.driver}`), - text: THREAD_MERGE_BACK_FORK_PROMPT, - attachments: [], - modelSelection: variant.modelSelection, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.merge_back", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command-merge-back-${variant.driver}`), - sourceThreadId: forkThreadId, - targetThreadId: sourceThreadId, - sourcePoint: { type: "run", runId: forkRunId }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: `thread-merge-back-${variant.driver}`, - commandName: "consume-merge", - }), - threadId: sourceThreadId, - messageId: MessageId.make(`message-consume-merge-${variant.driver}`), - text: MERGE_BACK_USER_TEXT, - attachments: [], - modelSelection: variant.modelSelection, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: `thread-merge-back-${variant.driver}`, - commandName: "recall", - }), - threadId: sourceThreadId, - messageId: MessageId.make(`message-merge-recall-${variant.driver}`), - text: THREAD_MERGE_BACK_RECALL_PROMPT, - attachments: [], - modelSelection: variant.modelSelection, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; - return { commands, sourceThreadId, forkThreadId, forkRunId }; - }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); - const summary = forkDeltaSummary({ - sourceThreadId: materialized.forkThreadId, - targetThreadId: materialized.sourceThreadId, - forkUserText: THREAD_MERGE_BACK_FORK_PROMPT, - forkAssistantText: "merge fork stored", - }); - const generatedProviderMessage = providerMessage(summary, MERGE_BACK_USER_TEXT); - const parameterizedTranscript = parameterizeHandoffs(rawTranscript, [ - [ - THREAD_MERGE_BACK_HANDOFF_PROMPT, - variant.driver === "codex" ? MERGE_BACK_USER_TEXT : generatedProviderMessage, - ], - ]); - const cwd = yield* Effect.acquireRelease( - Effect.promise(() => makeCheckpointWorkspace(`merge-back-${variant.driver}`)), - (directory) => - Effect.service(FileSystem.FileSystem).pipe( - Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })), - Effect.orDie, - ), - ); - const scenario = { - name: `thread_merge_back_continue/${variant.driver}`, - commands: materialized.commands, - steps: [ - { type: "dispatch" as const, command: materialized.commands[0]!, await: true }, - { type: "advance_clock" as const, duration: "1 millis" as const }, - { type: "dispatch" as const, command: materialized.commands[1]!, await: true }, - { type: "await_thread_idle" as const, threadId: materialized.sourceThreadId }, - { type: "dispatch" as const, command: materialized.commands[2]!, await: true }, - { type: "dispatch" as const, command: materialized.commands[3]!, await: true }, - { type: "await_thread_idle" as const, threadId: materialized.forkThreadId }, - { type: "dispatch" as const, command: materialized.commands[4]!, await: true }, - { type: "dispatch" as const, command: materialized.commands[5]!, await: true }, - { type: "await_thread_idle" as const, threadId: materialized.sourceThreadId }, - { type: "dispatch" as const, command: materialized.commands[6]!, await: true }, - { type: "await_thread_idle" as const, threadId: materialized.sourceThreadId }, - ], - projectionThreadIds: [materialized.sourceThreadId, materialized.forkThreadId], - runtimePolicyOverride: { cwd }, - }; - const result = - variant.driver === "codex" - ? yield* runOrchestratorV2ProviderReplayScenario( - { - ...scenario, - transcript: yield* CodexOrchestratorReplayHarness.decodeTranscript( - materializeReplayTranscriptWorkspace(parameterizedTranscript, cwd), - ), - }, - CodexHistoryReplayHarness, - ).pipe(provideDeterministicTestRuntime) - : yield* runOrchestratorV2ProviderReplayScenario( - { - ...scenario, - transcript: - yield* ClaudeOrchestratorReplayHarness.decodeTranscript( - parameterizedTranscript, - ), - }, - ClaudeOrchestratorReplayHarness, - ).pipe(provideDeterministicTestRuntime); - - const source = result.projections.get(materialized.sourceThreadId); - const fork = result.projections.get(materialized.forkThreadId); - assert.isDefined(source); - assert.isDefined(fork); - assert.equal( - source.providerThreads[0]?.nativeThreadRef?.nativeId, - nativeSourceThreadId(rawTranscript), - ); - assert.lengthOf(source.contextHandoffs, 1); - assert.equal(source.contextHandoffs[0]?.summaryText, summary); - assert.equal(source.contextHandoffs[0]?.strategy, "fork_delta_summary"); - const mergeTransfer = source.contextTransfers.find( - (transfer) => transfer.type === "merge_back", - ); - assert.isDefined(mergeTransfer); - assert.equal(mergeTransfer.status, "consumed"); - assert.equal(mergeTransfer.sourcePoint.runId, materialized.forkRunId); - assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); - assert.include( - normalizePipeSpacing(visibleConversationText(source)), - THREAD_MERGE_BACK_RECALL, - ); - assert.notInclude(visibleConversationText(source), "Context handoff ("); - assert.include(visibleConversationText(fork), "merge fork stored"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect.each(PROVIDERS)( "merges two sibling fork deltas into the original $driver thread", (variant) => diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 92610808b10e..5fd4d3590cc6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -117,9 +117,8 @@ export const THREAD_ROLLBACK_AFTER_PROMPT = "Repeat the conversation verbatim."; export const THREAD_FORK_NATIVE_SOURCE_PROMPT = "Respond with the following text: source fork seed ok"; export const THREAD_FORK_NATIVE_TARGET_PROMPT = "Respond with the following text: fork native ok"; -export const THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER = "source-marker-7Q9V"; -export const THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER = "fork-marker-2K4M"; -export const THREAD_FORK_NATIVE_CONTINUE_RECALL = `${THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER}|${THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER}`; +const THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER = "source-marker-7Q9V"; +const THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER = "fork-marker-2K4M"; export const THREAD_FORK_NATIVE_CONTINUE_SOURCE_PROMPT = `Remember the opaque marker ${THREAD_FORK_NATIVE_CONTINUE_SOURCE_MARKER} for later in this conversation. Respond with exactly: source marker stored`; export const THREAD_FORK_NATIVE_CONTINUE_FIRST_PROMPT = `Remember the second opaque marker ${THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER} for later in this conversation. Respond with exactly: fork marker stored`; export const THREAD_FORK_NATIVE_CONTINUE_SECOND_PROMPT = @@ -130,8 +129,8 @@ const THREAD_FORK_NATIVE_SIBLINGS_SECOND_MARKER = "sibling-second-9N6C"; export const THREAD_FORK_NATIVE_SIBLINGS_SOURCE_PROMPT = `Remember the opaque marker ${THREAD_FORK_NATIVE_SIBLINGS_SOURCE_MARKER} for later in this conversation. Respond with exactly: sibling source stored`; export const THREAD_FORK_NATIVE_SIBLINGS_FIRST_PROMPT = `Remember the fork-local marker ${THREAD_FORK_NATIVE_SIBLINGS_FIRST_MARKER}. Return the source marker followed by this marker, separated by |. Respond with only the markers and separator.`; export const THREAD_FORK_NATIVE_SIBLINGS_SECOND_PROMPT = `Remember the fork-local marker ${THREAD_FORK_NATIVE_SIBLINGS_SECOND_MARKER}. Return the source marker followed by this marker, separated by |. Respond with only the markers and separator.`; -export const THREAD_MERGE_BACK_SOURCE_MARKER = "merge-source-4H8Q"; -export const THREAD_MERGE_BACK_FORK_MARKER = "merge-fork-7T2W"; +const THREAD_MERGE_BACK_SOURCE_MARKER = "merge-source-4H8Q"; +const THREAD_MERGE_BACK_FORK_MARKER = "merge-fork-7T2W"; export const THREAD_MERGE_BACK_SOURCE_PROMPT = `Remember the opaque marker ${THREAD_MERGE_BACK_SOURCE_MARKER} for later in this conversation. Respond with exactly: merge source stored`; export const THREAD_MERGE_BACK_FORK_PROMPT = `Remember the fork-local marker ${THREAD_MERGE_BACK_FORK_MARKER}. Respond with exactly: merge fork stored`; export const THREAD_MERGE_BACK_HANDOFF_PROMPT = [ @@ -145,12 +144,11 @@ export const THREAD_MERGE_BACK_HANDOFF_PROMPT = [ "User message:", "Retain the transferred fork marker for later. Respond with exactly: merge delta stored", ].join("\n"); -export const THREAD_MERGE_BACK_RECALL = `${THREAD_MERGE_BACK_SOURCE_MARKER}|${THREAD_MERGE_BACK_FORK_MARKER}`; export const THREAD_MERGE_BACK_RECALL_PROMPT = "Return the source marker followed by the transferred fork marker, separated by a single | character. Respond with only the markers and separator."; -export const THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER = "merge-sibling-source-3C7K"; -export const THREAD_MERGE_BACK_SIBLINGS_FIRST_MARKER = "merge-sibling-first-6V2J"; -export const THREAD_MERGE_BACK_SIBLINGS_SECOND_MARKER = "merge-sibling-second-9X5B"; +const THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER = "merge-sibling-source-3C7K"; +const THREAD_MERGE_BACK_SIBLINGS_FIRST_MARKER = "merge-sibling-first-6V2J"; +const THREAD_MERGE_BACK_SIBLINGS_SECOND_MARKER = "merge-sibling-second-9X5B"; export const THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT = `Remember the opaque marker ${THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER} for later in this conversation. Respond with exactly: merge sibling source stored`; export const THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT = `Remember the fork-local marker ${THREAD_MERGE_BACK_SIBLINGS_FIRST_MARKER}. Respond with exactly: first merge sibling stored`; export const THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT = `Remember the fork-local marker ${THREAD_MERGE_BACK_SIBLINGS_SECOND_MARKER}. Respond with exactly: second merge sibling stored`; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson deleted file mode 100644 index 2e2c60fdcd9d..000000000000 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson +++ /dev/null @@ -1,21 +0,0 @@ -{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn.","normalization":"The thread/fork expected request gains lastTurnId (inclusive native boundary) to match the V2 adapter's native-boundary fork."}} -{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} -{"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}} -{"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} -{"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000}}}} -{"type":"emit_inbound","label":"thread/start/source","frame":{"id":2,"result":{"thread":{"id":"native-source-thread","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739349,"updatedAt":1776739349,"status":{"type":"idle"},"path":"/tmp/source.jsonl","cwd":"/tmp/project","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/tmp/project","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} -{"type":"expect_outbound","label":"turn/start/source","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Respond with the following text: source fork seed ok","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"native-source-thread","approvalsReviewer":"user","summary":"detailed"}}} -{"type":"emit_inbound","label":"turn/start/source","frame":{"id":3,"result":{"turn":{"id":"native-source-turn","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"turn/started/source","frame":{"method":"turn/started","params":{"threadId":"native-source-thread","turn":{"id":"native-source-turn","items":[],"status":"inProgress","error":null,"startedAt":1776739349,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"item/completed/source-user","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"native-source-user-item","content":[{"type":"text","text":"Respond with the following text: source fork seed ok","text_elements":[]}]},"threadId":"native-source-thread","turnId":"native-source-turn"}}} -{"type":"emit_inbound","label":"item/completed/source-agent","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"native-source-agent-item","text":"source fork seed ok","phase":"final_answer","memoryCitation":null},"threadId":"native-source-thread","turnId":"native-source-turn"}}} -{"type":"emit_inbound","label":"turn/completed/source","frame":{"method":"turn/completed","params":{"threadId":"native-source-thread","turn":{"id":"native-source-turn","items":[],"status":"completed","error":null,"startedAt":1776739349,"completedAt":1776739356,"durationMs":6434}}}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"threadId":"native-source-thread","lastTurnId":"native-source-turn","config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000}}}} -{"type":"emit_inbound","label":"thread/fork","frame":{"id":4,"result":{"thread":{"id":"native-fork-thread","forkedFromId":"native-source-thread","preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739360,"updatedAt":1776739360,"status":{"type":"idle"},"path":"/tmp/fork.jsonl","cwd":"/tmp/project","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/tmp/project","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} -{"type":"expect_outbound","label":"turn/start/fork","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Respond with the following text: fork native ok","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"native-fork-thread","approvalsReviewer":"user","summary":"detailed"}}} -{"type":"emit_inbound","label":"turn/start/fork","frame":{"id":5,"result":{"turn":{"id":"native-fork-turn","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"turn/started/fork","frame":{"method":"turn/started","params":{"threadId":"native-fork-thread","turn":{"id":"native-fork-turn","items":[],"status":"inProgress","error":null,"startedAt":1776739361,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"item/completed/fork-user","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"native-fork-user-item","content":[{"type":"text","text":"Respond with the following text: fork native ok","text_elements":[]}]},"threadId":"native-fork-thread","turnId":"native-fork-turn"}}} -{"type":"emit_inbound","label":"item/completed/fork-agent","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"native-fork-agent-item","text":"fork native ok","phase":"final_answer","memoryCitation":null},"threadId":"native-fork-thread","turnId":"native-fork-turn"}}} -{"type":"emit_inbound","label":"turn/completed/fork","frame":{"method":"turn/completed","params":{"threadId":"native-fork-thread","turn":{"id":"native-fork-turn","items":[],"status":"completed","error":null,"startedAt":1776739361,"completedAt":1776739363,"durationMs":2000}}}} -{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/README.md b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/README.md deleted file mode 100644 index cf2a76e4526f..000000000000 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Merge-Back Continuation Fixture - -This fixture records a native fork, a fork-local delta, and an app-style -merge-back handoff consumed by the original provider thread. A later source -turn recalls both markers without receiving either marker in its prompt. - -Both provider transcripts execute the same graph. - -## Conversation Graph - -```text -Source provider thread -| -+-- Turn 1 -| User: remember merge-source-4H8Q -| Assistant: merge source stored -| -+-- native fork -| | -| `-- Forked provider thread -| `-- Turn 2 -| User: remember merge-fork-7T2W -| Assistant: merge fork stored -| -`-- original source provider thread - | - +-- Turn 3 - | App input: merge_back handoff containing merge-fork-7T2W - | Assistant: merge delta stored - | - `-- Turn 4 - User: recall source and transferred markers without restating them - Assistant: merge-source-4H8Q|merge-fork-7T2W -``` - -## Assertions - -- The fork uses a different native provider thread or session. -- The handoff and recall turns continue the original source provider thread. -- The recall prompt contains neither marker. -- The final response recalls both source and transferred fork context. - -The transcript records the provider-facing handoff format. The app-level -`thread.merge_back` command and context-transfer lifecycle remain covered by -the orchestrator integration tests. diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/claude_transcript.ndjson deleted file mode 100644 index 4ed256d65dce..000000000000 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/claude_transcript.ndjson +++ /dev/null @@ -1,34 +0,0 @@ -{"type":"transcript_start","provider":"claudeAgent","protocol":"claude-agent-sdk.query","version":"0.2.111","scenario":"thread_merge_back_continue","metadata":{"prompts":["Remember the opaque marker merge-source-4H8Q for later in this conversation. Respond with exactly: merge source stored","Remember the fork-local marker merge-fork-7T2W. Respond with exactly: merge fork stored","Context handoff (merge_back / fork_delta_summary):\nMerge-back context from forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-fork-7T2W.\n- Assistant confirmed: merge fork stored\n\nUser message:\nRetain the transferred fork marker for later. Respond with exactly: merge delta stored","Return the source marker followed by the transferred fork marker, separated by a single | character. Respond with only the markers and separator."],"model":"claude-sonnet-4-6","nativeSessionId":"79522bc8-3de1-41c4-a2fd-902246ef1290","queryMode":"fork_session_merge_back","tools":"claude_code","permissionMode":"bypassPermissions","generatedBy":"recordClaudeAgentSdkReplayTranscript","sourceAssistantMessageUuids":["a4647f9c-80c4-458e-be3a-b15e5105fa4f"],"forkUpToMessageId":"a4647f9c-80c4-458e-be3a-b15e5105fa4f","forkedNativeSessionId":"8baac83f-471d-4d65-9091-2e7e66557251","forkedNativeSessionIds":["8baac83f-471d-4d65-9091-2e7e66557251"],"sourceContinuationPromptCount":2}} -{"type":"expect_outbound","label":"query.open:source","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"sessionId":"79522bc8-3de1-41c4-a2fd-902246ef1290","settings":{"showThinkingSummaries":true}}}} -{"type":"expect_outbound","label":"prompt.offer:1","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Remember the opaque marker merge-source-4H8Q for later in this conversation. Respond with exactly: merge source stored"},"parent_tool_use_id":null}}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"hook_started","hook_id":"ca138fb7-425e-49c2-81c3-74bba4794caa","hook_name":"SessionStart:startup","hook_event":"SessionStart","uuid":"8683c9cc-5740-4f87-8692-4f475d2ba357","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290"}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"hook_response","hook_id":"ca138fb7-425e-49c2-81c3-74bba4794caa","hook_name":"SessionStart:startup","hook_event":"SessionStart","output":"","stdout":"","stderr":"","exit_code":0,"outcome":"success","uuid":"98fb5ba2-d12a-40e6-b114-26da116f7360","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290"}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"init","agents":[],"apiKeySource":"none","claude_code_version":"2.1.159","cwd":"/tmp/claude-replay-thread_merge_back_continue","tools":[],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":[],"output_style":"default","skills":[],"plugins":[],"fast_mode_state":"off","uuid":"2a2e49f2-27b0-40b0-b1ac-7367b908dcb8","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290"}} -{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01MNXMZ2663k7u7x7qvgyrpx","type":"message","role":"assistant","content":[{"type":"text","text":"merge source stored"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":18977,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290","uuid":"a4647f9c-80c4-458e-be3a-b15e5105fa4f","request_id":"req_011CbrhadoaKaXmYQpTYNbHv"}} -{"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"41517fd7-973f-44ed-86e1-779bac6e6ab2","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290"}} -{"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2584,"duration_api_ms":1954,"ttft_ms":1885,"num_turns":1,"result":"merge source stored","stop_reason":"end_turn","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290","total_cost_usd":0.0057921000000000005,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":18977,"output_tokens":6,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":3,"output_tokens":6,"cache_read_input_tokens":18977,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":3,"outputTokens":6,"cacheReadInputTokens":18977,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0057921000000000005,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"ea85fb12-2387-44cf-b6c9-b62e2fc48696"}} -{"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"session.fork","frame":{"type":"session.fork","sessionId":"79522bc8-3de1-41c4-a2fd-902246ef1290","options":{"dir":"/tmp/claude-replay-thread_merge_back_continue","upToMessageId":"a4647f9c-80c4-458e-be3a-b15e5105fa4f"}}} -{"type":"emit_inbound","label":"session.forked","frame":{"type":"session.forked","sessionId":"8baac83f-471d-4d65-9091-2e7e66557251"}} -{"type":"expect_outbound","label":"query.open:fork","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"resume":"8baac83f-471d-4d65-9091-2e7e66557251","settings":{"showThinkingSummaries":true}}}} -{"type":"expect_outbound","label":"prompt.offer:2","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Remember the fork-local marker merge-fork-7T2W. Respond with exactly: merge fork stored"},"parent_tool_use_id":null}}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"hook_started","hook_id":"a2cb0c98-569d-4b16-9e71-937c6f38bfb2","hook_name":"SessionStart:resume","hook_event":"SessionStart","uuid":"8786da30-2c27-4fa8-aa71-ff7fecaa37f8","session_id":"350f8022-de90-440c-b32e-c559a60e7383"}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"hook_response","hook_id":"a2cb0c98-569d-4b16-9e71-937c6f38bfb2","hook_name":"SessionStart:resume","hook_event":"SessionStart","output":"","stdout":"","stderr":"","exit_code":0,"outcome":"success","uuid":"8da14dd3-d942-4ed4-a80d-a5eaf5c39d50","session_id":"350f8022-de90-440c-b32e-c559a60e7383"}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"init","agents":[],"apiKeySource":"none","claude_code_version":"2.1.159","cwd":"/tmp/claude-replay-thread_merge_back_continue","tools":[],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":[],"output_style":"default","skills":[],"plugins":[],"fast_mode_state":"off","uuid":"a3110ea8-1198-4a03-a8df-670c78523fd4","session_id":"8baac83f-471d-4d65-9091-2e7e66557251"}} -{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01BxoJ9bgjaXn24qSchFoFnQ","type":"message","role":"assistant","content":[{"type":"text","text":"merge fork stored"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":19009,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"8baac83f-471d-4d65-9091-2e7e66557251","uuid":"e2c1b56e-9ade-44ab-9087-bf293eb3aeaf","request_id":"req_011CbrhauAJJxiGXkMuAb2aA"}} -{"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"0ef1ea39-53df-4c70-81ac-ae4e11960937","session_id":"8baac83f-471d-4d65-9091-2e7e66557251"}} -{"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2531,"duration_api_ms":1866,"ttft_ms":2102,"num_turns":1,"result":"merge fork stored","stop_reason":"end_turn","session_id":"8baac83f-471d-4d65-9091-2e7e66557251","total_cost_usd":0.005801700000000001,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":19009,"output_tokens":6,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":3,"output_tokens":6,"cache_read_input_tokens":19009,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":3,"outputTokens":6,"cacheReadInputTokens":19009,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.005801700000000001,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"e22495dc-0ab1-4e99-bd90-3d2ac8295c6f"}} -{"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"query.open:source-continuation","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"resume":"79522bc8-3de1-41c4-a2fd-902246ef1290","settings":{"showThinkingSummaries":true}}}} -{"type":"expect_outbound","label":"prompt.offer:3","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-fork-7T2W.\n- Assistant confirmed: merge fork stored\n\nUser message:\nRetain the transferred fork marker for later. Respond with exactly: merge delta stored"},"parent_tool_use_id":null}}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"hook_started","hook_id":"5e475fe2-aef6-4282-b1d8-334acfa4c4cd","hook_name":"SessionStart:resume","hook_event":"SessionStart","uuid":"9888cede-fed1-4dc7-b75c-0ce156547e70","session_id":"49021892-2f16-4f92-98ee-1d2943382082"}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"hook_response","hook_id":"5e475fe2-aef6-4282-b1d8-334acfa4c4cd","hook_name":"SessionStart:resume","hook_event":"SessionStart","output":"","stdout":"","stderr":"","exit_code":0,"outcome":"success","uuid":"3faffda5-2087-4eb1-bdb9-27bfad62b5f1","session_id":"49021892-2f16-4f92-98ee-1d2943382082"}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"init","agents":[],"apiKeySource":"none","claude_code_version":"2.1.159","cwd":"/tmp/claude-replay-thread_merge_back_continue","tools":[],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":[],"output_style":"default","skills":[],"plugins":[],"fast_mode_state":"off","uuid":"370aeede-59f6-4d9c-a0f5-a4810fec3651","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290"}} -{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01QywCNnUUGSBbcW4HG5Fbj6","type":"message","role":"assistant","content":[{"type":"text","text":"merge delta stored"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":84,"cache_read_input_tokens":18977,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":84},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290","uuid":"7f1e76ac-a80e-48e5-8a5e-d54099bb68e2","request_id":"req_011CbrhbAZkbickYjaYkr1MU"}} -{"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"c31657df-4b5d-4b81-b1ae-c2da7df3c68a","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290"}} -{"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2399,"duration_api_ms":1773,"ttft_ms":2000,"num_turns":1,"result":"merge delta stored","stop_reason":"end_turn","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290","total_cost_usd":0.006107100000000001,"usage":{"input_tokens":3,"cache_creation_input_tokens":84,"cache_read_input_tokens":18977,"output_tokens":6,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":84,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":3,"output_tokens":6,"cache_read_input_tokens":18977,"cache_creation_input_tokens":84,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":84},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":3,"outputTokens":6,"cacheReadInputTokens":18977,"cacheCreationInputTokens":84,"webSearchRequests":0,"costUSD":0.006107100000000001,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"d8fdda24-40ab-4bbe-9147-b0433c5a5d5b"}} -{"type":"expect_outbound","label":"prompt.offer:4","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Return the source marker followed by the transferred fork marker, separated by a single | character. Respond with only the markers and separator."},"parent_tool_use_id":null}}} -{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"init","agents":[],"apiKeySource":"none","claude_code_version":"2.1.159","cwd":"/tmp/claude-replay-thread_merge_back_continue","tools":[],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":[],"output_style":"default","skills":[],"plugins":[],"fast_mode_state":"off","uuid":"930c142a-b5e9-4056-8636-329558eb401f","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290"}} -{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RovhD68EryidHLBGQMKjeQ","type":"message","role":"assistant","content":[{"type":"text","text":"merge-source-4H8Q | merge-fork-7T2W"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":135,"cache_read_input_tokens":19061,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":135},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290","uuid":"0ed8c280-8f05-46b8-a7f8-40c98b1993f1","request_id":"req_011CbrhbNGTNVZmtXQuy7R5V"}} -{"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2611,"duration_api_ms":3876,"ttft_ms":2308,"num_turns":1,"result":"merge-source-4H8Q | merge-fork-7T2W","stop_reason":"end_turn","session_id":"79522bc8-3de1-41c4-a2fd-902246ef1290","total_cost_usd":0.01264065,"usage":{"input_tokens":3,"cache_creation_input_tokens":135,"cache_read_input_tokens":19061,"output_tokens":20,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":135,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":3,"output_tokens":20,"cache_read_input_tokens":19061,"cache_creation_input_tokens":135,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":135},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":6,"outputTokens":26,"cacheReadInputTokens":38038,"cacheCreationInputTokens":219,"webSearchRequests":0,"costUSD":0.01264065,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"e6282264-d155-4e72-8289-fc0310eaca0c"}} -{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson deleted file mode 100644 index 1308e039e283..000000000000 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson +++ /dev/null @@ -1,95 +0,0 @@ -{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context.","model":"gpt-6-sol"}} -{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} -{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} -{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} -{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000},"cwd":"","model":"gpt-6-sol"}}} -{"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297758148}} -{"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297758148}} -{"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297758,"updatedAt":1790297758,"recencyAt":1790297758,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-58-01a0d60f-d9dd-7d82-8842-6efe9b7b5152.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker merge-source-4H8Q for later in this conversation. Respond with exactly: merge source stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152"}}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297758370}} -{"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297758,"updatedAt":1790297758,"recencyAt":1790297758,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-58-01a0d60f-d9dd-7d82-8842-6efe9b7b5152.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297758370}} -{"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297758380}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d60f-daa8-7020-b1c9-2fee56b42739","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297758428}} -{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turn":{"id":"01a0d60f-daa8-7020-b1c9-2fee56b42739","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297758,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297758428}} -{"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790297758731}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297758880}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790297759784}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d60f-e412-7581-bda2-d126ca6132c9","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-source-4H8Q for later in this conversation. Respond with exactly: merge source stored","text_elements":[]}]},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","startedAtMs":1790297760790},"emittedAtMs":1790297760793}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a0d60f-e412-7581-bda2-d126ca6132c9","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-source-4H8Q for later in this conversation. Respond with exactly: merge source stored","text_elements":[]}]},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","completedAtMs":1790297760792},"emittedAtMs":1790297760801}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a2dc0887d0afb50d9a544badba","text":"","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","startedAtMs":1790297763141},"emittedAtMs":1790297763142}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","itemId":"msg_01a6f3bdd4e03630016ab5c6a2dc0887d0afb50d9a544badba","delta":"merge"},"emittedAtMs":1790297763142}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","itemId":"msg_01a6f3bdd4e03630016ab5c6a2dc0887d0afb50d9a544badba","delta":" source"},"emittedAtMs":1790297763142}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","itemId":"msg_01a6f3bdd4e03630016ab5c6a2dc0887d0afb50d9a544badba","delta":" stored"},"emittedAtMs":1790297763143}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a2dc0887d0afb50d9a544badba","text":"merge source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","completedAtMs":1790297763224},"emittedAtMs":1790297763230}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790297763356}} -{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","tokenUsage":{"total":{"totalTokens":17748,"inputTokens":17741,"cachedInputTokens":7040,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"last":{"totalTokens":17748,"inputTokens":17741,"cachedInputTokens":7040,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297763358}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","status":{"type":"idle"}},"emittedAtMs":1790297763365}} -{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turn":{"id":"01a0d60f-daa8-7020-b1c9-2fee56b42739","items":[{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a2dc0887d0afb50d9a544badba","text":"merge source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297758,"completedAt":1790297763,"durationMs":4983}},"emittedAtMs":1790297763365}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000},"cwd":"","lastTurnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","model":"gpt-6-sol","threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152"}}} -{"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"Full-history hydration is deprecated for paginated threads; use `excludeTurns: true`, then page with `thread/turns/list` and `thread/items/list`.","details":null},"emittedAtMs":1790297763374}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297763613}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297763613}} -{"type":"emit_inbound","label":"thread/fork","frame":{"id":4,"result":{"thread":{"id":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","forkedFromId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297763,"updatedAt":1790297763,"recencyAt":1790297763,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-03-01a0d60f-ee79-77b0-8e97-bb27c81fd026.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d60f-daa8-7020-b1c9-2fee56b42739","items":[{"type":"userMessage","id":"01a0d60f-e412-7581-bda2-d126ca6132c9","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-source-4H8Q for later in this conversation. Respond with exactly: merge source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a2dc0887d0afb50d9a544badba","text":"merge source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297758,"completedAt":1790297763,"durationMs":4983}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-fork-7T2W. Respond with exactly: merge fork stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026"}}} -{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-daa8-7020-b1c9-2fee56b42739","tokenUsage":{"total":{"totalTokens":17748,"inputTokens":17741,"cachedInputTokens":7040,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"last":{"totalTokens":17748,"inputTokens":17741,"cachedInputTokens":7040,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297763630}} -{"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","forkedFromId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297763,"updatedAt":1790297763,"recencyAt":1790297763,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-03-01a0d60f-ee79-77b0-8e97-bb27c81fd026.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297763630}} -{"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297763652}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":5,"result":{"turn":{"id":"01a0d60f-ef35-7a63-b460-84ede485ec85","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297763665}} -{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turn":{"id":"01a0d60f-ef35-7a63-b460-84ede485ec85","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297763,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297763665}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790297764218}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d60f-f3a5-7101-872b-90a9426624ab","clientId":null,"content":[{"type":"text","text":"Remember the fork-local marker merge-fork-7T2W. Respond with exactly: merge fork stored","text_elements":[]}]},"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","startedAtMs":1790297764776},"emittedAtMs":1790297764776}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a0d60f-f3a5-7101-872b-90a9426624ab","clientId":null,"content":[{"type":"text","text":"Remember the fork-local marker merge-fork-7T2W. Respond with exactly: merge fork stored","text_elements":[]}]},"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","completedAtMs":1790297764776},"emittedAtMs":1790297764785}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a6795887d0ab300c0c07e3a285","text":"","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","startedAtMs":1790297766760},"emittedAtMs":1790297766760}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","itemId":"msg_01a6f3bdd4e03630016ab5c6a6795887d0ab300c0c07e3a285","delta":"merge"},"emittedAtMs":1790297766760}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","itemId":"msg_01a6f3bdd4e03630016ab5c6a6795887d0ab300c0c07e3a285","delta":" fork"},"emittedAtMs":1790297766760}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","itemId":"msg_01a6f3bdd4e03630016ab5c6a6795887d0ab300c0c07e3a285","delta":" stored"},"emittedAtMs":1790297766760}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a6795887d0ab300c0c07e3a285","text":"merge fork stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","completedAtMs":1790297766760},"emittedAtMs":1790297766789}} -{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turnId":"01a0d60f-ef35-7a63-b460-84ede485ec85","tokenUsage":{"total":{"totalTokens":39712,"inputTokens":39698,"cachedInputTokens":21888,"cacheWriteInputTokens":0,"outputTokens":14,"reasoningOutputTokens":0},"last":{"totalTokens":21964,"inputTokens":21957,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297766829}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","status":{"type":"idle"}},"emittedAtMs":1790297766848}} -{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","turn":{"id":"01a0d60f-ef35-7a63-b460-84ede485ec85","items":[{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a6795887d0ab300c0c07e3a285","text":"merge fork stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297763,"completedAt":1790297766,"durationMs":3191}},"emittedAtMs":1790297766848}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-fork-7T2W.\n- Assistant confirmed: merge fork stored\n\nUser message:\nRetain the transferred fork marker for later. Respond with exactly: merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152"}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297766878}} -{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turn":{"id":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297766,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297766878}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d60f-fc9d-7151-bd36-33ef393fdcb0","clientId":null,"content":[{"type":"text","text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-fork-7T2W.\n- Assistant confirmed: merge fork stored\n\nUser message:\nRetain the transferred fork marker for later. Respond with exactly: merge delta stored","text_elements":[]}]},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","startedAtMs":1790297767071},"emittedAtMs":1790297767071}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a0d60f-fc9d-7151-bd36-33ef393fdcb0","clientId":null,"content":[{"type":"text","text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-fork-7T2W.\n- Assistant confirmed: merge fork stored\n\nUser message:\nRetain the transferred fork marker for later. Respond with exactly: merge delta stored","text_elements":[]}]},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","completedAtMs":1790297767071},"emittedAtMs":1790297767076}} -{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60f-ee79-77b0-8e97-bb27c81fd026","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790297767988}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a8c55487d0ace5b685a25be087","text":"","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","startedAtMs":1790297768987},"emittedAtMs":1790297768988}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","itemId":"msg_01a6f3bdd4e03630016ab5c6a8c55487d0ace5b685a25be087","delta":"merge"},"emittedAtMs":1790297769063}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","itemId":"msg_01a6f3bdd4e03630016ab5c6a8c55487d0ace5b685a25be087","delta":" delta"},"emittedAtMs":1790297769063}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","itemId":"msg_01a6f3bdd4e03630016ab5c6a8c55487d0ace5b685a25be087","delta":" stored"},"emittedAtMs":1790297769063}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a8c55487d0ace5b685a25be087","text":"merge delta stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","completedAtMs":1790297769063},"emittedAtMs":1790297769068}} -{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","tokenUsage":{"total":{"totalTokens":39754,"inputTokens":39740,"cachedInputTokens":24576,"cacheWriteInputTokens":0,"outputTokens":14,"reasoningOutputTokens":0},"last":{"totalTokens":22006,"inputTokens":21999,"cachedInputTokens":17536,"cacheWriteInputTokens":0,"outputTokens":7,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297769076}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","status":{"type":"idle"}},"emittedAtMs":1790297769081}} -{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turn":{"id":"01a0d60f-fbc3-7921-a57c-5b571eddbcf8","items":[{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6a8c55487d0ace5b685a25be087","text":"merge delta stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297766,"completedAt":1790297769,"durationMs":2211}},"emittedAtMs":1790297769081}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Return the source marker followed by the transferred fork marker, separated by a single | character. Respond with only the markers and separator.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152"}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297769101}} -{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turn":{"id":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297769,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297769101}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d610-0529-7c62-8a34-a646ec8a9f8f","clientId":null,"content":[{"type":"text","text":"Return the source marker followed by the transferred fork marker, separated by a single | character. Respond with only the markers and separator.","text_elements":[]}]},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","startedAtMs":1790297769258},"emittedAtMs":1790297769259}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a0d610-0529-7c62-8a34-a646ec8a9f8f","clientId":null,"content":[{"type":"text","text":"Return the source marker followed by the transferred fork marker, separated by a single | character. Respond with only the markers and separator.","text_elements":[]}]},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","completedAtMs":1790297769258},"emittedAtMs":1790297769264}} -{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","text":"","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","startedAtMs":1790297771119},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"merge"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"-source"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"-"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"4"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"H"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"8"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"Q"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"|"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"merge"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"-f"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"ork"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"-"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"7"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"T"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"2"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","itemId":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","delta":"W"},"emittedAtMs":1790297771119}} -{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","text":"merge-source-4H8Q|merge-fork-7T2W","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","completedAtMs":1790297771282},"emittedAtMs":1790297771285}} -{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turnId":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","tokenUsage":{"total":{"totalTokens":61812,"inputTokens":61778,"cachedInputTokens":46336,"cacheWriteInputTokens":0,"outputTokens":34,"reasoningOutputTokens":0},"last":{"totalTokens":22058,"inputTokens":22038,"cachedInputTokens":21760,"cacheWriteInputTokens":0,"outputTokens":20,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297771334}} -{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","status":{"type":"idle"}},"emittedAtMs":1790297771343}} -{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d60f-d9dd-7d82-8842-6efe9b7b5152","turn":{"id":"01a0d610-047d-7b93-92bb-f5a8ffa0666c","items":[{"type":"agentMessage","id":"msg_01a6f3bdd4e03630016ab5c6aad45087d0924564d55dae9a2f","text":"merge-source-4H8Q|merge-fork-7T2W","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297769,"completedAt":1790297771,"durationMs":2246}},"emittedAtMs":1790297771343}} -{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 2e96b4805883..4055fd85b7ae 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -7,24 +7,20 @@ import { type OrchestrationV2ProjectedTurnItem, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { buildBoundedThreadProjection, boundedTimelineEncodedBytes, computeLatestLocalTurnOrdinal, - decodeThreadHistoryCursor, encodeThreadHistoryCursor, - InvalidThreadHistoryCursorError, projectedRowBoundedSnapshotEncodedBytes, projectedRowEncodedBytes, selectHistoryPageFromCursor, selectRecentTimelineWindow, - THREAD_HISTORY_CURSOR_MAX_LENGTH, THREAD_HISTORY_PAGE_POLICY, } from "./threadHistoryPaging.ts"; -import { buildBoundedThreadStreamSnapshot } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); @@ -180,22 +176,6 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { - it("builds a resumable bounded socket snapshot frame", () => { - const projection = makeProjection(Array.from({ length: 90 }, (_, index) => makeRow(index))); - const item = buildBoundedThreadStreamSnapshot({ - snapshotSequence: 23, - projection, - }); - - expect(item.kind).toBe("snapshot"); - expect(item.snapshotSequence).toBe(23); - expect(item.projection.visibleTurnItems).toHaveLength(THREAD_HISTORY_PAGE_POLICY.maxItems); - expect(item.historyCursor).not.toBeNull(); - expect(item.hasMoreHistory).toBe(true); - expect(item.latestLocalTurnOrdinal).toBe(90); - expect(item.payloadBudgetExceeded).toBe(false); - }); - it("keeps background turns with user turns, with main's 150-turn fan-out ceiling", () => { const items = Array.from({ length: 161 }, (_, turn) => { const row = makeRow(turn * 2); @@ -307,52 +287,6 @@ describe("threadHistoryPaging", () => { expect([...older.items, ...recent.items]).toHaveLength(items.length); }); - it("encodes opaque cursors with stable source identity", () => { - const cursor = encodeThreadHistoryCursor({ - snapshotSequence: 9, - sourceThreadId: THREAD, - sourceItemId: TurnItemId.make("item-3"), - position: 3, - }); - expect(cursor.includes("{")).toBe(false); - expect(decodeThreadHistoryCursor(cursor)).toEqual({ - v: 1, - seq: 9, - st: "thread-1", - si: "item-3", - p: 3, - }); - }); - - it("rejects malformed cursors", () => { - expect(() => decodeThreadHistoryCursor("not-valid")).toThrow(InvalidThreadHistoryCursorError); - expect(() => - decodeThreadHistoryCursor(Buffer.from("{}", "utf8").toString("base64url")), - ).toThrow(InvalidThreadHistoryCursorError); - }); - - it("rejects cursors longer than the maximum length before decoding", () => { - const oversized = "A".repeat(THREAD_HISTORY_CURSOR_MAX_LENGTH + 1); - expect(() => decodeThreadHistoryCursor(oversized)).toThrow(InvalidThreadHistoryCursorError); - }); - - it("selects a recent window by item count", () => { - const items = Array.from({ length: 120 }, (_, index) => makeRow(index)); - const page = selectRecentTimelineWindow({ - items, - snapshotSequence: 4, - policy: { maxItems: 10, maxEncodedBytes: 10_000_000 }, - }); - expect(page.items).toHaveLength(10); - expect(page.items[0]?.sourceItemId).toBe("item-110"); - expect(page.items[9]?.sourceItemId).toBe("item-119"); - expect(page.hasMoreHistory).toBe(true); - expect(page.nextCursor).not.toBeNull(); - const cursor = decodeThreadHistoryCursor(page.nextCursor!); - expect(cursor.si).toBe("item-110"); - expect(cursor.seq).toBe(4); - }); - it("always includes at least one pathological oversized item", () => { const items = [makeRow(0, { outputBytes: 2_000_000 }), makeRow(1, { outputBytes: 10 })]; const page = selectRecentTimelineWindow({ @@ -439,40 +373,6 @@ describe("threadHistoryPaging", () => { expect(computeLatestLocalTurnOrdinal(full.turnItems)).toBe(40); }); - it("preserves linked control rows as one coherent graph", () => { - const base = makeProjection(Array.from({ length: 40 }, (_, index) => makeRow(index))); - const linked = { - ...base, - runs: [{ id: "run-linked", threadId: THREAD }], - attempts: [{ id: "attempt-linked", runId: "run-linked" }], - nodes: [{ id: "node-linked", runId: "run-linked", attemptId: "attempt-linked" }], - providerThreads: [{ id: "provider-thread-linked", runId: "run-linked" }], - providerTurns: [ - { - id: "provider-turn-linked", - providerThreadId: "provider-thread-linked", - nodeId: "node-linked", - }, - ], - checkpointScopes: [{ id: "scope-linked", runId: "run-linked" }], - checkpoints: [{ id: "checkpoint-linked", scopeId: "scope-linked" }], - } as unknown as typeof base; - - const bounded = buildBoundedThreadProjection({ - projection: linked, - snapshotSequence: 7, - policy: { maxItems: 5, maxEncodedBytes: 10_000_000 }, - }); - - expect(bounded.projection.runs).toEqual(linked.runs); - expect(bounded.projection.attempts).toEqual(linked.attempts); - expect(bounded.projection.nodes).toEqual(linked.nodes); - expect(bounded.projection.providerThreads).toEqual(linked.providerThreads); - expect(bounded.projection.providerTurns).toEqual(linked.providerTurns); - expect(bounded.projection.checkpointScopes).toEqual(linked.checkpointScopes); - expect(bounded.projection.checkpoints).toEqual(linked.checkpoints); - }); - it("carries a full-projection watermark when the bounded window is inherited-only", () => { const localOlder = makeRow(0); const localMid = makeRow(1); @@ -524,32 +424,6 @@ describe("threadHistoryPaging", () => { expect(bounded.hasMoreHistory).toBe(true); }); - it("retains all run_interrupt_request items even when no result is in the initial window", () => { - const request = interruptRow(0, "run_interrupt_request"); - const result = interruptRow(1, "run_interrupt_result"); - const recent = makeRow(2); - const full = makeProjection([request, result, recent]); - const bounded = buildBoundedThreadProjection({ - projection: full, - snapshotSequence: 4, - policy: { maxItems: 1, maxEncodedBytes: 10_000_000 }, - }); - - expect(bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId))).toEqual([ - "item-2", - ]); - expect(bounded.projection.turnItems.map((item) => String(item.id)).sort()).toEqual([ - "interrupt-req", - "item-2", - ]); - expect(bounded.projection.turnItems.some((item) => item.type === "run_interrupt_request")).toBe( - true, - ); - expect(bounded.projection.turnItems.some((item) => item.type === "run_interrupt_result")).toBe( - false, - ); - }); - it("charges local row.item duplication when measuring bounded timeline bytes", () => { const rows = Array.from({ length: 8 }, (_, index) => makeRow(index, { outputBytes: 2_000 })); const full = makeProjection(rows); @@ -586,29 +460,6 @@ describe("threadHistoryPaging", () => { ); }); - it("allows a single pathological bounded row to exceed the configured cap", () => { - const huge = makeRow(0, { outputBytes: 50_000 }); - const full = makeProjection([huge]); - const maxEncodedBytes = 1_000; - const bounded = buildBoundedThreadProjection({ - projection: full, - snapshotSequence: 1, - policy: { maxItems: 10, maxEncodedBytes }, - }); - expect(bounded.projection.visibleTurnItems).toHaveLength(1); - const contribution = boundedTimelineEncodedBytes({ - visibleTurnItems: bounded.projection.visibleTurnItems, - turnItems: bounded.projection.turnItems, - }); - expect(contribution).toBeGreaterThan(maxEncodedBytes); - }); - - it("exposes conservative default page budgets", () => { - expect(THREAD_HISTORY_PAGE_POLICY.maxItems).toBeGreaterThanOrEqual(50); - expect(THREAD_HISTORY_PAGE_POLICY.maxItems).toBeLessThanOrEqual(100); - expect(THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes).toBe(1_048_576); - }); - it("does not carry the full duplicated message table into a bounded snapshot", () => { const projection = makeProjection(Array.from({ length: 120 }, (_, index) => makeRow(index))); const full = { @@ -739,24 +590,6 @@ describe("threadHistoryPaging", () => { expect(bounded.projection.visibleTurnItems).toEqual(actionable.visibleTurnItems); }); - it("reports overflow from timeline text alone without serializing the projection", () => { - // A local item is encoded twice (visible row and turnItems): 2 x 20k chars > 30k bytes. - const projection = makeProjection([assistantRow("a".repeat(20_000))]); - const stringify = vi.spyOn(JSON, "stringify"); - try { - const bounded = buildBoundedThreadProjection({ - projection, - snapshotSequence: 1, - policy: { maxItems: 75, maxEncodedBytes: 30_000 }, - }); - expect(bounded.payloadBudgetExceeded).toBe(true); - // Skipping this encode is the shortcut's only observable effect. - expect(stringify).not.toHaveBeenCalledWith(bounded.projection); - } finally { - stringify.mockRestore(); - } - }); - it("flips overflow at the exact encoded size when timeline text fits the budget", () => { // Inherited rows are encoded once, local rows twice; neither may overflow early. for (const visibility of ["local", "inherited"] as const) { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 6e8638570850..e0aa215f3f1e 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -25,7 +25,7 @@ export const THREAD_HISTORY_MAX_RAW_TURNS = 150; export const THREAD_HISTORY_SNAPSHOT_ROW_LIMIT = THREAD_HISTORY_PAGE_POLICY.maxItems + 2; -export const THREAD_HISTORY_CURSOR_MAX_LENGTH = 4_096; +const THREAD_HISTORY_CURSOR_MAX_LENGTH = 4_096; export type ThreadHistoryPagePolicy = { readonly maxUserTurns?: number | undefined; diff --git a/apps/server/src/orchestration-v2/toolOutputImageBytes.test.ts b/apps/server/src/orchestration-v2/toolOutputImageBytes.test.ts index 8eb57f0423da..4a885dd4cf03 100644 --- a/apps/server/src/orchestration-v2/toolOutputImageBytes.test.ts +++ b/apps/server/src/orchestration-v2/toolOutputImageBytes.test.ts @@ -90,6 +90,7 @@ describe("stripUnservedToolOutputImageBytes", () => { }); }); + // regression: #16652 stop storing tool image bytes no client reads it("strips a Cursor image part that names no mime type", () => { const item = toolItem({ content: [{ text: { text: "captured" } }, { image: { data: PNG_BASE64 } }], @@ -184,6 +185,7 @@ describe("stripUnservedToolOutputImageBytes", () => { expect(stripUnservedToolOutputImageBytes(item)).toBe(item); }); + // regression: #16652 stop storing tool image bytes no client reads it("measures the size instead of trusting one the provider supplied", () => { const item = toolItem({ type: "image", @@ -196,26 +198,6 @@ describe("stripUnservedToolOutputImageBytes", () => { }); }); - it("returns items without image bytes by reference", () => { - const nested = { files: [{ path: "src/app.ts" }] }; - const item = toolItem({ stdout: "data: 42", data: PNG_BASE64, nested }); - const command: OrchestrationV2TurnItem = { - ...itemBase, - type: "command_execution", - input: "cat shot.png | base64", - output: PNG_BASE64, - }; - - const stripped = stripUnservedToolOutputImageBytes(item); - - expect(stripped).toBe(item); - expect(outputOf(stripped)).toBe(outputOf(item)); - expect(stripUnservedToolOutputImageBytes(command)).toBe(command); - expect(stripUnservedToolOutputImageBytes(toolItem("plain text output"))).toEqual( - toolItem("plain text output"), - ); - }); - it("does not mutate the provider's item and shares untouched siblings", () => { const untouched = { type: "text", text: "captured" }; const output = { diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts index f408534245ea..b1106f7bca54 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts @@ -3,7 +3,6 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; -import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -51,23 +50,6 @@ describe("workflow script containment", () => { }), ); - effectIt.effect("rejects relative and non-js paths", () => - Effect.gen(function* () { - const relative = yield* Effect.exit(readWorkflowScript({ scriptPath: "run.js" })); - assert.equal(relative._tag, "Failure"); - const nonJs = yield* Effect.exit( - readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }), - ); - assert.equal(nonJs._tag, "Failure"); - const error = yield* Effect.flip(readWorkflowScript({ scriptPath: "run.js" })); - assert.equal(error._tag, "OrchestrationGetWorkflowScriptError"); - assert.equal(error.scriptPath, "run.js"); - assert.equal(error.reason, "invalid-path"); - assert.equal(error.message, "Workflow scripts must be absolute .js paths."); - assert.instanceOf(error.cause, OrchestrationWorkflowFileError); - }), - ); - effectIt.effect.skipIf(!symlinksSupported)( "rejects paths outside the root and symlink escapes", () => diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index 0051747e2195..d9ae788baad1 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -12,14 +12,6 @@ import { fixPath, hydratePosixHome } from "./os-jank.ts"; vi.mock("node:child_process", { spy: true }); -it("hydrates HOME for minimal service environments from the user account", () => { - const env: NodeJS.ProcessEnv = {}; - - hydratePosixHome(env); - - assert.equal(env.HOME, NodeOS.userInfo().homedir); -}); - it("hydrates HOME independently of a blank process HOME", () => { const originalHome = process.env.HOME; const env: NodeJS.ProcessEnv = { HOME: " " }; @@ -48,32 +40,6 @@ it("preserves an explicitly configured HOME", () => { assert.equal(env.HOME, "/custom/home"); }); -effectIt.effect.each(["darwin", "linux"] as const)( - "keeps the prepared PATH and still hydrates HOME on %s", - (platform) => - Effect.acquireUseRelease( - Effect.sync(() => vi.spyOn(NodeChildProcess, "execFileSync")), - (probe) => - Effect.gen(function* () { - probe.mockImplementation(() => { - throw new Error("The prepared environment must not launch another shell"); - }); - const env: NodeJS.ProcessEnv = { PATH: "/prepared/bin:/usr/bin" }; - - yield* fixPath({ shellEnvironmentPrepared: true }).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService(HostProcess.Environment, env), - Effect.provide(NodeServices.layer), - ); - - assert.equal(probe.mock.calls.length, 0); - assert.equal(env.PATH, "/prepared/bin:/usr/bin"); - assert.equal(env.HOME, NodeOS.userInfo().homedir); - }), - (probe) => Effect.sync(() => probe.mockRestore()), - ), -); - effectIt.effect("hydrates PATH when the desktop handoff is absent", () => Effect.acquireUseRelease( Effect.sync(() => @@ -130,31 +96,6 @@ effectIt.effect("keeps mise's PATH record with the login shell PATH", () => ), ); -effectIt.effect("keeps the prepared Windows PATH without probing", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "C:\\Prepared\\Bin;C:\\Windows" }; - const commands: string[] = []; - - yield* fixPath({ shellEnvironmentPrepared: true }).pipe( - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(HostProcess.Environment, env), - Effect.provideService(WindowsShellEnvironment, () => { - throw new Error("The prepared environment must not launch another shell"); - }), - Effect.provideService(CommandAvailability, (command) => - Effect.sync(() => { - commands.push(command); - return true; - }), - ), - Effect.provide(NodeServices.layer), - ); - - assert.deepEqual(commands, []); - assert.equal(env.PATH, "C:\\Prepared\\Bin;C:\\Windows"); - }), -); - effectIt.effect("replaces a Path-spelled Windows PATH instead of adding a second key", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { Path: "C:\\Windows" }; diff --git a/apps/server/src/outboundHttp.test.ts b/apps/server/src/outboundHttp.test.ts index a4f8a9260616..be8a275fd4b2 100644 --- a/apps/server/src/outboundHttp.test.ts +++ b/apps/server/src/outboundHttp.test.ts @@ -2,7 +2,6 @@ import * as NodeHttp2 from "node:http2"; import * as NodeNet from "node:net"; import * as NodeTls from "node:tls"; -import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -15,7 +14,6 @@ import { afterAll, afterEach, beforeAll, describe, expect } from "vite-plus/test import { builtinFetchUsesHttp2, - extendConnectionAttemptTimeout, ignoreMacosTypeOfServiceEinval, installHttp1FetchDispatcher, } from "./outboundHttp.ts"; @@ -138,24 +136,6 @@ describe("ignoreMacosTypeOfServiceEinval", () => { expect(() => prototype.setTypeOfService.call(socket, 0)).toThrow(); }); - it("changes nothing off macOS or on a runtime without setTypeOfService", () => { - const error = errnoError("EINVAL"); - const prototype = { - setTypeOfService() { - throw error; - }, - }; - const original = prototype.setTypeOfService; - ignoreMacosTypeOfServiceEinval("linux", prototype); - ignoreMacosTypeOfServiceEinval("win32", prototype); - expect(prototype.setTypeOfService).toBe(original); - expect(() => prototype.setTypeOfService()).toThrow(error); - - const withoutTypeOfService = {}; - ignoreMacosTypeOfServiceEinval("darwin", withoutTypeOfService); - expect(withoutTypeOfService).not.toHaveProperty("setTypeOfService"); - }); - // Bundled undici 7 calls this API from a socket callback. Keep the real fetch // in a child so an escaped EINVAL exits that process instead of breaking Vitest. it.effect.runIf( @@ -186,67 +166,4 @@ if (calls === 0) throw new Error("fetch did not call setTypeOfService"); expect(child.stdout.trim()).toBe("1.1"); }).pipe(Effect.provide(NodeServices.layer)), ); - - it.effect("guards ordinary CLI and both ACP fast paths before their imports run", () => - Effect.gen(function* () { - const dispatchModules = ["mcp/AcpMcpStdioBridge.ts", "cli/consoleStdin.ts", "binCli.ts"]; - // Load the real entrypoint, but replace dispatch modules to avoid starting - // the app. Probe at import time, when those modules could first open sockets. - const preload = ` -import * as NodeModule from "node:module"; -import * as NodeNet from "node:net"; - -Object.defineProperty(process, "platform", { value: "darwin" }); -NodeNet.Socket.prototype.setTypeOfService = () => { - throw Object.assign(new Error("setTypeOfService EINVAL"), { code: "EINVAL" }); -}; -NodeModule.registerHooks({ - load(url, context, nextLoad) { - const path = ${JSON.stringify(dispatchModules)}.find((path) => url.endsWith("/src/" + path)); - if (path === undefined) return nextLoad(url, context); - NodeNet.Socket.prototype.setTypeOfService.call(new NodeNet.Socket(), 0); - console.log(path); - return { - format: "module", - source: "export const runAcpMcpCliFastPath = async () => {}; export const attachConsoleStdin = () => {}; export const runCli = () => {};", - shortCircuit: true, - }; - }, -}); -`; - const children = yield* Effect.forEach( - ["--version", "acp-mcp-bridge", "acp-mcp-call"], - (command) => - runNode([ - "--import", - `data:text/javascript,${encodeURIComponent(preload)}`, - NodeURL.fileURLToPath(new URL("./bin.ts", import.meta.url)), - command, - ]), - ); - expect( - children.map((child) => child.exitCode), - children.map((child) => child.stderr).join("\n"), - ).toEqual([0, 0, 0]); - expect(children.map((child) => child.stdout.trim().split("\n"))).toEqual([ - ["cli/consoleStdin.ts", "binCli.ts"], - ["mcp/AcpMcpStdioBridge.ts"], - ["mcp/AcpMcpStdioBridge.ts"], - ]); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); - -describe("extendConnectionAttemptTimeout", () => { - it("raises a default below the floor to 1000 ms", () => { - NodeNet.setDefaultAutoSelectFamilyAttemptTimeout(50); - extendConnectionAttemptTimeout(); - expect(NodeNet.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(1_000); - }); - - it("keeps a larger configured timeout", () => { - NodeNet.setDefaultAutoSelectFamilyAttemptTimeout(5_000); - extendConnectionAttemptTimeout(); - expect(NodeNet.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(5_000); - }); }); diff --git a/apps/server/src/persistence/DatabaseStorage.test.ts b/apps/server/src/persistence/DatabaseStorage.test.ts index 23dda5aabc57..fa10279656a3 100644 --- a/apps/server/src/persistence/DatabaseStorage.test.ts +++ b/apps/server/src/persistence/DatabaseStorage.test.ts @@ -1,15 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; -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 Queue from "effect/Queue"; -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"; @@ -40,10 +34,6 @@ const storageLayer = ( const testConfig = ServerConfig.layerTest(process.cwd(), { prefix: "database-storage-" }); -const currentStatus = Effect.flatMap(DatabaseStorage.DatabaseStorage, (storage) => - storage.status.pipe(Stream.take(1), Stream.runHead), -); - it.effect("a new database returns freed pages online without losing data", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -60,157 +50,3 @@ it.effect("a new database returns freed pages online without losing data", () => assert.deepEqual(yield* sql`SELECT value FROM retained`, [{ value: "keep" }]); }).pipe(Effect.provide(storageLayer(testConfig))), ); - -it.effect("publishes file accounting and the doctor command after each pass", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const config = yield* ServerConfig.ServerConfig; - yield* sql`CREATE TABLE discarded (value BLOB)`; - yield* sql`INSERT INTO discarded VALUES (zeroblob(1048576))`; - yield* TestClock.adjust("1 minute"); - const before = (yield* currentStatus).pipe(Option.getOrThrow); - assert.isNotNull(before); - assert.isAbove(before!.databaseBytes, 1048576); - assert.isTrue(before!.incrementalVacuumEnabled); - // Quoting follows the host shell, so check each word on its own. - assert.include(before!.compactCommand, "doctor"); - assert.include(before!.compactCommand, "--base-dir"); - assert.include(before!.compactCommand, config.baseDir); - - // The next reclaim pass frees the dropped table and republishes. - yield* sql`DROP TABLE discarded`; - yield* TestClock.adjust("1 minute"); - const storage = yield* DatabaseStorage.DatabaseStorage; - const after = yield* storage.status.pipe( - Stream.filter((status) => status !== null && status.databaseBytes < before!.databaseBytes), - Stream.runHead, - Effect.map(Option.getOrThrow), - ); - assert.isAtMost(after!.databaseBytes, before!.databaseBytes - 1048576); - assert.equal(after!.reclaimableBytes, 0); - }).pipe(Effect.provide(storageLayer(testConfig))), -); - -it.effect("publishes nothing when doctor would repair a different database", () => - Effect.gen(function* () { - yield* TestClock.adjust("1 minute"); - assert.deepEqual(yield* currentStatus, Option.some(null)); - }).pipe( - Effect.provide( - storageLayer( - // A dev server without an explicit home keeps state in /dev, - // while `doctor --base-dir` targets /userdata. - Layer.effect( - ServerConfig.ServerConfig, - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - return { ...config, dbPath: `${config.baseDir}/dev/statev2.sqlite` }; - }), - ).pipe(Layer.provide(testConfig)), - ), - ), - ), -); - -const compactionLayer = ( - compactEventStore: ProjectionMaintenance.ProjectionMaintenanceV2["Service"]["compactEventStore"], -) => - DatabaseStorage.layer.pipe( - Layer.provide( - Layer.succeed(ProjectionMaintenance.ProjectionMaintenanceV2, { - compactEventStore, - verify: Effect.die("unexpected verification"), - rebuild: Effect.die("unexpected rebuild"), - }), - ), - Layer.provide(Sqlite.layerMemory), - ); - -const minute = 60_000; -const hour = 60 * minute; -const startupTime = Date.UTC(2026, 9, 19); -const emptyCompaction = { deletedEventCount: 0, deletedReceiptCount: 0, reclaimableBytes: 0 }; - -it.effect.each([ - { name: "recent success", saved: String(startupTime - 2 * hour), delay: 4 * hour }, - { name: "overdue success", saved: String(startupTime - 7 * hour), delay: minute }, - { name: "nearly due success", saved: String(startupTime - 6 * hour + 1), delay: minute }, - { name: "future timestamp", saved: String(startupTime + hour), delay: 6 * hour }, - { name: "missing timestamp", saved: undefined, delay: minute }, - { name: "invalid timestamp", saved: "not a timestamp", delay: minute }, - { name: "unreadable timestamp", saved: undefined, delay: minute }, -])("schedules startup compaction after $name", ({ name, saved, delay }) => - Effect.gen(function* () { - yield* TestClock.setTime(startupTime); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const config = yield* ServerConfig.ServerConfig; - const timestampPath = path.join(path.dirname(config.dbPath), "compacted-at"); - if (name === "unreadable timestamp") yield* fs.makeDirectory(timestampPath); - else if (saved !== undefined) yield* fs.writeFileString(timestampPath, saved); - const started = yield* Deferred.make(); - const compact = Clock.currentTimeMillis.pipe( - Effect.flatMap((now) => Deferred.succeed(started, now)), - Effect.as(emptyCompaction), - ); - yield* Effect.gen(function* () { - yield* TestClock.adjust(delay - 1); - assert.isFalse(yield* Deferred.isDone(started)); - yield* TestClock.adjust(1); - assert.equal(yield* Deferred.await(started), startupTime + delay); - }).pipe(Effect.provide(compactionLayer(compact))); - }).pipe(Effect.provide(testConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - -it.effect.each(["success", "compaction failure", "timestamp write failure"] as const)( - "keeps the six-hour cadence after %s and remembers only successful completion", - (outcome) => - Effect.gen(function* () { - yield* TestClock.setTime(startupTime); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const config = yield* ServerConfig.ServerConfig; - const timestampPath = path.join(path.dirname(config.dbPath), "compacted-at"); - const previous = String(startupTime - 7 * hour); - if (outcome === "timestamp write failure") yield* fs.makeDirectory(timestampPath); - else yield* fs.writeFileString(timestampPath, previous); - const starts = yield* Queue.unbounded(); - const finish = yield* Queue.unbounded(); - const writeFinished = yield* Deferred.make(); - const compact = Effect.gen(function* () { - yield* Queue.offer(starts, yield* Clock.currentTimeMillis); - yield* Queue.take(finish); - if (outcome === "compaction failure") { - return yield* new ProjectionMaintenance.ProjectionMaintenanceError({ operation: "test" }); - } - return emptyCompaction; - }); - yield* Effect.gen(function* () { - yield* TestClock.adjust(minute); - assert.equal(yield* Queue.take(starts), startupTime + minute); - yield* TestClock.adjust(2 * minute); - if (outcome !== "timestamp write failure") { - assert.equal(yield* fs.readFileString(timestampPath), previous); - } - yield* Queue.offer(finish, undefined); - if (outcome !== "compaction failure") yield* Deferred.await(writeFinished); - yield* TestClock.adjust(6 * hour - 1); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(1); - assert.equal(yield* Queue.take(starts), startupTime + 3 * minute + 6 * hour); - }).pipe( - Effect.provide(compactionLayer(compact)), - Effect.provideService(FileSystem.FileSystem, { - ...fs, - writeFileString: (file, content, options) => - fs - .writeFileString(file, content, options) - .pipe(Effect.ensuring(Deferred.succeed(writeFinished, undefined))), - }), - ); - if (outcome !== "timestamp write failure") { - const saved = Number(yield* fs.readFileString(timestampPath)); - assert.equal(saved, outcome === "success" ? startupTime + 3 * minute : Number(previous)); - } - }).pipe(Effect.provide(testConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts index 840210a5d4c0..59ca8854b331 100644 --- a/apps/server/src/persistence/Errors.test.ts +++ b/apps/server/src/persistence/Errors.test.ts @@ -41,29 +41,6 @@ it("reads the condition through a wrapping driver error", () => { assert.equal(error.detail, "SQLITE(5) database is locked"); }); -it.each([ - new Error("unhelpful"), - Object.assign(new Error("file not found"), { errno: -2, code: "ENOENT" }), -])("omits a detail for a cause it cannot categorize (%#)", (cause) => { - const error = toPersistenceSqlError("AuthSessionRepository.list:query")(cause); - - assert.equal(error.detail, undefined); - assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); -}); - -it.effect("summarizes a schema cause by issue tag instead of by rejected value", () => - Effect.gen(function* () { - const rejectedPayload = "sql-mapper-secret-sentinel"; - const cause = yield* Effect.flip( - decodeRuntimePayload({ runtimePayload: { attempt: rejectedPayload } }), - ); - const error = toPersistenceSqlError("ProviderSessionRuntimeRepository.list:query")(cause); - - assert.ok(error.detail !== undefined); - assert.ok(!error.message.includes(rejectedPayload)); - }), -); - it.effect("maps schema errors without copying rejected payloads into diagnostics", () => Effect.gen(function* () { const rejectedPayload = "runtime-payload-secret-sentinel"; diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 21c8e7a308f5..67f238b7484f 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -26,14 +26,6 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { - it.effect("migrates a fresh database through the whole manifest", () => - Effect.gen(function* () { - yield* runMigrations(); - assert.deepStrictEqual(yield* history, migrationManifest); - assert.isAbove(yield* v2TableCount, 0); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), - ); - it.effect("refuses to start when another migration took a V2 id", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9a0dff477368..5979eee6c18d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -69,8 +69,9 @@ import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts"; import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts"; import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts"; import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts"; +import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; -export const migrationEntries = [ +const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], [2, "OrchestrationCommandReceipts", Migration0002], [3, "CheckpointDiffBlobs", Migration0003], @@ -138,6 +139,7 @@ export const migrationEntries = [ [64, "CanonicalizeThreadPullRequestLinks", Migration0064], [65, "CodexUsageNativeTurnIndex", Migration0065], [66, "RepairClientSettlementTimestamps", Migration0066], + [67, "WebhookDedupe", Migration0067], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts deleted file mode 100644 index 84f69deb273f..000000000000 --- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("019_ProjectionSnapshotLookupIndexes", (it) => { - it.effect("creates indexes for targeted projection lookup filters", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 18 }); - yield* runMigrations({ toMigrationInclusive: 19 }); - - const projectIndexes = yield* sql<{ - readonly seq: number; - readonly name: string; - readonly unique: number; - readonly origin: string; - readonly partial: number; - }>` - PRAGMA index_list(projection_projects) - `; - assert.ok( - projectIndexes.some( - (index) => index.name === "idx_projection_projects_workspace_root_deleted_at", - ), - ); - - const projectIndexColumns = yield* sql<{ - readonly seqno: number; - readonly cid: number; - readonly name: string; - }>` - PRAGMA index_info('idx_projection_projects_workspace_root_deleted_at') - `; - assert.deepStrictEqual( - projectIndexColumns.map((column) => column.name), - ["workspace_root", "deleted_at"], - ); - - const threadIndexes = yield* sql<{ - readonly seq: number; - readonly name: string; - readonly unique: number; - readonly origin: string; - readonly partial: number; - }>` - PRAGMA index_list(projection_threads) - `; - assert.ok( - threadIndexes.some( - (index) => index.name === "idx_projection_threads_project_deleted_created", - ), - ); - - const threadIndexColumns = yield* sql<{ - readonly seqno: number; - readonly cid: number; - readonly name: string; - }>` - PRAGMA index_info('idx_projection_threads_project_deleted_created') - `; - assert.deepStrictEqual( - threadIndexColumns.map((column) => column.name), - ["project_id", "deleted_at", "created_at"], - ); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts deleted file mode 100644 index 43cd9821b611..000000000000 --- a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("029_ProjectionThreadDetailOrderingIndexes", (it) => { - it.effect("creates indexes matching thread detail ordering queries", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 28 }); - yield* runMigrations({ toMigrationInclusive: 29 }); - - const activityIndexes = yield* sql<{ - readonly seq: number; - readonly name: string; - readonly unique: number; - readonly origin: string; - readonly partial: number; - }>` - PRAGMA index_list(projection_thread_activities) - `; - assert.ok( - activityIndexes.some( - (index) => index.name === "idx_projection_thread_activities_thread_sequence_created_id", - ), - ); - - const activityIndexColumns = yield* sql<{ - readonly seqno: number; - readonly cid: number; - readonly name: string; - }>` - PRAGMA index_info('idx_projection_thread_activities_thread_sequence_created_id') - `; - assert.deepStrictEqual( - activityIndexColumns.map((column) => column.name), - ["thread_id", "sequence", "created_at", "activity_id"], - ); - - const messageIndexes = yield* sql<{ - readonly seq: number; - readonly name: string; - readonly unique: number; - readonly origin: string; - readonly partial: number; - }>` - PRAGMA index_list(projection_thread_messages) - `; - assert.ok( - messageIndexes.some( - (index) => index.name === "idx_projection_thread_messages_thread_created_id", - ), - ); - - const messageIndexColumns = yield* sql<{ - readonly seqno: number; - readonly cid: number; - readonly name: string; - }>` - PRAGMA index_info('idx_projection_thread_messages_thread_created_id') - `; - assert.deepStrictEqual( - messageIndexColumns.map((column) => column.name), - ["thread_id", "created_at", "message_id"], - ); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts deleted file mode 100644 index d753cf06d3cd..000000000000 --- a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("035_ProjectionThreadTitleRegeneration", (it) => { - it.effect("adds pending title regeneration columns", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 34 }); - yield* runMigrations({ toMigrationInclusive: 35 }); - - const columns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(projection_threads) - `; - const names = new Set(columns.map((column) => column.name)); - assert.ok(names.has("title_regeneration_request_id")); - assert.ok(names.has("title_regeneration_started_at")); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts deleted file mode 100644 index 2894ef698ccb..000000000000 --- a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("040_ProjectionProjectFaviconPath", (it) => { - it.effect("adds the nullable favicon path to project projections", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 39 }); - yield* runMigrations({ toMigrationInclusive: 40 }); - - const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` - PRAGMA table_info(projection_projects) - `; - const faviconPath = columns.find((column) => column.name === "favicon_path"); - - assert.equal(faviconPath?.name, "favicon_path"); - assert.equal(faviconPath?.notnull, 0); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts deleted file mode 100644 index 90467511e678..000000000000 --- a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("041_AuthSessionClientConnection", (it) => { - it.effect("adds nullable client surface and app version columns to auth sessions", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 40 }); - yield* runMigrations({ toMigrationInclusive: 41 }); - - const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` - PRAGMA table_info(auth_sessions) - `; - const surface = columns.find((column) => column.name === "client_surface"); - const appVersion = columns.find((column) => column.name === "client_app_version"); - - assert.equal(surface?.name, "client_surface"); - assert.equal(surface?.notnull, 0); - assert.equal(appVersion?.name, "client_app_version"); - assert.equal(appVersion?.notnull, 0); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts deleted file mode 100644 index 5039e70fb989..000000000000 --- a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("042_ProjectionThreadLinkedPullRequest", (it) => { - it.effect("adds the linked pull request column", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 41 }); - yield* runMigrations({ toMigrationInclusive: 42 }); - - const columns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(projection_threads) - `; - assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts b/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts deleted file mode 100644 index 0a62831b201f..000000000000 --- a/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("047_ProjectionProjectIcon", (it) => { - it.effect("adds the nullable project icon JSON to project projections", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 46 }); - yield* runMigrations({ toMigrationInclusive: 47 }); - - const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` - PRAGMA table_info(projection_projects) - `; - const projectIcon = columns.find((column) => column.name === "project_icon_json"); - - assert.equal(projectIcon?.name, "project_icon_json"); - assert.equal(projectIcon?.notnull, 0); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.effect-cancellation.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.effect-cancellation.test.ts deleted file mode 100644 index ad5d492fc06e..000000000000 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.effect-cancellation.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("055_OrchestrationV2 effect cancellation", (it) => { - it.effect("creates the effect outbox with the cancelled terminal status", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations(); - yield* sql` - INSERT INTO orchestration_v2_effect_outbox ( - effect_id, - command_id, - thread_id, - effect_type, - payload_json, - status, - attempt_count, - available_at, - created_at, - updated_at, - completed_at - ) VALUES ( - 'effect:cancelled', - 'command:cancelled', - 'thread:cancelled', - 'provider-turn.start', - '{"type":"provider-turn.start","runId":"run:cancelled"}', - 'cancelled', - 1, - '2026-06-20T00:00:00.000Z', - '2026-06-20T00:00:00.000Z', - '2026-06-20T00:01:00.000Z', - '2026-06-20T00:01:00.000Z' - ) - `; - - const rows = yield* sql<{ - readonly effect_id: string; - readonly status: string; - readonly attempt_count: number; - }>` - SELECT effect_id, status, attempt_count - FROM orchestration_v2_effect_outbox - `; - assert.deepStrictEqual(rows, [ - { - effect_id: "effect:cancelled", - status: "cancelled", - attempt_count: 1, - }, - ]); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts deleted file mode 100644 index cd926c899d97..000000000000 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { migrationEntries, runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("055_OrchestrationV2", (it) => { - it.effect("keeps released migrations contiguous", () => - Effect.sync(() => { - assert.deepStrictEqual( - migrationEntries.map(([id]) => id), - Array.from({ length: 66 }, (_, index) => index + 1), - ); - }), - ); - - it.effect("upgrades released schema 53 through the latest migrations", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 53 }); - - const executed = yield* runMigrations(); - assert.deepStrictEqual(executed, [ - [54, "ProjectionThreadsAutoSettleDisabledAt"], - [55, "OrchestrationV2"], - [56, "RemoveRedundantProjectionIndexes"], - [57, "ScheduledTaskWebhooks"], - [58, "WebhookRelayDeliveries"], - [59, "McpAppModelContext"], - [60, "ThreadSnapshotWindowIndexes"], - [61, "PullRequestReviewGuides"], - [62, "ThreadFailureLookupIndex"], - [63, "DropUnusedV1Projections"], - [64, "CanonicalizeThreadPullRequestLinks"], - [65, "CodexUsageNativeTurnIndex"], - [66, "RepairClientSettlementTimestamps"], - ]); - assert.deepStrictEqual(yield* runMigrations(), []); - - const migrations = yield* sql<{ - readonly migration_id: number; - readonly name: string; - }>` - SELECT migration_id, name - FROM effect_sql_migrations - WHERE migration_id >= 48 - ORDER BY migration_id - `; - assert.deepStrictEqual(migrations, [ - { migration_id: 48, name: "ProjectionThreadBranchPullRequest" }, - { migration_id: 49, name: "ProjectionThreadsActiveOrderKey" }, - { migration_id: 50, name: "ProjectionThreadPullRequests" }, - { migration_id: 51, name: "ProjectionThreadMessageContext" }, - { migration_id: 52, name: "ProjectionThreadTitleState" }, - { migration_id: 53, name: "PullRequestFilesViewed" }, - { migration_id: 54, name: "ProjectionThreadsAutoSettleDisabledAt" }, - { migration_id: 55, name: "OrchestrationV2" }, - { migration_id: 56, name: "RemoveRedundantProjectionIndexes" }, - { migration_id: 57, name: "ScheduledTaskWebhooks" }, - { migration_id: 58, name: "WebhookRelayDeliveries" }, - { migration_id: 59, name: "McpAppModelContext" }, - { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, - { migration_id: 61, name: "PullRequestReviewGuides" }, - { migration_id: 62, name: "ThreadFailureLookupIndex" }, - { migration_id: 63, name: "DropUnusedV1Projections" }, - { migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" }, - { migration_id: 65, name: "CodexUsageNativeTurnIndex" }, - { migration_id: 66, name: "RepairClientSettlementTimestamps" }, - ]); - - const tables = yield* sql<{ readonly name: string }>` - SELECT name - FROM sqlite_master - WHERE type = 'table' - AND name IN ( - 'orchestration_v2_projection_threads', - 'orchestration_v2_projection_subagents', - 'orchestration_v2_effect_outbox', - 'orchestration_v2_turn_item_positions', - 'orchestration_v2_projection_metadata', - 'orchestration_v2_projection_provider_session_bindings', - 'orchestration_v2_thread_launch_workflows', - 'orchestration_v2_legacy_imports', - 'scheduled_tasks' - ) - ORDER BY name - `; - assert.deepStrictEqual( - tables.map(({ name }) => name), - [ - "orchestration_v2_effect_outbox", - "orchestration_v2_legacy_imports", - "orchestration_v2_projection_metadata", - "orchestration_v2_projection_provider_session_bindings", - "orchestration_v2_projection_subagents", - "orchestration_v2_projection_threads", - "orchestration_v2_thread_launch_workflows", - "orchestration_v2_turn_item_positions", - "scheduled_tasks", - ], - ); - - const eventColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(orchestration_events) - `; - const receiptColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(orchestration_command_receipts) - `; - const threadColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(orchestration_v2_projection_threads) - `; - const subagentColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(orchestration_v2_projection_subagents) - `; - assert.ok(eventColumns.some(({ name }) => name === "application_event_version")); - assert.ok(receiptColumns.some(({ name }) => name === "command_type")); - assert.ok(threadColumns.some(({ name }) => name === "provider_instance_id")); - assert.ok(subagentColumns.some(({ name }) => name === "driver")); - assert.ok(subagentColumns.some(({ name }) => name === "provider_instance_id")); - - const indexes = yield* sql<{ readonly name: string }>` - SELECT name - FROM sqlite_master - WHERE type = 'index' - AND name IN ( - 'idx_orchestration_events_application_high_water', - 'orchestration_events_v2_created_threads_idx', - 'orchestration_v2_projection_turn_items_shell_pending_idx' - ) - ORDER BY name - `; - assert.deepStrictEqual( - indexes.map(({ name }) => name), - [ - "idx_orchestration_events_application_high_water", - "orchestration_events_v2_created_threads_idx", - "orchestration_v2_projection_turn_items_shell_pending_idx", - ], - ); - }), - ); -}); - -it.effect("indexes batched Codex native turns without discarding duplicate candidates", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 64 }); - for (const [id, driver, nativeId] of [ - ["match", "codex", "requested"], - ["duplicate", "codex", "requested"], - ["second", "codex", "requested-second"], - ["other-provider", "claudeAgent", "requested"], - ["unrelated", "codex", "unrelated"], - ] as const) { - yield* sql`INSERT INTO orchestration_v2_projection_run_attempts ${sql.insert({ - attempt_id: `attempt-${id}`, - thread_id: `thread-${id}`, - run_id: `run-${id}`, - attempt_ordinal: 1, - root_node_id: `node-${id}`, - provider: driver, - provider_thread_id: `provider-thread-${id}`, - status: "completed", - payload_json: "{}", - })}`; - yield* sql`INSERT INTO orchestration_v2_projection_provider_turns ${sql.insert({ - provider_turn_id: `turn-${id}`, - thread_id: `thread-${id}`, - provider_thread_id: `provider-thread-${id}`, - node_id: `node-${id}`, - run_attempt_id: `attempt-${id}`, - ordinal: 1, - status: "completed", - payload_json: encodeJson({ nativeTurnRef: { driver, nativeId, strength: "strong" } }), - })}`; - } - const lookup = sql<{ readonly provider_turn_id: string }>` - SELECT turn.provider_turn_id - FROM orchestration_v2_projection_provider_turns AS turn - JOIN orchestration_v2_projection_run_attempts AS attempt - ON attempt.attempt_id = turn.run_attempt_id - AND attempt.thread_id = turn.thread_id - AND attempt.provider_thread_id = turn.provider_thread_id - WHERE json_extract(turn.payload_json, '$.nativeTurnRef.driver') = 'codex' - AND json_extract(turn.payload_json, '$.nativeTurnRef.nativeId') IN ( - SELECT value FROM json_each(${encodeJson(["requested", "requested-second", "missing"])}) - ) - `; - const before = (yield* lookup).map((row) => row.provider_turn_id).toSorted(); - assert.deepStrictEqual(before, ["turn-duplicate", "turn-match", "turn-second"]); - yield* runMigrations(); - assert.deepStrictEqual((yield* lookup).map((row) => row.provider_turn_id).toSorted(), before); - - const [statement, parameters] = lookup.compile(); - const plan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statement}`, - parameters, - ); - assert.isTrue( - plan.some(({ detail }) => - /SEARCH turn USING INDEX orchestration_v2_provider_turns_codex_native_id_idx\b/.test( - detail, - ), - ), - plan.map(({ detail }) => detail).join("\n"), - ); - assert.isFalse(plan.some(({ detail }) => /SCAN turn\b/.test(detail))); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), -); diff --git a/apps/server/src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.test.ts b/apps/server/src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.test.ts deleted file mode 100644 index 1b18002eed7c..000000000000 --- a/apps/server/src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); - -layer("056_RemoveRedundantProjectionIndexes", (it) => { - it.effect("keeps the covering indexes and removes their prefix indexes", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 55 }); - yield* runMigrations({ toMigrationInclusive: 56 }); - - const rows = yield* sql<{ readonly name: string }>` - SELECT name - FROM sqlite_master - WHERE type = 'index' - AND name LIKE 'idx_projection_%' - `; - const indexes = new Set(rows.map((row) => row.name)); - - for (const removed of [ - "idx_projection_threads_project_id", - "idx_projection_thread_messages_thread_created", - "idx_projection_thread_activities_thread_sequence", - "idx_projection_turns_thread_requested", - ]) { - assert.isFalse(indexes.has(removed), removed); - } - for (const covering of [ - "idx_projection_threads_project_deleted_created", - "idx_projection_thread_messages_thread_created_id", - "idx_projection_thread_activities_thread_sequence_created_id", - "idx_projection_turns_thread_keyset", - ]) { - assert.isTrue(indexes.has(covering), covering); - } - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts b/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts new file mode 100644 index 000000000000..b132e7c6dd14 --- /dev/null +++ b/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN last_run_webhook_delivery_id TEXT`; + yield* sql`CREATE TABLE scheduled_task_webhook_replay_keys ( + task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE, + replay_key TEXT NOT NULL, + delivery_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (task_id, replay_key) + )`; + yield* sql`CREATE INDEX idx_webhook_replay_expiry ON scheduled_task_webhook_replay_keys(expires_at)`; + yield* sql`CREATE INDEX idx_webhook_replay_delivery ON scheduled_task_webhook_replay_keys(delivery_id)`; + yield* sql`CREATE TABLE scheduled_task_webhook_pending ( + delivery_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE, + coalesce_key TEXT NOT NULL, + due_at INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + task_json TEXT NOT NULL, + request_json TEXT NOT NULL, + delivery_ids_json TEXT NOT NULL + )`; + yield* sql`CREATE INDEX idx_webhook_pending_due ON scheduled_task_webhook_pending(state, due_at)`; + yield* sql`CREATE UNIQUE INDEX idx_webhook_pending_key + ON scheduled_task_webhook_pending(task_id, coalesce_key) WHERE state = 'pending'`; +}); diff --git a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts index 9c7f87b3c9b8..07f4fe5c9fbf 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts @@ -5,11 +5,8 @@ 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 Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; -import { runMigrations } from "./Migrations.ts"; -import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as OrchestrationEventStore from "./OrchestrationEventStore.ts"; import * as SqlitePersistence from "./Sqlite.ts"; @@ -207,126 +204,3 @@ it.effect( assert.isAbove(liveSequence, highWater); }).pipe(Effect.provide(Layer.fresh(layerEventStore))), ); - -it.effect("uses indexed high-water lookups for populated history without OR scans", () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - yield* runMigrations(); - yield* sql` - WITH RECURSIVE history(n) AS ( - SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 - ) - INSERT INTO orchestration_events ( - event_id, aggregate_kind, stream_id, stream_version, event_type, - occurred_at, actor_kind, payload_json, metadata_json, application_event_version - ) - SELECT 'history:' || n, 'thread', - CASE WHEN n = 1 THEN 'target' ELSE 'other' END, - n, 'provider-session.detached', ${occurredAt}, 'server', '{}', '{}', - CASE WHEN n % 3 = 0 THEN 1 ELSE 2 END - FROM history - `; - const statements: 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); - const query = span.attributes.get("db.query.text"); - if (typeof query === "string") statements.push(query); - }; - return span; - }, - }); - assert.equal(yield* store.latestApplicationSequence.pipe(Effect.withTracer(tracer)), 25_000); - assert.equal( - yield* store.latestAgentSequence(ThreadId.make("target")).pipe(Effect.withTracer(tracer)), - 1, - ); - assert.equal(statements.length, 2); - const applicationPlan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statements[0]}`, - ); - const agentPlan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statements[1]}`, - ["target"], - ); - assert.match( - applicationPlan.map((row) => row.detail).join("\n"), - /SEARCH orchestration_events USING INDEX idx_orchestration_events_application_high_water/, - ); - assert.match( - agentPlan.map((row) => row.detail).join("\n"), - /USING COVERING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/, - ); - for (const row of [...applicationPlan, ...agentPlan]) { - assert.notMatch(row.detail, /MULTI-INDEX OR|TEMP B-TREE/); - } - }).pipe( - Effect.provide( - OrchestrationEventStore.layer.pipe( - Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), - ), - ), - ), -); - -it.effect("replays a command's events through the command index, not the sequence range", () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - yield* runMigrations(); - yield* sql` - WITH RECURSIVE history(n) AS ( - SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 - ) - INSERT INTO orchestration_events ( - event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, - command_id, actor_kind, payload_json, metadata_json, application_event_version - ) - SELECT 'history:' || n, 'thread', 'thread', n, 'provider-session.detached', ${occurredAt}, - CASE WHEN n IN (12, 24990) THEN 'retried-command' ELSE 'command:' || n END, - 'server', - '{"providerSessionId":"session","detachedAt":"' || ${occurredAt} || '"}', - '{}', 2 - FROM history - `; - const statements: 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); - const query = span.attributes.get("db.query.text"); - if (typeof query === "string") statements.push(query); - }; - return span; - }, - }); - const replayed = yield* store - .readAgentEvents({ commandId: CommandId.make("retried-command") }) - .pipe(Stream.runCollect, Effect.withTracer(tracer)); - assert.deepEqual( - replayed.map((event) => event.sequence), - [12, 24990], - ); - assert.equal(statements.length, 1); - const plan = yield* sql.unsafe<{ readonly detail: string }>( - `EXPLAIN QUERY PLAN ${statements[0]}`, - [0, Number.MAX_SAFE_INTEGER, "retried-command", 500], - ); - assert.match( - plan.map((row) => row.detail).join("\n"), - /SEARCH orchestration_events USING INDEX idx_orch_events_command_id \(command_id=\?/, - ); - }).pipe( - Effect.provide( - OrchestrationEventStore.layer.pipe( - Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), - ), - ), - ), -); diff --git a/apps/server/src/persistence/OrchestrationEventStore.test.ts b/apps/server/src/persistence/OrchestrationEventStore.test.ts index b6c8be596052..baad3d5c52a0 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.test.ts @@ -1,5 +1,3 @@ -import * as NodeV8 from "node:v8"; - import { CommandId, EventId, @@ -297,124 +295,6 @@ layer("OrchestrationEventStore", (it) => { }), ); - it.effect("stores json columns as strings and replays CLI-origin events", () => - Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = "2026-01-01T00:00:00.000Z"; - - const appended = yield* eventStore.appendProjectEvent({ - type: "project.created", - eventId: EventId.make("evt-store-roundtrip"), - aggregateKind: "project", - aggregateId: ProjectId.make("project-roundtrip"), - occurredAt: now, - commandId: CommandId.make("cmd-store-roundtrip"), - causationEventId: null, - correlationId: CommandId.make("cmd-store-roundtrip"), - metadata: { - adapterKey: "codex", - origin: { - surface: "cli", - }, - }, - payload: { - projectId: ProjectId.make("project-roundtrip"), - title: "Roundtrip Project", - workspaceRoot: "/tmp/project-roundtrip", - defaultModelSelection: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - const storedRows = yield* sql<{ - readonly payloadJson: string; - readonly metadataJson: string; - }>` - SELECT - payload_json AS "payloadJson", - metadata_json AS "metadataJson" - FROM orchestration_events - WHERE event_id = ${appended.eventId} - `; - assert.equal(storedRows.length, 1); - assert.equal(typeof storedRows[0]?.payloadJson, "string"); - assert.equal(typeof storedRows[0]?.metadataJson, "string"); - - const replayed = yield* eventStore - .readApplicationEvents({ - afterSequence: appended.sequence - 1, - throughSequence: appended.sequence, - }) - .pipe( - Stream.runCollect, - Effect.map((chunk) => Array.from(chunk)), - ); - assert.equal(replayed.length, 1); - const [project] = replayed; - if (project === undefined || !("aggregateKind" in project)) { - return assert.fail("Expected a project event"); - } - assert.equal(project.type, "project.created"); - assert.equal(project.metadata.adapterKey, "codex"); - assert.deepEqual(project.metadata.origin, { surface: "cli" }); - }), - ); - - it.effect("fails with PersistenceDecodeError when stored json is invalid", () => - Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = "2026-01-01T00:00:00.000Z"; - - yield* sql` - INSERT INTO orchestration_events ( - event_id, - aggregate_kind, - stream_id, - stream_version, - event_type, - occurred_at, - command_id, - causation_event_id, - correlation_id, - actor_kind, - payload_json, - metadata_json - ) - VALUES ( - ${EventId.make("evt-store-invalid-json")}, - ${"project"}, - ${ProjectId.make("project-invalid-json")}, - ${0}, - ${"project.created"}, - ${now}, - ${CommandId.make("cmd-store-invalid-json")}, - ${null}, - ${null}, - ${"server"}, - ${"{"}, - ${"{}"} - ) - `; - - const replayResult = yield* Effect.result( - Stream.runCollect( - eventStore.readApplicationEvents({ - afterSequence: 0, - throughSequence: Number.MAX_SAFE_INTEGER, - }), - ), - ); - assert.equal(replayResult._tag, "Failure"); - if (replayResult._tag === "Failure") { - assert.ok(isPersistenceDecodeError(replayResult.failure)); - } - }), - ); - it.effect("orders project and V2 agent events in the retained application event source", () => Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -750,55 +630,3 @@ it.effect.each(["high-water", "replay"] as const)( }), ).pipe(Effect.provide(Layer.fresh(layerTest))), ); - -it.effect("releases consumed application replay pages", () => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const afterSequence = yield* store.latestApplicationSequence; - yield* Effect.forEach( - Array.from({ length: 1501 }, (_, index) => index), - (index) => - store.appendProjectEvent({ - type: "project.created", - eventId: EventId.make(`retention-${index}`), - aggregateKind: "project", - aggregateId: ProjectId.make(`retention-${index}`), - occurredAt: "2026-09-08T00:00:00Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - payload: { - projectId: ProjectId.make(`retention-${index}`), - title: "Replay", - workspaceRoot: "/tmp/replay", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-09-08T00:00:00Z", - updatedAt: "2026-09-08T00:00:00Z", - }, - }), - { discard: true }, - ); - // oxlint-disable-next-line typescript/no-extraneous-class -- Counts retained page markers after full GC. - class ReplayPage {} - let count = 0; - yield* store - .readApplicationEvents({ - afterSequence, - throughSequence: yield* store.latestApplicationSequence, - }) - .pipe( - Stream.runForEach((event) => - Effect.sync(() => { - if (count % 500 === 0) { - Object.assign(event, { replayPage: new ReplayPage() }); - assert.isAtMost(NodeV8.queryObjects(ReplayPage, { format: "count" }), 1); - } - count++; - }), - ), - ); - assert.equal(count, 1501); - }).pipe(Effect.provide(layerTest)), -); diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index aaa387ed1379..58895755ff98 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off -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"; @@ -10,62 +9,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; -import { WAL_SIZE_LIMIT_BYTES } from "./Sqlite.ts"; import * as SqlitePersistence from "./Sqlite.ts"; -const lockHolderSource = ` -const { DatabaseSync } = require("node:sqlite"); -const db = new DatabaseSync(process.argv[1]); -db.exec("BEGIN IMMEDIATE"); -process.stdout.write("locked\\n"); -setTimeout(() => { - db.exec("COMMIT"); - db.close(); -}, Number(process.argv[2])); -`; - -const spawnWriteLockHolder = (dbPath: string, holdMs: number) => - Effect.promise( - () => - new Promise((resolve, reject) => { - const holder = NodeChildProcess.spawn( - process.execPath, - ["-e", lockHolderSource, dbPath, String(holdMs)], - { stdio: ["ignore", "pipe", "ignore"] }, - ); - holder.stdout.once("data", () => resolve()); - holder.on("error", reject); - holder.on("exit", () => - reject(new Error("lock holder exited before acquiring the write lock")), - ); - }), - ); - -it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); - const dbPath = NodePath.join(tempDir, "state.sqlite"); - - return Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; - yield* spawnWriteLockHolder(dbPath, 300); - yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; - const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; - assert.deepEqual([...rows], [{ id: 1 }]); - }).pipe( - Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), - Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), - ); -}); - -it.effect("limits the WAL file size in the shared persistence setup", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const rows = yield* sql<{ readonly journal_size_limit: number }>`PRAGMA journal_size_limit`; - assert.equal(rows[0]?.journal_size_limit, WAL_SIZE_LIMIT_BYTES); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); - it.effect("drops planner statistics a database already has", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-stats-")); const dbPath = NodePath.join(tempDir, "state.sqlite"); @@ -107,35 +52,3 @@ it.effect("drops planner statistics a database already has", () => { Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); - -it.effect("only fsyncs the database file at WAL checkpoints", () => { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-sync-")); - const dbPath = NodePath.join(tempDir, "state.sqlite"); - - return Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const rows = yield* sql<{ readonly synchronous: number }>`PRAGMA synchronous`; - assert.equal(rows[0]?.synchronous, 1); - }).pipe( - Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), - Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), - ); -}); - -it.effect("applies busy_timeout in the shared persistence setup", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; - assert.equal(rows[0]?.timeout, 5000); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); - -it.effect("enables checkpoint_fullfsync in the shared persistence setup", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const rows = yield* sql<{ - readonly checkpoint_fullfsync: number; - }>`PRAGMA checkpoint_fullfsync`; - assert.equal(rows[0]?.checkpoint_fullfsync, 1); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 74acb9ce1a35..3be3ec5a7975 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -9,7 +9,7 @@ import { runMigrations } from "./Migrations.ts"; import { initializeV2Database } from "./initializeV2Database.ts"; import * as ServerConfig from "../config.ts"; -export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; +const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; const layerSetup = Layer.effectDiscard( Effect.gen(function* () { diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index 5ee4158be978..9a978169e8c0 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -549,20 +549,6 @@ it.effect("includes committed WAL data and does not publish a failed snapshot", ); }); -it.effect("uses statev2.sqlite for default and explicit development paths", () => - Effect.gen(function* () { - for (const devUrl of [undefined, new URL("http://localhost:5173")]) { - for (const baseDirIsExplicit of [false, true]) { - const paths = yield* ServerConfig.deriveServerPaths("/tmp/t3", devUrl, { - baseDirIsExplicit, - }); - assert.equal(NodePath.basename(paths.dbPath), "statev2.sqlite"); - assert.equal(paths.settingsPath, NodePath.join(paths.stateDir, "settings.json")); - } - } - }).pipe(Effect.provide(NodeServices.layer)), -); - it.effect("starts fresh without V1 and never imports over existing V2 state", () => { const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-fresh-")); const destinationPath = NodePath.join(directory, "userdata", "statev2.sqlite"); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index f1495d1e62a4..990d450cde6b 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -46,6 +46,7 @@ describe("V2 preview upgrade", () => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -135,6 +136,7 @@ describe("V2 preview upgrade", () => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts deleted file mode 100644 index 92157d210fe2..000000000000 --- a/apps/server/src/preview/DesktopBrowserChannel.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Exercises the real inherited descriptor lifecycle in a subprocess. -import * as NodeChildProcess from "node:child_process"; -import * as NodeURL from "node:url"; - -import { expect, it } from "vite-plus/test"; - -it("receives desktop messages and exits while the parent keeps both input pipes open", async () => { - const child = NodeChildProcess.spawn( - process.execPath, - [NodeURL.fileURLToPath(new URL("./testing/DesktopPipeLifecycle.fixture.ts", import.meta.url))], - { stdio: ["ignore", "pipe", "pipe", "pipe", "pipe", "pipe", "pipe"] }, - ); - // @effect-diagnostics-next-line globalTimers:off -- Bounds the native subprocess on failure; success waits for process exit. - const watchdog = setTimeout(() => child.kill("SIGKILL"), 8_000); - let output = ""; - let errors = ""; - let verified = false; - child.stderr?.on("data", (chunk: Buffer) => { - errors += chunk.toString(); - }); - const write = (fd: number, value: Record) => { - const stream = child.stdio[fd]; - if (!stream || !("write" in stream)) throw new Error(`Missing pipe ${fd}`); - stream.write(`${JSON.stringify(value)}\n`); - }; - const key = { threadId: "thread-1", tabId: "tab-1" }; - child.stdout?.on("data", (chunk: Buffer) => { - output += chunk.toString(); - let end: number; - while ((end = output.indexOf("\n")) >= 0) { - const line = output.slice(0, end); - output = output.slice(end + 1); - if (line === "ready") { - write(3, { type: "attached", ...key }); - write(5, { version: 1, type: "desktopTelemetryHello", electronPid: process.pid }); - } else if (line === "attached") { - write(3, { type: "detached", ...key }); - } else if (line === "verified") { - verified = true; - } - } - }); - try { - const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( - (resolve, reject) => { - child.once("error", reject); - child.once("exit", (code, signal) => resolve({ code, signal })); - }, - ); - expect(errors).not.toContain("Error"); - expect(verified).toBe(true); - expect(result).toEqual({ code: 0, signal: null }); - } finally { - clearTimeout(watchdog); - if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); - for (const stream of child.stdio) stream?.destroy(); - } -}); diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index a3bac1941ec0..a3b058de1c75 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -2,11 +2,9 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; -import { PreviewUrlNormalizationError } from "@t3tools/shared/preview"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; -import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; @@ -96,36 +94,6 @@ it.layer(layer)("PreviewManager", (it) => { }), ); - it.effect("lets any client size, theme, and zoom a server tab", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const collector = yield* collectEvents; - const opened = yield* manager.open({ threadId, runtime: "server" }); - const viewport = { - _tag: "preset", - presetId: "iphone-12-pro", - width: 390, - height: 844, - } as const; - yield* manager.resize({ threadId, tabId: opened.tabId, viewport }); - const adjusted = yield* manager.adjust({ - threadId, - tabId: opened.tabId, - colorScheme: "dark", - zoomFactor: 1.25, - }); - expect(adjusted).toMatchObject({ viewport, colorScheme: "dark", zoomFactor: 1.25 }); - yield* manager.adjust({ threadId, tabId: opened.tabId, hardReload: true, clear: "cache" }); - const events = (yield* collector.drain).filter((event) => event.type === "resized"); - expect(events.at(-1)).toMatchObject({ - request: { hardReload: true, clear: "cache" }, - snapshot: { colorScheme: "dark", zoomFactor: 1.25 }, - }); - expect(events.at(-2)).not.toHaveProperty("request"); - }), - ); - it.effect("reissues presentation requests without replaying them on navigation", () => Effect.gen(function* () { const threadId = freshThreadId(); @@ -163,15 +131,6 @@ it.layer(layer)("PreviewManager", (it) => { }), ); - it.effect("opens an Idle tab when no URL is supplied", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const snapshot = yield* manager.open({ threadId }); - expect(snapshot.navStatus._tag).toBe("Idle"); - }), - ); - it.effect("orders list snapshots and events with one monotonic revision", () => Effect.gen(function* () { const threadId = freshThreadId(); @@ -198,90 +157,6 @@ it.layer(layer)("PreviewManager", (it) => { }), ); - it.effect("treats bare hosts as https", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const snapshot = yield* manager.open({ threadId, url: "example.com" }); - if (snapshot.navStatus._tag === "Loading") { - expect(snapshot.navStatus.url).toBe("https://example.com/"); - } - }), - ); - - it.effect("rejects empty URL with PreviewInvalidUrlError", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const error = yield* Effect.flip(manager.open({ threadId, url: " " })); - expect(error._tag).toBe("PreviewInvalidUrlError"); - expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); - expect(error).not.toHaveProperty("rawUrl"); - expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); - expect((error.cause as PreviewUrlNormalizationError).reason).toBe("empty"); - }), - ); - - it.effect("preserves URL parser failures as the invalid URL cause chain", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; - const error = yield* Effect.flip(manager.open({ threadId, url: rawUrl })); - - expect(error).toMatchObject({ - inputLength: rawUrl.length, - reason: "parse", - protocol: "https:", - }); - expect(error).not.toHaveProperty("rawUrl"); - expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); - const normalizationError = error.cause as PreviewUrlNormalizationError; - expect(normalizationError.cause).toBeInstanceOf(Error); - expect(error.message).not.toContain((normalizationError.cause as Error).message); - expect(error.message).not.toMatch(/user|password|access_token|secret|fragment/); - }), - ); - - it.effect("navigate updates snapshot and emits navigated", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const collector = yield* collectEvents; - - const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); - const snapshot = yield* manager.navigate({ - threadId, - tabId: opened.tabId, - url: "http://localhost:5173/about", - resolvedTitle: "About", - }); - - expect(snapshot.navStatus._tag).toBe("Success"); - if (snapshot.navStatus._tag === "Success") { - expect(snapshot.navStatus.url).toBe("http://localhost:5173/about"); - expect(snapshot.navStatus.title).toBe("About"); - } - const events = yield* collector.drain; - expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]); - }), - ); - - it.effect("navigate fails for unknown tab", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const error = yield* Effect.flip( - manager.navigate({ - threadId, - tabId: "tab_missing", - url: "http://localhost:5173", - }), - ); - expect(error._tag).toBe("PreviewSessionLookupError"); - }), - ); - it.effect("resizes a tab and preserves its viewport across navigation reports", () => Effect.gen(function* () { const threadId = freshThreadId(); @@ -323,20 +198,6 @@ it.layer(layer)("PreviewManager", (it) => { }), ); - it.effect("rejects resize for an unknown tab", () => - Effect.gen(function* () { - const manager = yield* PreviewManager.PreviewManager; - const error = yield* Effect.flip( - manager.resize({ - threadId: freshThreadId(), - tabId: "tab_missing", - viewport: { _tag: "fill" }, - }), - ); - expect(error._tag).toBe("PreviewSessionLookupError"); - }), - ); - it.effect( "rejects client status reports for server tabs without changing state or emitting events", () => @@ -377,54 +238,6 @@ it.layer(layer)("PreviewManager", (it) => { }), ); - it.effect("reportStatus emits failed for LoadFailed nav", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const collector = yield* collectEvents; - - const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); - yield* manager.reportStatus({ - threadId, - tabId: opened.tabId, - navStatus: { - _tag: "LoadFailed", - url: "http://localhost:5173", - title: "", - code: -105, - description: "ERR_NAME_NOT_RESOLVED", - }, - canGoBack: false, - canGoForward: false, - }); - - const events = yield* collector.drain; - const failed = events.find((e) => e.type === "failed"); - expect(failed?.type).toBe("failed"); - if (failed?.type === "failed") { - expect(failed.code).toBe(-105); - expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED"); - } - }), - ); - - it.effect("close removes the session and emits closed", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const collector = yield* collectEvents; - - yield* manager.open({ threadId, url: "http://localhost:5173" }); - yield* manager.close({ threadId }); - - const result = yield* manager.list({ threadId }); - expect(result.sessions).toHaveLength(0); - const events = yield* collector.drain; - const closed = events.find((e) => e.type === "closed"); - expect(closed?.type).toBe("closed"); - }), - ); - it.effect("gives every tab in a batch close its own monotonic revision", () => Effect.gen(function* () { const threadId = freshThreadId(); @@ -445,60 +258,6 @@ it.layer(layer)("PreviewManager", (it) => { }), ); - it.effect("close is idempotent for unknown threads", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - yield* manager.close({ threadId }); - const result = yield* manager.list({ threadId }); - expect(result.sessions).toHaveLength(0); - }), - ); - - it.effect("list returns every snapshot for the thread sorted by updatedAt", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const first = yield* manager.open({ threadId, url: "http://localhost:5173" }); - yield* TestClock.adjust("1 second"); - const second = yield* manager.open({ threadId, url: "http://localhost:3000" }); - yield* TestClock.adjust("1 second"); - yield* manager.navigate({ threadId, tabId: first.tabId, url: "http://localhost:5173/next" }); - const result = yield* manager.list({ threadId }); - expect(result.sessions.map((s) => s.tabId)).toEqual([second.tabId, first.tabId]); - }), - ); - - it.effect("open creates an independent tab on every call", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const collector = yield* collectEvents; - - const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); - const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" }); - - expect(a.tabId).not.toBe(b.tabId); - const list = yield* manager.list({ threadId }); - expect(list.sessions).toHaveLength(2); - - const events = yield* collector.drain; - expect(events.map((e) => e.type)).toEqual(["opened", "opened"]); - }), - ); - - it.effect("close with mismatching tabId is a no-op", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - yield* manager.open({ threadId, url: "http://localhost:5173" }); - yield* manager.close({ threadId, tabId: "tab_missing" }); - - const list = yield* manager.list({ threadId }); - expect(list.sessions).toHaveLength(1); - }), - ); - it.effect("close with explicit tabId removes only that tab", () => Effect.gen(function* () { const threadId = freshThreadId(); @@ -512,21 +271,4 @@ it.layer(layer)("PreviewManager", (it) => { expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]); }), ); - - it.effect("multiple subscribers receive every event independently", () => - Effect.gen(function* () { - const threadId = freshThreadId(); - const manager = yield* PreviewManager.PreviewManager; - const aSub = yield* manager.subscribeEvents; - const bSub = yield* manager.subscribeEvents; - - yield* manager.open({ threadId, url: "http://localhost:5173" }); - yield* manager.open({ threadId, url: "http://localhost:3000" }); - - const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT); - const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT); - expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]); - expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]); - }), - ); }); diff --git a/apps/server/src/preview/PdfDocumentNavigation.test.ts b/apps/server/src/preview/PdfDocumentNavigation.test.ts index dd5cff006ce6..f97dac0b4a0c 100644 --- a/apps/server/src/preview/PdfDocumentNavigation.test.ts +++ b/apps/server/src/preview/PdfDocumentNavigation.test.ts @@ -57,89 +57,6 @@ function fixture(nativePdfViewer = false) { } describe("PDF document navigation", () => { - it("keeps native PDF rendering when the browser supports it", async () => { - const test = fixture(true); - await installPdfDocumentNavigation(test.cdp); - expect(test.send.mock.calls.map(([method]) => method)).toEqual(["Runtime.evaluate"]); - }); - - it("commits the PDF URL for client rendering without redirects or server-side PDF rendering", async () => { - const test = fixture(); - await installPdfDocumentNavigation(test.cdp); - const { method, params } = await test.navigate(); - expect(method).toBe("Fetch.fulfillRequest"); - expect(params).toMatchObject({ responseCode: 200, requestId: "request-1" }); - expect(params.responseHeaders).toEqual([ - { name: "Content-Type", value: "text/html; charset=utf-8" }, - { name: "Cache-Control", value: "no-store" }, - { name: "Content-Security-Policy", value: "default-src 'none'; sandbox" }, - { name: "X-Content-Type-Options", value: "nosniff" }, - ]); - expect(Buffer.from(String(params.body), "base64").toString()).toBe( - "Study guide.PDF

Study guide.PDF

This PDF is displayed in the connected client.

", - ); - expect(test.send).toHaveBeenCalledWith("Fetch.enable", { - patterns: [ - { urlPattern: "*/api/assets/*", resourceType: "Document", requestStage: "Response" }, - ], - }); - }); - - it.each([401, 403, 404, 410, 500])( - "fails unavailable PDF assets with HTTP %s", - async (status) => { - const test = fixture(); - await installPdfDocumentNavigation(test.cdp); - expect(await test.navigate(source, "main", "GET", status)).toEqual({ - method: "Fetch.failRequest", - params: { requestId: "request-1", errorReason: "Failed" }, - }); - expect(test.send.mock.calls.some(([method]) => method === "Fetch.fulfillRequest")).toBe( - false, - ); - }, - ); - - it("rejects a non-PDF response instead of committing a successful PDF tab", async () => { - const test = fixture(); - await installPdfDocumentNavigation(test.cdp); - expect(await test.navigate(source, "main", "GET", 200, "text/html")).toMatchObject({ - method: "Fetch.failRequest", - }); - }); - - it("allows redirects to reach their asset response", async () => { - const test = fixture(); - await installPdfDocumentNavigation(test.cdp); - expect(await test.navigate(source, "main", "GET", 302)).toMatchObject({ - method: "Fetch.continueRequest", - }); - }); - - it("accepts a partial PDF response", async () => { - const test = fixture(); - await installPdfDocumentNavigation(test.cdp); - expect( - await test.navigate(source, "main", "GET", 206, "Application/PDF; charset=binary"), - ).toMatchObject({ - method: "Fetch.fulfillRequest", - }); - }); - - it.each([ - ["https://environment.test/api/assets/token/index.html", "main", "GET"], - ["https://example.com/paper.pdf", "main", "GET"], - [source, "subframe", "GET"], - [source, "main", "POST"], - ])("leaves other browser requests alone: %s, %s, %s", async (url, frameId, method) => { - const test = fixture(); - await installPdfDocumentNavigation(test.cdp); - expect(await test.navigate(url, frameId, method)).toEqual({ - method: "Fetch.continueRequest", - params: { requestId: "request-1" }, - }); - }); - it("escapes filenames in the browser document", async () => { const test = fixture(); await installPdfDocumentNavigation(test.cdp); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index b78c08b827b3..3ea5a4c810a3 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,5 +1,3 @@ -import * as NodeNet from "node:net"; - import { it as effectIt } from "@effect/vitest"; import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, @@ -7,19 +5,15 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as Clock from "effect/Clock"; 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 PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; -import * as Tracer from "effect/Tracer"; import { expect } from "vite-plus/test"; import { FetchHttpClient } from "effect/http"; @@ -40,10 +34,6 @@ const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (inpu }), ); -const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: processProbeFailure, -}); - const layerNoProc = FileSystem.layerNoop({}); const layerProbeFailure = ( @@ -62,17 +52,6 @@ const layerProbeFailure = ( ), ); -const layerTestPortDiscovery = PortScanner.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerNoProc, - layerTestProcessRunner, - Layer.succeed(HostProcess.Platform, "win32"), - FetchHttpClient.layer, - ), - ), -); - const LSOF_TEST_PORT = 43_123; const ownedScanner = (processIds: ReadonlyArray = [1234]) => @@ -114,131 +93,6 @@ const layerLsofScanner = (input: { ), ); -const openServer = ( - port: number, - onConnection: (socket: NodeNet.Socket) => void, -): Effect.Effect => - Effect.callback((resume) => { - const server = NodeNet.createServer(onConnection); - server.once("error", () => { - resume(Effect.succeed(null)); - }); - server.listen(port, "127.0.0.1", () => { - resume(Effect.succeed(server)); - }); - return Effect.sync(() => { - server.close(); - }); - }); - -const closeServer = (server: NodeNet.Server): Effect.Effect => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - }); - -const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( - onConnection: (socket: NodeNet.Socket) => void, -) { - const server = yield* openServer(0, onConnection); - const address = server?.address(); - if (!server || !address || typeof address === "string") { - return yield* Effect.die(new Error("Could not open the preview scanner test listener")); - } - return { port: address.port, server }; -}); - -const commonDevServer = Effect.acquireRelease( - openCommonDevServer((socket) => { - socket.once("data", () => { - socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); - }); - }), - ({ server }) => closeServer(server), -); - -const commonNonHttpServer = Effect.acquireRelease( - openCommonDevServer((socket) => { - socket.on("error", () => undefined); - socket.once("data", () => socket.end("MYSQL\r\n\r\n")); - }), - ({ server }) => closeServer(server), -); - -/** - * Integration tests against a real TCP listener. We provide the Windows host - * platform with a failing listener probe so the tests exercise configured URLs - * without depending on `lsof` being installed. - */ -effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => { - it.effect( - "scan() returns a configured HTTP server we just opened", - Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { - const { port } = yield* commonDevServer; - const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan([`http://localhost:${port}`]); - const found = result.find((server) => server.port === port); - expect(found).toBeDefined(); - expect(found?.host).toBe("localhost"); - }), - ); - - it.effect( - "scan() excludes a listening port that does not speak HTTP", - Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { - const { port } = yield* commonNonHttpServer; - const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan([`http://localhost:${port}`]); - expect(result.some((server) => server.port === port)).toBe(false); - }), - ); - - it.effect( - "retain drives an immediate broadcast to subscribers", - Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { - const { port } = yield* commonDevServer; - const received: number[] = []; - const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe( - { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] }, - (servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), - ); - yield* scanner.retain; - expect(received).toContain(port); - }), - ); -}); - -effectIt.effect("revalidates a successful HTML probe after its cache entry expires", () => { - let responds = true; - const requests: string[] = []; - const fetchFn = ((input: Parameters[0]) => { - requests.push(String(input)); - return responds - ? Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })) - : Promise.reject(new TypeError("not HTTP")); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - expect(yield* scanner.scan()).toHaveLength(1); - expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); - - responds = false; - yield* TestClock.adjust(Duration.seconds(15)); - expect(yield* scanner.scan()).toHaveLength(0); - expect(requests).toEqual([ - `http://localhost:${LSOF_TEST_PORT}/`, - `http://localhost:${LSOF_TEST_PORT}/`, - `https://localhost:${LSOF_TEST_PORT}/`, - ]); - }).pipe(Effect.provide(layer)); -}); - effectIt.effect("keeps a full configured URL when the discovered server root fails", () => { const requests: string[] = []; const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; @@ -408,26 +262,6 @@ effectIt.effect("stops probing a subscriber's configured paths after its scope c }).pipe(Effect.scoped, Effect.provide(layer)); }); -effectIt.effect("writes no poll span while no client retains the scanner", () => { - let pollSpans = 0; - const tracer = Tracer.make({ - span: (options) => { - if (options.name === "PortDiscovery.pollTick") pollSpans += 1; - return new Tracer.NativeSpan(options); - }, - }); - const layer = layerProbeFailure(processProbeFailure); - - return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; - yield* TestClock.adjust(Duration.seconds(15)); - expect(pollSpans).toBe(0); - - yield* scanner.retain; - expect(pollSpans).toBe(1); - }).pipe(Effect.scoped, Effect.provide(layer), Effect.withTracer(tracer)); -}); - effectIt.effect("uses the current configured fragment when readiness comes from cache", () => { const requests: string[] = []; const fetchFn = ((input: Parameters[0]) => { @@ -447,77 +281,6 @@ effectIt.effect("uses the current configured fragment when readiness comes from }).pipe(Effect.provide(layer)); }); -effectIt.effect("shares a configured root probe with discovered-root classification", () => { - const requests: string[] = []; - const rootUrl = `http://localhost:${LSOF_TEST_PORT}/`; - const fetchFn = ((input: Parameters[0]) => { - requests.push(String(input)); - return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - expect(yield* scanner.scan([rootUrl])).toHaveLength(1); - expect(requests).toEqual([rootUrl]); - - yield* TestClock.adjust(Duration.seconds(15)); - expect(yield* scanner.scan([rootUrl])).toHaveLength(1); - expect(requests).toEqual([rootUrl, rootUrl]); - }).pipe(Effect.provide(layer)); -}); - -effectIt.effect("starts fresh cache entries after the probing batch completes", () => - Effect.gen(function* () { - const baseClock = yield* Clock.Clock; - const times = [0, 20_000, 20_000, 20_000]; - let timeIndex = 0; - const currentTimeMillis = () => times[Math.min(timeIndex++, times.length - 1)]!; - const clock: Clock.Clock = { - ...baseClock, - currentTimeMillisUnsafe: currentTimeMillis, - currentTimeMillis: Effect.sync(currentTimeMillis), - }; - const requests: string[] = []; - const fetchFn = ((input: Parameters[0]) => { - requests.push(String(input)); - return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - - yield* Effect.gen(function* () { - const scanner = yield* ownedScanner(); - expect(yield* scanner.scan()).toHaveLength(1); - expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toHaveLength(1); - }).pipe(Effect.provide(layer), Effect.provideService(Clock.Clock, clock)); - }), -); - -effectIt.effect("caches a failed web probe until its bounded cache entry expires", () => { - let responds = false; - const requests: string[] = []; - const fetchFn = ((input: Parameters[0]) => { - requests.push(String(input)); - return responds - ? Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })) - : Promise.reject(new TypeError("not HTTP")); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - expect(yield* scanner.scan()).toHaveLength(0); - expect(yield* scanner.scan()).toHaveLength(0); - expect(requests).toHaveLength(2); - - responds = true; - yield* TestClock.adjust(Duration.seconds(15)); - expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toHaveLength(3); - }).pipe(Effect.provide(layer)); -}); - effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => { const redirects: Array = []; const fetchFn = (async ( @@ -618,56 +381,6 @@ effectIt.effect( }, ); -effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { - const aborted: string[] = []; - const fetchFn = (( - input: Parameters[0], - init?: Parameters[1], - ) => - new Promise((_resolve, reject) => { - const signal = init?.signal; - const onAbort = () => { - aborted.push(String(input)); - reject(new DOMException("Aborted", "AbortError")); - }; - if (signal?.aborted) { - onAbort(); - } else { - signal?.addEventListener("abort", onAbort, { once: true }); - } - })) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - const scanFiber = yield* Effect.forkChild(scanner.scan()); - yield* TestClock.adjust(Duration.seconds(2)); - expect(yield* Fiber.join(scanFiber)).toHaveLength(0); - expect(aborted).toEqual([ - `http://localhost:${LSOF_TEST_PORT}/`, - `https://localhost:${LSOF_TEST_PORT}/`, - ]); - }).pipe(Effect.provide(layer)); -}); - -effectIt.effect("does not swallow process probe defects", () => - Effect.gen(function* () { - const defect = new Error("unexpected process probe defect"); - const layer = layerProbeFailure(() => Effect.die(defect)); - - const exit = yield* Effect.flatMap(ownedScanner(), (scanner) => scanner.scan()).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.hasDies(exit.cause)).toBe(true); - expect(Cause.squash(exit.cause)).toBe(defect); - } - }), -); - const PROC_NET_TCP = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode 0: 0100007F:223D 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5001 1 0000000000000000 100 0 0 10 0 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 5002 1 0000000000000000 100 0 0 10 0 @@ -741,202 +454,6 @@ effectIt.effect( }, ); -effectIt.effect("keeps spawning lsof after a spawn failure that is not a missing command", () => { - let lsofSpawns = 0; - const layer = layerProbeFailure((input) => { - lsofSpawns += 1; - return Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cwd: input.cwd, - cause: PlatformError.systemError({ - _tag: "Unknown", - module: "ChildProcess", - method: "spawn", - description: "EAGAIN", - }), - }), - ); - }); - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - yield* scanner.scan(); - yield* scanner.scan(); - expect(lsofSpawns).toBe(2); - }).pipe(Effect.provide(layer)); -}); - -effectIt.effect("does not swallow process probe interruption", () => - Effect.gen(function* () { - const layer = layerProbeFailure(() => Effect.interrupt); - - const exit = yield* Effect.flatMap(ownedScanner(), (scanner) => scanner.scan()).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); - } - }), -); - -effectIt.effect.each(["linux", "darwin"] as const)( - "skips idle listener scans on %s and resumes when candidates exist", - (platform) => { - let processCalls = 0; - const layer = PortScanner.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerNoProc, - Layer.succeed(HostProcess.Platform, platform), - Layer.succeed(ProcessRunner.ProcessRunner, { - run: () => { - processCalls += 1; - return Effect.succeed({ - stdout: `p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }); - }, - }), - FetchHttpClient.layer.pipe( - Layer.provide( - Layer.succeed(FetchHttpClient.Fetch, () => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - ), - ), - ), - ), - ), - ), - ); - return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.retain; - yield* TestClock.adjust("9 seconds"); - expect(yield* scanner.scan()).toEqual([]); - expect(processCalls).toBe(0); - - yield* scanner.registerTerminalProcesses({ - threadId: "scanner-thread", - terminalId: "scanner-terminal", - processIds: [1234], - }); - expect(yield* scanner.scan()).toMatchObject([{ port: LSOF_TEST_PORT, pid: 1234 }]); - expect(processCalls).toBe(1); - - yield* scanner.unregisterTerminal({ - threadId: "scanner-thread", - terminalId: "scanner-terminal", - }); - yield* TestClock.adjust("9 seconds"); - expect(yield* scanner.scan()).toEqual([]); - expect(processCalls).toBe(1); - - expect(yield* scanner.scan([`http://localhost:${LSOF_TEST_PORT}/docs`])).toMatchObject([ - { port: LSOF_TEST_PORT, url: `http://localhost:${LSOF_TEST_PORT}/docs` }, - ]); - expect(processCalls).toBe(2); - }).pipe(Effect.provide(layer)); - }, -); - -effectIt.effect( - "parses Windows netstat listeners, names them via tasklist with retry, and skips spawns when idle", - () => { - const spawnedArgs: Array> = []; - let tasklistCalls = 0; - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; - const layer = PortScanner.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerNoProc, - Layer.succeed(ProcessRunner.ProcessRunner, { - run: (input) => { - spawnedArgs.push(input.args); - const isIpv6 = input.args.includes("tcpv6"); - const isTasklist = input.command === "tasklist.exe"; - const failTasklist = isTasklist && tasklistCalls++ === 0; - return Effect.succeed({ - stdout: isTasklist - ? '"node.exe","1234","Console","1","93,164 K"\r\n' - : isIpv6 - ? " TCP [::1]:43123 [::]:0 ABHÖREN 1234\r\n TCP [::1]:43123 [::1]:52000 HERGESTELLT 1234\r\n" - : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n TCP 127.0.0.1:5173 127.0.0.1:60123 ESTABLISHED 9999\r\n", - stderr: "", - code: failTasklist ? ChildProcessSpawner.ExitCode(1) : null, - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }); - }, - }), - Layer.succeed(HostProcess.Platform, "win32"), - FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), - ), - ), - ); - - return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; - expect(yield* scanner.scan()).toEqual([]); - expect(spawnedArgs).toHaveLength(0); - - yield* scanner.registerTerminalProcesses({ - threadId: "scanner-thread", - terminalId: "scanner-terminal", - processIds: [1234], - }); - const summarize = ( - servers: ReadonlyArray<{ - readonly port: number; - readonly pid: number | null; - readonly processName: string | null; - }>, - ) => - servers.map((entry) => ({ - port: entry.port, - pid: entry.pid, - processName: entry.processName, - })); - expect(summarize(yield* scanner.scan())).toEqual([ - { port: 5173, pid: 1234, processName: null }, - { port: 43123, pid: 1234, processName: null }, - ]); - expect(spawnedArgs).toEqual([ - ["-ano", "-p", "tcp"], - ["-ano", "-p", "tcpv6"], - ["/FO", "CSV", "/NH", "/FI", "PID eq 1234"], - ]); - - expect(summarize(yield* scanner.scan())).toEqual([ - { port: 5173, pid: 1234, processName: "node" }, - { port: 43123, pid: 1234, processName: "node" }, - ]); - yield* scanner.scan(); - expect(tasklistCalls).toBe(2); - - yield* TestClock.adjust(Duration.seconds(61)); - yield* scanner.scan(); - expect(tasklistCalls).toBe(3); - }).pipe(Effect.provide(layer)); - }, -); - effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { const fetchFn = (() => Promise.resolve( diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 3a83da1b7534..06c4d99544af 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -2,18 +2,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import type { HostLinuxLibc } from "@t3tools/shared/HostProcess"; -import * as Deferred from "effect/Deferred"; import * as Crypto from "effect/Crypto"; import type * as Duration from "effect/Duration"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as NodeZlib from "node:zlib"; @@ -370,92 +367,4 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { ); }), ); - - it.effect("shares one install between callers and finishes it after they stop waiting", () => - Effect.gen(function* () { - const firstChunkSent = yield* Deferred.make(); - const finishDownload = yield* Deferred.make(); - const { browser, requests } = yield* makeHarness({ - wait: "45 seconds", - body: Stream.concat( - Stream.make(browserArchive.subarray(0, 100)), - Stream.fromEffect( - Deferred.succeed(firstChunkSent, undefined).pipe( - Effect.andThen(Deferred.await(finishDownload)), - Effect.as(browserArchive.subarray(100)), - ), - ), - ), - }); - - const callers = yield* Effect.forEach([1, 2], () => - Effect.forkChild(browser.executable.pipe(Effect.flip)), - ); - yield* Deferred.await(firstChunkSent); - yield* TestClock.adjust("45 seconds"); - for (const caller of callers) { - expect(yield* Fiber.join(caller)).toMatchObject({ - _tag: "PreviewBrowserInstallingError", - downloadedBytes: 100, - unpacking: false, - }); - } - - yield* Deferred.succeed(finishDownload, undefined); - expect(yield* browser.executable).toMatch(new RegExp(`${executableName}$`)); - expect(requests).toHaveLength(1); - }), - ); - - it.effect("reports hosts Chrome for Testing does not build for without downloading", () => - Effect.gen(function* () { - const { browser, requests } = yield* makeHarness({ unsupported: true }); - expect((yield* browser.executable.pipe(Effect.flip))._tag).toBe( - "PreviewBrowserUnsupportedError", - ); - expect(yield* browser.installed).toEqual(Option.none()); - expect(requests).toEqual([]); - }), - ); -}); - -it("pins one Chrome for Testing build per host", () => { - const hosts = [ - ["linux", "x64"], - ["linux", "arm64"], - ["darwin", "arm64"], - ["darwin", "x64"], - ["win32", "x64"], - ["win32", "arm64"], - ["win32", "ia32"], - ["linux", "ia32"], - ["freebsd", "x64"], - ] as const; - expect( - hosts.map(([platform, arch]) => PreviewBrowser.previewBrowserRelease(platform, arch)?.platform), - ).toEqual([ - "linux64", - "linux-arm64", - "mac-arm64", - "mac-x64", - "win64", - "win64", - "win32", - undefined, - undefined, - ]); - expect(PreviewBrowser.previewBrowserRelease("linux", "x64")).toMatchObject({ - url: "https://storage.googleapis.com/chrome-for-testing-public/154.0.8037.92/linux64/chrome-headless-shell-linux64.zip", - bytes: 120_477_194, - }); -}); - -it("tells the agent how far the install has come", () => { - expect( - new PreviewBrowser.PreviewBrowserInstallingError({ - downloadedBytes: 37_200_000, - totalBytes: 120_477_194, - unpacking: false, - }).message, - ).toBe("T3 is installing its headless browser (37 of 120 MB downloaded). Try again in a minute."); }); diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index 622a7901da1a..2982167752b1 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -78,7 +78,7 @@ export interface PreviewBrowserRelease { readonly sha256: string; } -export const previewBrowserRelease = ( +const previewBrowserRelease = ( platform: NodeJS.Platform, arch: NodeJS.Architecture, ): PreviewBrowserRelease | null => { diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts index 8c6b8b47865d..b0b52a9362b9 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts @@ -17,8 +17,6 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) `; // Chrome's own abort on Ubuntu 26.04 with unprivileged user namespaces restricted. -const SANDBOX_ABORT = - "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/"; // What the real loader prints when the browser's `--version` cannot start it. const LOADER_ERROR = @@ -75,35 +73,6 @@ const diagnose = (input: { }; describe("diagnoseLaunchFailure", () => { - it.effect("turns Chrome's sandbox abort into the AppArmor steps", () => - Effect.gen(function* () { - const { error, commands } = yield* diagnose({ - platform: "linux", - output: SANDBOX_ABORT, - ldd: "", - }); - expect(error?._tag).toBe("PreviewBrowserSandboxError"); - expect(error?.message).toContain("Run `sudo t3 browser setup` on the host"); - expect(commands).toEqual([]); - }), - ); - - it.effect("tells a root server to drop root, not to run setup", () => - Effect.gen(function* () { - const { error, commands } = yield* diagnose({ - platform: "linux", - output: PreviewBrowserHost.NO_SANDBOX_SIGNATURE, - ldd: "", - uid: 0, - }); - expect(error?._tag).toBe("PreviewBrowserRootError"); - expect(error?.message).toContain("`User=`"); - expect(error?.message).toContain("`T3CODE_SERVER_BROWSER_SANDBOX=0`"); - expect(error?.message).not.toMatch(/AppArmor|browser setup/); - expect(commands).toEqual([]); - }), - ); - it.effect("names every library the loader cannot find", () => Effect.gen(function* () { const { error } = yield* diagnose({ @@ -131,19 +100,4 @@ describe("diagnoseLaunchFailure", () => { expect(error).toBeUndefined(); }), ); - - it.effect("leaves other failures alone", () => - Effect.gen(function* () { - const linux = yield* diagnose({ - platform: "linux", - output: "crashed", - ldd: "\tlibc.so.6 => /lib/libc.so.6\n", - version: "Google Chrome for Testing 154.0.8037.92\n", - }); - expect(linux.error).toBeUndefined(); - const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" }); - expect(mac.error).toBeUndefined(); - expect(mac.commands).toEqual([]); - }), - ); }); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2669b291cd8d..ac4a785f207f 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -13,9 +13,7 @@ import { type PreviewAutomationSnapshot, type PreviewAutomationStatus, } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; 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"; @@ -23,9 +21,7 @@ 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 Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import type { BrowserContext, Page } from "playwright-core"; import { beforeEach, expect, vi } from "vite-plus/test"; @@ -35,7 +31,6 @@ import * as Broker from "../mcp/PreviewAutomationBroker.ts"; import * as DesktopChannel from "./DesktopBrowserChannel.ts"; import * as Manager from "./Manager.ts"; import * as ServerBrowser from "./ServerBrowser.ts"; -import * as ServerBrowserContexts from "./ServerBrowserContexts.ts"; import * as PreviewBrowser from "./PreviewBrowser.ts"; vi.mock("./ServerBrowserContexts.ts", () => ({ @@ -314,13 +309,6 @@ const ready = Effect.gen(function* () { const tabId = PreviewTabId.make(opened.tabId!); return { browser, broker, tabId }; }); -const stallViewer = ( - viewer: ServerBrowser.ServerBrowserViewer, - item: ServerBrowser.ServerBrowserViewerOutput, -) => { - const output = viewer.output as unknown as Queue.Queue; - while (Queue.offerUnsafe(output, item)); -}; const viewerInput = (tabId: string, canOperate: boolean) => ({ threadId: scope.thread.threadId, tabId, @@ -345,71 +333,6 @@ beforeEach(() => { desktopConnections.length = 0; }); -it.live("an expired open still drains its initial navigation before human input", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const broker = yield* Broker.PreviewAutomationBroker; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - const committed = Promise.withResolvers(); - const events: string[] = []; - const clock = vi.spyOn(performance, "now").mockReturnValue(0); - yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore())); - yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve())); - const original = ServerBrowserContexts.ServerBrowserContexts.prototype.contextFor; - const create = vi - .spyOn(ServerBrowserContexts.ServerBrowserContexts.prototype, "contextFor") - .mockImplementation(async function ( - this: ServerBrowserContexts.ServerBrowserContexts, - profileId, - isolationKey, - ) { - const context = await original.call(this, profileId, isolationKey); - contexts.at(-1)!.page.goto.mockImplementationOnce(async () => { - clock.mockReturnValue(1000); - await committed.promise; - events.push("navigation committed"); - }); - return context; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => create.mockRestore())); - const expired = yield* broker - .invoke({ - scope, - operation: "open", - input: { url: "http://localhost:5173/slow", reuseExistingTab: false, show: false }, - timeoutMs: 1000, - }) - .pipe(Effect.flip); - expect(expired).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - expect(sessions).toHaveLength(1); - const tabId = PreviewTabId.make(sessions[0]!.tabId); - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* Queue.clear(viewer.output); - const takeover = yield* viewer.input({ type: "takeControl" }).pipe(Effect.forkScoped); - let control = yield* Queue.take(viewer.output); - while (control._tag !== "control" || control.controller !== "you") { - control = yield* Queue.take(viewer.output); - } - const cdp = contexts[0]!.sessions.at(-1)!; - const send = cdp.send.getMockImplementation()!; - cdp.send.mockImplementation(async (operation, input) => { - if (operation === "Input.insertText") events.push("human typed"); - return send(operation, input); - }); - const typing = yield* viewer.input({ type: "text", text: "hello" }).pipe(Effect.forkScoped); - yield* broker.invoke({ scope, tabId, operation: "status", input: {} }); - expect(events).toEqual([]); - committed.resolve(); - yield* Fiber.join(takeover); - yield* Fiber.join(typing); - expect(events).toEqual(["navigation committed", "human typed"]); - }), - ).pipe(Effect.provide(layer)), -); - it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => @@ -447,198 +370,6 @@ it.live.each(["desktop" as const, undefined])( ).pipe(Effect.provide(layer)), ); -it.live("an unknown explicit target fails without creating a server tab", () => - Effect.scoped( - Effect.gen(function* () { - const { broker } = yield* ready; - const manager = yield* Manager.PreviewManager; - const before = yield* manager.list({ threadId: scope.thread.threadId }); - const error = yield* broker - .invoke({ - scope, - tabId: PreviewTabId.make("missing-tab"), - operation: "open", - input: { url: "https://example.com/replacement", open: true }, - }) - .pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "PreviewAutomationTabNotFoundError", - tabId: "missing-tab", - }); - expect(yield* manager.list({ threadId: scope.thread.threadId })).toEqual(before); - expect(contexts).toHaveLength(1); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("an explicit server target reuses its ID and navigates without creating a tab", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const manager = yield* Manager.PreviewManager; - const before = yield* manager.list({ threadId: scope.thread.threadId }); - const opened = yield* broker.invoke({ - scope, - tabId, - operation: "open", - input: { url: "https://example.com/reused", open: false }, - }); - expect(opened).toMatchObject({ tabId, url: "https://example.com/reused" }); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - expect(sessions.map((session) => session.tabId)).toEqual( - before.sessions.map((session) => session.tabId), - ); - expect(contexts).toHaveLength(1); - expect(contexts[0]!.page.goto).toHaveBeenCalledWith( - "https://example.com/reused", - expect.anything(), - ); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("an unresolved implicit target still falls back to a new server tab", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const manager = yield* Manager.PreviewManager; - yield* manager.close({ threadId: scope.thread.threadId, tabId }); - const opened = yield* broker.invoke({ - scope, - operation: "open", - input: { url: "https://example.com/fallback", open: false }, - }); - expect(opened.tabId).not.toBe(tabId); - expect(opened).toMatchObject({ available: true, url: "https://example.com/fallback" }); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - expect(sessions).toEqual([ - expect.objectContaining({ tabId: opened.tabId, runtime: "server" }), - ]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("readiness none responds immediately but takeover input waits for navigation commit", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, broker, tabId } = yield* ready; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - const committed = Promise.withResolvers(); - const events: string[] = []; - contexts[0]!.page.goto.mockImplementationOnce(async () => { - await committed.promise; - events.push("navigation committed"); - }); - yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve())); - const response = yield* broker.invoke({ - scope, - tabId, - operation: "navigate", - input: { url: "http://localhost:5173/next", readiness: "none" }, - }); - expect(response.available).toBe(true); - expect(events).toEqual([]); - yield* Queue.clear(viewer.output); - const takeover = yield* viewer.input({ type: "takeControl" }).pipe(Effect.forkScoped); - let control = yield* Queue.take(viewer.output); - while (control._tag !== "control" || control.controller !== "you") { - control = yield* Queue.take(viewer.output); - } - const cdp = contexts[0]!.sessions.at(-1)!; - const send = cdp.send.getMockImplementation()!; - cdp.send.mockImplementation(async (operation, input) => { - if (operation === "Input.insertText") events.push("human typed"); - return send(operation, input); - }); - const typing = yield* viewer.input({ type: "text", text: "hello" }).pipe(Effect.forkScoped); - yield* broker.invoke({ scope, tabId, operation: "status", input: {} }); - expect(events).toEqual([]); - committed.resolve(); - yield* Fiber.join(takeover); - yield* Fiber.join(typing); - expect(events).toEqual(["navigation committed", "human typed"]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live.each([ - { - method: "goto" as const, - message: { type: "navigate", url: "http://localhost:5173/next" }, - handoff: "release", - }, - { method: "goBack" as const, message: { type: "history", delta: -1 }, handoff: "release" }, - { method: "goForward" as const, message: { type: "history", delta: 1 }, handoff: "release" }, - { method: "reload" as const, message: { type: "reload" }, handoff: "release" }, - { - method: "goto" as const, - message: { type: "navigate", url: "http://localhost:5173/next" }, - handoff: "disconnect", - }, -])( - "$handoff waits for viewer $method to commit before agent actions", - ({ method, message, handoff }) => - Effect.scoped( - Effect.gen(function* () { - const { browser, broker, tabId } = yield* ready; - const viewerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(viewerScope, Exit.void)); - const viewer = yield* browser - .attachViewer(viewerInput(tabId, true)) - .pipe(Effect.provideService(Scope.Scope, viewerScope)); - yield* viewer.input({ type: "takeControl" }); - const started = Promise.withResolvers(); - const committed = Promise.withResolvers(); - const events: string[] = []; - contexts[0]!.page[method].mockImplementationOnce(async () => { - started.resolve(); - await committed.promise; - events.push("navigation committed"); - }); - const navigate = yield* viewer.input(message).pipe(Effect.forkScoped); - yield* Effect.promise(() => started.promise); - const releasing = yield* ( - handoff === "disconnect" - ? Scope.close(viewerScope, Exit.void) - : viewer.input({ type: "releaseControl" }) - ).pipe(Effect.forkScoped); - yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve())); - // Drain runnable promise work while the navigation gate remains closed. - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - expect(status.control?.owner).toBe("agent"); - const cdp = contexts[0]!.sessions[0]!; - const send = cdp.send.getMockImplementation()!; - cdp.send.mockImplementation(async (operation, input) => { - if (operation === "Runtime.evaluate") events.push("agent acted"); - return send(operation, input); - }); - const resumed = yield* broker - .invoke({ - scope, - tabId, - operation: "evaluate", - input: { expression: "resumed()" }, - }) - .pipe(Effect.forkScoped); - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); - expect(events).toEqual([]); - committed.resolve(); - yield* Fiber.join(navigate); - yield* Fiber.join(releasing); - yield* Fiber.join(resumed); - expect(events).toEqual(["navigation committed", "agent acted"]); - const calls = contexts[0]!.page[method].mock.calls; - expect(calls[0]?.at(-1)).toMatchObject({ waitUntil: "commit", timeout: 15_000 }); - }), - ).pipe(Effect.provide(layer)), -); - it.live.each([ { method: "goto" as const, message: { type: "navigate", url: "http://10.255.255.1/" } }, { method: "goBack" as const, message: { type: "history", delta: -1 } }, @@ -659,13 +390,20 @@ it.live.each([ }); const navigation = yield* viewer.input(message).pipe(Effect.forkScoped); yield* Effect.promise(() => started.promise); + const cdp = contexts[0]!.sessions.at(-1)!; + const send = cdp.send.getMockImplementation()!; + const typed = Promise.withResolvers(); + cdp.send.mockImplementation(async (operation, input) => { + if (operation === "Input.insertText") typed.resolve(); + return send(operation, input); + }); const correction = yield* Effect.gen(function* () { yield* viewer.input({ type: "navigate", url: "http://localhost:5173/fixed" }); yield* viewer.input({ type: "text", text: "hello" }); }).pipe(Effect.forkScoped); yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve())); - // Check after runnable promises drain, without waiting for navigation or a timeout. - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + // The text reaches the page while the first navigation is still uncommitted. + yield* Effect.promise(() => typed.promise); expect(page.goto).toHaveBeenLastCalledWith( "http://localhost:5173/fixed", expect.objectContaining({ waitUntil: "commit" }), @@ -680,193 +418,6 @@ it.live.each([ ).pipe(Effect.provide(layer)), ); -it.live("a user-opened tab allows a corrected URL before its initial navigation commits", () => - Effect.scoped( - Effect.gen(function* () { - const { browser } = yield* ready; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - const started = Promise.withResolvers(); - const committed = Promise.withResolvers(); - yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve())); - initialNavigation = () => { - started.resolve(); - return committed.promise; - }; - const opened = yield* manager.open({ - threadId: scope.thread.threadId, - url: "http://localhost:5173/stalled", - runtime: "server", - }); - yield* Effect.promise(() => started.promise); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - const correction = yield* viewer - .input({ type: "navigate", url: "http://localhost:5173/fixed" }) - .pipe(Effect.forkScoped); - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); - const lastNavigationBeforeCommit = contexts[1]!.page.goto.mock.calls.at(-1); - committed.resolve(); - yield* Fiber.join(correction); - expect(lastNavigationBeforeCommit).toEqual([ - "http://localhost:5173/fixed", - expect.objectContaining({ waitUntil: "commit" }), - ]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("an expired queued click never reaches the page", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const started = Promise.withResolvers(); - const release = Promise.withResolvers>>(); - const metadata = { - url: "about:blank", - title: "test page", - loading: false, - visibleText: "", - interactiveElements: [], - }; - yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve(metadata))); - page.evaluate.mockImplementationOnce(async () => { - started.resolve(); - return release.promise; - }); - const click = vi.fn(async () => {}); - page.locator.mockReturnValue({ - scrollIntoViewIfNeeded: async () => {}, - boundingBox: async () => ({ x: 0, y: 0, width: 10, height: 10 }), - click, - } as never); - let now = performance.now(); - const clock = vi.spyOn(performance, "now").mockImplementation(() => now); - yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore())); - const snapshot = yield* broker - .invoke({ - scope, - tabId, - operation: "snapshot", - input: {}, - timeoutMs: 5000, - }) - .pipe(Effect.forkScoped); - yield* Effect.promise(() => started.promise); - const action = yield* broker - .invoke({ - scope, - tabId, - operation: "click", - input: { selector: "button" }, - timeoutMs: 1000, - }) - .pipe(Effect.exit, Effect.forkScoped); - // The later status reply marks delivery through the same request stream. - yield* broker.invoke({ scope, tabId, operation: "status", input: {} }); - now += 1000; - release.resolve(metadata); - yield* Fiber.join(snapshot); - const result = yield* Fiber.join(action); - expect(click).not.toHaveBeenCalled(); - const error = yield* Effect.flip(result); - expect(error).toMatchObject({ - _tag: "PreviewAutomationTimeoutError", - remoteTag: "PreviewAutomationTimeoutError", - }); - }), - ).pipe(Effect.provide(layer)), -); - -it.effect( - "a queued recording stop still runs after its deadline when a snapshot caller times out", - () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const encoder = { - evaluate: vi - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce({ mimeType: "video/webm", count: 1, bytes: 5 }) - .mockResolvedValueOnce("ZnJhbWU="), - close: vi.fn(async () => {}), - }; - const scratchPage = vi - .spyOn(ServerBrowserContexts.ServerBrowserContexts.prototype, "scratchPage") - .mockResolvedValue(encoder as unknown as Page); - yield* Effect.addFinalizer(() => Effect.sync(() => scratchPage.mockRestore())); - const recording = yield* broker.invoke({ - scope, - tabId, - operation: "recordingStart", - input: {}, - }); - expect(recording).toMatchObject({ recording: true }); - const session = contexts[0]!.sessions.at(-1)!; - expect(session.send).toHaveBeenCalledWith("Page.startScreencast", expect.anything()); - - const started = Promise.withResolvers(); - const release = Promise.withResolvers>>(); - const metadata = { - url: "about:blank", - title: "test page", - loading: false, - visibleText: "", - interactiveElements: [], - }; - yield* Effect.addFinalizer(() => Effect.sync(() => release.resolve(metadata))); - page.evaluate.mockImplementationOnce(async () => { - started.resolve(); - return release.promise; - }); - let now = performance.now(); - const clock = vi.spyOn(performance, "now").mockImplementation(() => now); - yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore())); - const snapshot = yield* broker - .invoke({ - scope, - tabId, - operation: "snapshot", - input: {}, - timeoutMs: 150, - updateCurrentTab: false, - }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Effect.promise(() => started.promise); - const stop = yield* broker - .invoke({ - scope, - tabId, - operation: "recordingStop", - input: {}, - timeoutMs: 1000, - }) - .pipe(Effect.exit, Effect.forkScoped); - // The status reply marks delivery while the snapshot still holds the tab queue. - yield* broker.invoke({ scope, tabId, operation: "status", input: {} }); - yield* TestClock.adjust(150); - expect(yield* Fiber.join(snapshot)).toMatchObject({ - _tag: "PreviewAutomationTimeoutError", - }); - now += 1000; - expect(session.detach).not.toHaveBeenCalled(); - release.resolve(metadata); - yield* yield* Fiber.join(stop); - expect(session.detach).toHaveBeenCalledOnce(); - const stopped = yield* broker - .invoke({ scope, tabId, operation: "recordingStop", input: {} }) - .pipe(Effect.flip); - expect(stopped).toMatchObject({ - _tag: "PreviewAutomationExecutionError", - cause: { _tag: "PreviewAutomationRecordingNotActiveError" }, - }); - }), - ).pipe(Effect.provide(layer)), -); - it.live("enforces provider ownership and explicit targets when a session has multiple tabs", () => Effect.scoped( Effect.gen(function* () { @@ -982,37 +533,6 @@ it.live("streams to a read-only viewer without allowing takeover, input, or view ).pipe(Effect.provide(layer)), ); -it.live("applies any client's viewport, appearance, and zoom to a headless tab", () => - Effect.scoped( - Effect.gen(function* () { - const { tabId } = yield* ready; - const manager = yield* Manager.PreviewManager; - const page = contexts[0]!.page; - const session = contexts[0]!.sessions[0]!; - const target = { threadId: scope.thread.threadId, tabId }; - yield* manager.resize({ ...target, viewport: { _tag: "freeform", width: 390, height: 844 } }); - yield* manager.adjust({ ...target, colorScheme: "dark", zoomFactor: 1.25 }); - const reloaded = Promise.withResolvers(); - const send = session.send.getMockImplementation()!; - session.send.mockImplementation(async (method, input) => { - if (method === "Page.reload") reloaded.resolve(); - return send(method, input); - }); - yield* manager.adjust({ ...target, hardReload: true }); - yield* Effect.promise(() => reloaded.promise); - expect(session.send).toHaveBeenCalledWith("Page.reload", { ignoreCache: true }); - expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 }); - expect(page.emulateMedia).toHaveBeenCalledWith({ colorScheme: "dark" }); - expect(session.send).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { - width: 312, - height: 675, - deviceScaleFactor: 2.5, - mobile: false, - }); - }), - ).pipe(Effect.provide(layer)), -); - it.live( "takeover waits for the running agent and revokes snapshot refs before returning control", () => @@ -1153,139 +673,6 @@ it.live("reports a pending dialog without evaluating the page and resolves it ex ).pipe(Effect.provide(layer)), ); -it.live("the owner can close a tab while an agent action waits on its dialog", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const started = Promise.withResolvers(); - const finish = Promise.withResolvers>(); - contexts[0]!.sessions[0]!.send.mockImplementationOnce(async () => { - started.resolve(); - return finish.promise; - }); - page.on("close", () => finish.resolve({ result: { value: "closed" } })); - const running = yield* broker - .invoke({ - scope, - tabId, - operation: "evaluate", - input: { expression: 'confirm("Delete?")' }, - }) - .pipe(Effect.forkScoped); - yield* Effect.addFinalizer(() => - Effect.sync(() => finish.resolve({ result: { value: "cleanup" } })), - ); - yield* Effect.promise(() => started.promise); - page.emit("dialog", { - type: () => "confirm", - message: () => "Delete?", - defaultValue: () => "", - accept: vi.fn(), - dismiss: vi.fn(), - }); - const foreign = yield* broker - .invoke({ - scope: asSession("agent-b"), - tabId, - operation: "close", - input: {}, - }) - .pipe(Effect.flip); - expect(foreign).toMatchObject({ - _tag: "PreviewAutomationControlInterruptedError", - reason: "agentMismatch", - }); - expect(page.close).not.toHaveBeenCalled(); - yield* broker.invoke({ scope, tabId, operation: "close", input: {} }); - expect(yield* Fiber.join(running)).toBe("closed"); - expect(page.close).toHaveBeenCalled(); - const manager = yield* Manager.PreviewManager; - expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(0); - const afterClose = yield* broker - .invoke({ scope, tabId, operation: "evaluate", input: { expression: "late()" } }) - .pipe(Effect.flip); - expect(afterClose._tag).toBe("PreviewAutomationTabNotFoundError"); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a popup becomes the agent's own tab and keeps its opener page", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const opener = contexts[0]!.page; - const popup = makeContext(); - opener.emit("popup", popup.page); - const manager = yield* Manager.PreviewManager; - let sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions; - while (sessions.length < 2) { - yield* Effect.sleep("5 millis"); - sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions; - } - const popupTab = sessions.find((session) => session.tabId !== tabId)!; - const opened = sessions.find((session) => session.tabId === tabId)!; - expect(popupTab).toMatchObject({ automationOwner: opened.automationOwner, reveal: false }); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - expect(status.tabs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ tabId }), - expect.objectContaining({ tabId: popupTab.tabId, openerTabId: tabId }), - ]), - ); - expect(opener.goto).not.toHaveBeenCalled(); - expect(opener.close).not.toHaveBeenCalled(); - yield* Effect.promise(() => popup.page.close()); - while ((yield* manager.list({ threadId: scope.thread.threadId })).sessions.length > 1) { - yield* Effect.sleep("5 millis"); - } - }), - ).pipe(Effect.provide(layer)), -); - -it.live.each([ - { url: "https://environment.test/api/assets/token/guide.pdf", pdf: true }, - { url: "https://example.com/sign-in", pdf: false }, -])("an adopted popup at $url recovers only PDF asset navigation", ({ url, pdf }) => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - const events = yield* manager.subscribeEvents; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - yield* viewer.input({ type: "takeControl" }); - const popup = makeContext(); - yield* Effect.promise(() => popup.page.goto(url)); - popup.page.goto.mockClear(); - popup.page.reload.mockImplementationOnce(async () => { - popup.page.emit("load"); - }); - contexts[0]!.page.emit("popup", popup.page); - let shown = yield* Queue.take(viewer.output); - while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output); - yield* browser.attachViewer(viewerInput(shown.tabId, true)); - expect(popup.page.goto).not.toHaveBeenCalled(); - expect(popup.page.reload).toHaveBeenCalledTimes(pdf ? 1 : 0); - if (pdf) { - let event = yield* PubSub.take(events); - while (event.type !== "navigated" || event.tabId !== shown.tabId) { - event = yield* PubSub.take(events); - } - const session = (yield* manager.list({ threadId: scope.thread.threadId })).sessions.find( - (entry) => entry.tabId === shown.tabId, - ); - expect(session?.navStatus).toMatchObject({ _tag: "Success", url }); - } - }), - ).pipe(Effect.provide(layer)), -); - it.live("a tab opened to a file the browser cannot show reports the file to download", () => Effect.scoped( Effect.gen(function* () { @@ -1300,125 +687,23 @@ it.live("a tab opened to a file the browser cannot show reports the file to down const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true }; // What Chromium reports when a navigation turns into a download. page.emit("requestfailed", { - ...request, - frame: () => page, - failure: () => ({ errorText: "net::ERR_ABORTED" }), - }); - const saved = yield* Queue.unbounded(); - const written = Promise.withResolvers(); - page.emit("download", { - failure: async () => null, - delete: async () => { - // A new navigation during cleanup must not erase the settled download status. - page.emit("request", { - url: () => "https://example.com/next", - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - }); - }, - saveAs: (path: string) => { - Queue.offerUnsafe(saved, path); - return written.promise; - }, - suggestedFilename: () => "paper.pdf", - url: () => pdf, - }); - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); - written.resolve(); - const received = []; - let status; - do { - status = yield* PubSub.take(events); - received.push(status); - } while ( - status.type !== "navigated" || - status.snapshot.navStatus._tag !== "Loading" || - status.snapshot.navStatus.url !== "https://example.com/next" - ); - expect(received).toContainEqual( - expect.objectContaining({ - type: "failed", - url: pdf, - download: { id: expect.any(String), fileName: "paper.pdf" }, - }), - ); - expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( - false, - ); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a file a blank tab opened is offered to download if the tab moves on while it saves", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - yield* viewer.input({ type: "takeControl" }); - const page = contexts[0]!.page; - const pdf = "https://example.com/paper.pdf"; - const navigation = { - url: () => pdf, - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - failure: () => ({ errorText: "net::ERR_ABORTED" }), - }; - page.emit("request", navigation); - page.emit("requestfailed", navigation); - const saved = yield* Queue.unbounded(); - const written = Promise.withResolvers(); - page.emit("download", { - failure: async () => null, - delete: async () => {}, - saveAs: (path: string) => { - Queue.offerUnsafe(saved, path); - return written.promise; - }, - suggestedFilename: () => "paper.pdf", - url: () => pdf, - }); - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); - page.emit("request", { - url: () => "https://example.com/next", - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - }); - written.resolve(); - let offered = yield* Queue.take(viewer.output); - while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); - expect(offered).toMatchObject({ _tag: "download", fileName: "paper.pdf" }); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a download from a superseded navigation leaves the newer navigation loading", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const pdf = "https://example.com/paper.pdf"; - const navigation = { - url: () => pdf, - method: () => "GET", - isNavigationRequest: () => true, + ...request, frame: () => page, failure: () => ({ errorText: "net::ERR_ABORTED" }), - }; - page.emit("request", navigation); - page.emit("requestfailed", navigation); + }); const saved = yield* Queue.unbounded(); const written = Promise.withResolvers(); page.emit("download", { failure: async () => null, - delete: async () => {}, + delete: async () => { + // A new navigation during cleanup must not erase the settled download status. + page.emit("request", { + url: () => "https://example.com/next", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + }); + }, saveAs: (path: string) => { Queue.offerUnsafe(saved, path); return written.promise; @@ -1428,17 +713,27 @@ it.live("a download from a superseded navigation leaves the newer navigation loa }); const fs = yield* FileSystem.FileSystem; yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); - page.emit("request", { - url: () => "https://example.com/next", - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - }); written.resolve(); - const status = () => - broker.invoke({ scope, tabId, operation: "status", input: {} }); - while ((yield* status()).downloads?.length !== 1) yield* Effect.sleep("5 millis"); - expect((yield* status()).loading).toBe(true); + const received = []; + let status; + do { + status = yield* PubSub.take(events); + received.push(status); + } while ( + status.type !== "navigated" || + status.snapshot.navStatus._tag !== "Loading" || + status.snapshot.navStatus.url !== "https://example.com/next" + ); + expect(received).toContainEqual( + expect.objectContaining({ + type: "failed", + url: pdf, + download: { id: expect.any(String), fileName: "paper.pdf" }, + }), + ); + expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( + false, + ); }), ).pipe(Effect.provide(layer)), ); @@ -1471,86 +766,6 @@ it.live( ).pipe(Effect.provide(layer)), ); -it.live("an aborted navigation that no download explains stops loading", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const navigation = { - url: () => "https://example.com/cancelled", - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - failure: () => ({ errorText: "net::ERR_ABORTED" }), - }; - const status = () => - broker.invoke({ scope, tabId, operation: "status", input: {} }); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - page.emit("request", navigation); - page.emit("requestfailed", navigation); - expect((yield* status()).loading).toBe(true); - vi.advanceTimersByTime(5_000); - } finally { - vi.useRealTimers(); - } - expect((yield* status()).loading).toBe(false); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a popup from a person's click is shown to that person, with its opener kept", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false)); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - yield* viewer.input({ type: "takeControl" }); - const opener = contexts[0]!.page; - const popup = makeContext(); - opener.emit("popup", popup.page); - let shown = yield* Queue.take(viewer.output); - while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output); - const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions; - expect(sessions.map((session) => session.tabId)).toEqual( - expect.arrayContaining([opened.tabId, shown.tabId]), - ); - expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe( - false, - ); - expect(opener.close).not.toHaveBeenCalled(); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("closing a tab while a viewer is still opening it does not leave its page behind", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - contextFailure = new Error("first launch failed"); - const snapshot = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - yield* Effect.sleep("10 millis"); - contextFailure = null; - contextGate = Promise.withResolvers(); - const attaching = yield* browser - .attachViewer(viewerInput(snapshot.tabId, false)) - .pipe(Effect.flip, Effect.forkScoped); - yield* Effect.sleep("10 millis"); - yield* manager.close({ threadId: scope.thread.threadId, tabId: snapshot.tabId }); - yield* Effect.sleep("10 millis"); - contextGate.resolve(); - expect((yield* Fiber.join(attaching))._tag).toBe("ServerBrowserTabNotFoundError"); - expect(contexts).toHaveLength(1); - expect(contexts[0]!.page.close).toHaveBeenCalled(); - }), - ).pipe(Effect.provide(layer)), -); - it.live("only the controlling viewer's copy and cut keys reach its clipboard", () => Effect.scoped( Effect.gen(function* () { @@ -1588,38 +803,6 @@ it.live("only the controlling viewer's copy and cut keys reach its clipboard", ( ).pipe(Effect.provide(layer)), ); -it.live("copy keys reach the viewer from a frame in its own process", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* viewer.input({ type: "takeControl" }); - const copy = () => - viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 }).pipe( - Effect.andThen(Queue.clear(viewer.output)), - Effect.map((items) => items.filter((item) => item._tag === "clipboard")), - ); - const frame = makeFrame(); - contexts[0]!.page.emit("framenavigated", frame); - yield* Effect.promise(() => frame.recording.promise); - const frameSession = contexts[0]!.sessions.at(-1)!; - focusedFrame = frame; - keyCopy = "framed"; - expect(yield* copy()).toEqual([{ _tag: "clipboard", text: "framed" }]); - frame.copy = "left over"; - focusedFrame = null; - keyCopy = ""; - expect(yield* copy()).toEqual([]); - expect(frame.copy).toBe(""); - frame.detached = true; - frame.copy = "gone"; - frameSession.emit("close"); - expect(yield* copy()).toEqual([]); - expect(frame.copy).toBe("gone"); - }), - ).pipe(Effect.provide(layer)), -); - it.live.each([false, true])( "a page download is saved, offered to the controller, and listed for the agent (delete fails: %s)", (deleteFails) => @@ -1808,311 +991,6 @@ it.live("only the current controller can answer a file picker", () => ).pipe(Effect.provide(layer)), ); -it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const page = contexts[0]!.page; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* viewer.input({ type: "takeControl" }); - const accept = Promise.withResolvers(); - page.emit("filechooser", { - isMultiple: () => false, - element: () => ({ getAttribute: () => accept.promise }), - setFiles: async () => {}, - }); - yield* Queue.clear(viewer.output); - stallViewer(viewer, { _tag: "viewport", width: 1, height: 1 }); - accept.resolve(".csv"); - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); - expect(yield* Queue.clear(viewer.output)).toEqual([ - expect.objectContaining({ _tag: "fileChooser", accept: ".csv" }), - ]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("control updates replace a stalled viewer backlog in the order they happen", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* Queue.clear(viewer.output); - const acked = Promise.withResolvers(); - yield* Effect.addFinalizer(() => Effect.sync(() => acked.resolve())); - stallViewer(viewer, { - _tag: "frame", - data: Buffer.alloc(0), - ack: Effect.promise(() => acked.promise), - }); - yield* viewer.input({ type: "takeControl" }); - yield* viewer.input({ type: "releaseControl" }); - const controls = (yield* Queue.clear(viewer.output)).flatMap((item) => - item._tag === "control" ? [item.controller] : [], - ); - expect(controls[0]).toBe("you"); - expect(controls.at(-1)).not.toBe("you"); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("an agent's tabs stop at the limit until an unwatched idle tab closes", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const open = broker.invoke({ - scope, - operation: "open", - input: { reuseExistingTab: false, show: false }, - }); - for (let count = 1; count < 8; count += 1) yield* open; - expect(yield* Effect.flip(open)).toMatchObject({ - _tag: "PreviewAutomationControlInterruptedError", - reason: "tabLimit", - }); - yield* broker.invoke({ - scope: asSession("agent-b"), - operation: "open", - input: { reuseExistingTab: false, show: false }, - }); - const later = (yield* Clock.currentTimeMillis) + 31 * 60 * 1000; - const clock = vi.spyOn(Date, "now").mockReturnValue(later); - yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore())); - const browser = yield* ServerBrowser.ServerBrowser; - yield* browser.attachViewer(viewerInput(tabId, false)); - const reopened = yield* open; - const manager = yield* Manager.PreviewManager; - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - expect(sessions.map((session) => session.tabId).toSorted()).toEqual( - [tabId, reopened.tabId].toSorted(), - ); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("an idle agent tab the desktop renders stays open for the person browsing it", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* Broker.PreviewAutomationBroker; - yield* Effect.yieldNow; - const open = broker.invoke({ - scope, - operation: "open", - input: { reuseExistingTab: false, show: false }, - }); - desktopRendersNext = true; - const desktopTab = yield* open; - const headlessTab = yield* open; - const later = (yield* Clock.currentTimeMillis) + 31 * 60 * 1000; - const clock = vi.spyOn(Date, "now").mockReturnValue(later); - yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore())); - // Opening sweeps idle tabs first: only the unwatched headless one closes. - const reopened = yield* open; - const manager = yield* Manager.PreviewManager; - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - const openTabIds = sessions.map((session) => session.tabId); - expect(openTabIds).toContain(desktopTab.tabId); - expect(openTabIds).toContain(reopened.tabId); - expect(openTabIds).not.toContain(headlessTab.tabId); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("an agent answers the page's file picker or sets files on a file input", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const upload = (input: Record) => - broker.invoke({ scope, tabId, operation: "upload", input }); - expect(yield* Effect.flip(upload({ paths: ["/tmp/a.csv"] }))).toMatchObject({ - _tag: "PreviewAutomationExecutionError", - }); - const setFiles = vi.fn(async (_files: unknown) => {}); - page.emit("filechooser", { - isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), - setFiles, - }); - const status = broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - let opened = yield* status; - while (!opened.fileChooser) { - yield* Effect.sleep("5 millis"); - opened = yield* status; - } - expect(opened.fileChooser).toEqual({ multiple: false, accept: ".csv" }); - yield* Effect.flip(upload({ paths: ["/tmp/a.csv", "/tmp/b.csv"] })); - yield* upload({ paths: ["/tmp/a.csv"] }); - expect(setFiles).toHaveBeenCalledExactlyOnceWith(["/tmp/a.csv"], expect.anything()); - expect((yield* status).fileChooser).toBeNull(); - - const setInputFiles = vi.fn(async () => {}); - page.locator.mockReturnValue({ setInputFiles } as never); - yield* upload({ paths: ["/tmp/b.csv"], locator: "input[type=file]" }); - expect(page.locator).toHaveBeenLastCalledWith("input[type=file]"); - expect(setInputFiles).toHaveBeenCalledWith(["/tmp/b.csv"], expect.anything()); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("viewers see the agent's pointer move to its target and click there", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const viewer = yield* browser.attachViewer(viewerInput(tabId, false)); - const click = vi.fn(async () => {}); - page.locator.mockReturnValue({ - scrollIntoViewIfNeeded: async () => {}, - boundingBox: async () => ({ x: 100, y: 40, width: 80, height: 20 }), - click, - } as never); - yield* broker.invoke({ scope, tabId, operation: "click", input: { locator: "#go" } }); - const pointers = (yield* Queue.clear(viewer.output)).filter( - (item) => item._tag === "pointer", - ); - expect(pointers).toEqual([ - expect.objectContaining({ phase: "move", x: 140, y: 50 }), - expect.objectContaining({ phase: "click", x: 140, y: 50 }), - ]); - expect(click).toHaveBeenCalledOnce(); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("drives the desktop's own page for a tab the desktop renders", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const broker = yield* Broker.PreviewAutomationBroker; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - expect(desktopConnections.map((connection) => connection.endpoint)).toEqual([ - `ws://desktop/${opened.tabId}`, - ]); - expect(contexts).toEqual([]); - const page = desktopConnections[0]!.context.page; - yield* viewer.input({ type: "takeControl" }); - yield* viewer.input({ type: "resize", width: 390, height: 844 }); - expect(page.setViewportSize).not.toHaveBeenCalled(); - yield* viewer.input({ type: "releaseControl" }); - const evaluated = yield* broker.invoke({ - scope: asSession("agent-desktop"), - operation: "status", - input: {}, - tabId: PreviewTabId.make(opened.tabId), - }); - expect(evaluated).toMatchObject({ tabId: opened.tabId }); - yield* manager.close({ threadId: scope.thread.threadId, tabId: opened.tabId }); - while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow; - expect(releasedDesktopTabs).toEqual([opened.tabId]); - expect(page.close).not.toHaveBeenCalled(); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("desktop file pickers stay native outside agent actions and remote control", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const broker = yield* Broker.PreviewAutomationBroker; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const tabId = PreviewTabId.make(opened.tabId); - const observer = yield* browser.attachViewer(viewerInput(tabId, false)); - const page = desktopConnections[0]!.context.page; - page.openFileChooser(); - expect(page.nativeFileChooser).toHaveBeenCalledTimes(1); - - const status = () => - broker.invoke({ scope, tabId, operation: "status", input: {} }); - expect((yield* status()).fileChooser).toBeNull(); - const remote = yield* browser.attachViewer(viewerInput(tabId, true)); - page.openFileChooser(); - let offered = yield* Queue.take(remote.output); - while (offered._tag !== "fileChooser") offered = yield* Queue.take(remote.output); - const file = { name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a,b") }; - expect( - yield* browser.answerFileChooser({ - threadId: scope.thread.threadId, - tabId, - chooserId: offered.id, - files: [file], - }), - ).toBe(true); - expect(page.setChooserFiles).toHaveBeenLastCalledWith([file]); - expect(page.nativeFileChooser).toHaveBeenCalledTimes(1); - - yield* remote.input({ type: "releaseControl" }); - page.openFileChooser(); - expect(page.nativeFileChooser).toHaveBeenCalledTimes(2); - - page.locator.mockReturnValue({ - scrollIntoViewIfNeeded: async () => {}, - boundingBox: async () => null, - click: async () => page.openFileChooser(), - } as never); - yield* broker.invoke({ scope, tabId, operation: "click", input: { locator: "input" } }); - expect((yield* status()).fileChooser).toEqual({ multiple: false, accept: ".csv" }); - yield* broker.invoke({ scope, tabId, operation: "upload", input: { paths: ["/tmp/a.csv"] } }); - expect(page.setChooserFiles).toHaveBeenLastCalledWith(["/tmp/a.csv"], expect.anything()); - expect((yield* status()).fileChooser).toBeNull(); - page.openFileChooser(); - expect(page.nativeFileChooser).toHaveBeenCalledTimes(3); - page.locator.mockReturnValue({ - scrollIntoViewIfNeeded: async () => { - throw new Error("input disappeared"); - }, - } as never); - expect( - yield* Effect.flip( - broker.invoke({ scope, tabId, operation: "click", input: { locator: "input" } }), - ), - ).toMatchObject({ _tag: "PreviewAutomationExecutionError" }); - page.openFileChooser(); - expect(page.nativeFileChooser).toHaveBeenCalledTimes(4); - expect( - (yield* Queue.clear(observer.output)).some((item) => item._tag === "fileChooser"), - ).toBe(false); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a desktop page the desktop takes back reconnects instead of closing", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); - // The server's listener subscribes in its own fiber; detach once it is there. - while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; - desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); - let end = yield* Queue.take(viewer.output); - while (end._tag !== "reconnect" && end._tag !== "gone") - end = yield* Queue.take(viewer.output); - expect(end._tag).toBe("reconnect"); - expect(releasedDesktopTabs).toEqual([opened.tabId]); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - expect(sessions.map((session) => session.tabId)).toContain(opened.tabId); - yield* browser.attachViewer(viewerInput(opened.tabId, false)); - expect(desktopConnections).toHaveLength(2); - }), - ).pipe(Effect.provide(layer)), -); - it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => Effect.scoped( Effect.gen(function* () { @@ -2270,131 +1148,3 @@ it.live( }), ).pipe(Effect.provide(layer)), ); - -it.live("an agent tab keeps throwaway storage when no client reported profiles", () => - Effect.scoped( - Effect.gen(function* () { - yield* ready; - expect(contextRequests).toEqual([{ profileId: "default", isolated: true }]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a desktop page that comes back reconnects without waiting for a viewer", () => - Effect.scoped( - Effect.gen(function* () { - const manager = yield* Manager.PreviewManager; - yield* ServerBrowser.ServerBrowser; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - while (desktopConnections.length === 0) yield* Effect.yieldNow; - while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; - desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); - while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow; - desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId }); - while (desktopConnections.length < 2) yield* Effect.yieldNow; - expect(desktopConnections).toHaveLength(2); - }), - ).pipe(Effect.provide(layer)), -); - -// Chromium sizes a capture by the clip, its scale, and the display ratio net of page zoom. -it.live.each([ - { - name: "desktop at display ratio 1.5", - desktop: true, - page: { ratio: 1.5, width: 1280, height: 800, zoom: 1 }, - scrollY: 0, - size: { width: 1280, height: 800 }, - clipY: 0, - }, - { - name: "desktop resized to a phone", - desktop: true, - page: { ratio: 1, width: 390, height: 844, zoom: 1 }, - scrollY: 0, - size: { width: 390, height: 844 }, - clipY: undefined, - }, - { - name: "desktop zoom changes during a scrolled snapshot", - desktop: true, - page: { ratio: 3, width: 640, height: 400, zoom: 2 }, - scrollY: 100, - size: { width: 1280, height: 800 }, - clipY: 200, - }, - { - name: "headless", - desktop: false, - page: { ratio: 2, width: 1280, height: 800, zoom: 1 }, - scrollY: 0, - size: { width: 640, height: 400 }, - clipY: 0, - }, -])("snapshot captures the page's own viewport: $name", ({ desktop, page, scrollY, size, clipY }) => - Effect.scoped( - Effect.gen(function* () { - desktopRendersNext = desktop; - const { browser, broker, tabId } = yield* ready; - yield* browser.attachViewer(viewerInput(tabId, false)); - const context = desktop ? desktopConnections[0]!.context : contexts[0]!; - const display = desktop ? page.ratio / page.zoom : 1; - let zoom = 1; - const clips: Array<{ y: number } | undefined> = []; - let metricsReads = 0; - for (const session of context.sessions) { - const send = session.send.getMockImplementation()!; - session.send.mockImplementation(async (method, input) => { - if ( - method === "Runtime.evaluate" && - String((input as { expression?: string })?.expression).includes("devicePixelRatio") - ) { - metricsReads += 1; - const value = { - ratio: page.ratio * (zoom / page.zoom), - width: page.width * (page.zoom / zoom), - height: page.height * (page.zoom / zoom), - }; - // Desktop zoom applies after this renderer read, before the next layout reply. - zoom = page.zoom; - return { result: { value } }; - } - if (method === "Page.getLayoutMetrics") { - return { cssVisualViewport: { pageX: 0, pageY: scrollY, zoom } }; - } - if (method === "Page.captureScreenshot") { - const clip = ( - input as { clip?: { y: number; width: number; height: number; scale: number } } - )?.clip; - clips.push(clip); - const header = Buffer.alloc(24); - header.write("IHDR", 12); - header.writeUInt32BE( - Math.round((clip ? clip.width * clip.scale : page.width * page.zoom) * display), - 16, - ); - header.writeUInt32BE( - Math.round((clip ? clip.height * clip.scale : page.height * page.zoom) * display), - 20, - ); - return { data: header.toString("base64") }; - } - return send(method, input); - }); - } - const result = yield* broker.invoke({ - scope, - tabId, - operation: "snapshot", - input: {}, - }); - const png = Buffer.from(result.screenshot.data, "base64"); - expect(result.screenshot).toMatchObject(size); - expect({ width: png.readUInt32BE(16), height: png.readUInt32BE(20) }).toEqual(size); - expect(clips.map((clip) => clip?.y)).toEqual([clipY]); - if (!desktop) expect(metricsReads).toBe(0); - }), - ).pipe(Effect.provide(layer)), -); diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 2713821beb73..7a3babd0dcc0 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -210,22 +210,6 @@ describe("ServerBrowserContexts", () => { await pool.close(); }); - it("evicts a closed context without closing other agents or relaunching chromium", async () => { - const browser = makeBrowser(); - launches.launch.mockResolvedValue(browser as unknown as Browser); - const onContextClose = vi.fn(); - const pool = new ServerBrowserContexts({ ...options(), onContextClose }); - const a = await pool.contextFor("default", "agent-a"); - const b = await pool.contextFor("default", "agent-b"); - await a.close(); - expect(onContextClose).toHaveBeenCalledWith(a); - expect(await pool.contextFor("default", "agent-a")).not.toBe(a); - expect(await pool.contextFor("default", "agent-b")).toBe(b); - expect(browser.close).not.toHaveBeenCalled(); - expect(launches.launch).toHaveBeenCalledTimes(1); - await pool.close(); - }); - it("cleans up a browser whose launch finishes during shutdown and rejects future work", async () => { const browser = makeBrowser(); const launch = Promise.withResolvers(); diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index b1d4a43bf855..2491e6ac9a5f 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -7,7 +7,6 @@ import { } from "playwright-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; -import { presentAsChrome } from "./ServerBrowserContexts.ts"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; describe("server browser element refs", () => { @@ -215,27 +214,6 @@ describe("server browser element refs", () => { expect(tree).not.toContain("PAYLOAD"); }); - it("shows data: URLs in Chromium's console messages by media type, and page logs as written", async () => { - const logged: Array = []; - page.on("console", (message) => logged.push(ServerBrowserPage.consoleText(message))); - await page.setContent(` - - - `); - // 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"); @@ -244,16 +222,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"); @@ -261,13 +229,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) => { @@ -277,14 +238,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( '', @@ -292,101 +245,6 @@ describe("server browser element refs", () => { await ServerBrowserPage.click(page, { selector: 'button[data-example=" >> aria-ref=e1"]' }); 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 (;;) {}" }, 200), - ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" }); - expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2); - }); }); describe("server browser drag", () => { @@ -415,8 +273,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); @@ -427,60 +287,3 @@ describe("server browser drag", () => { } }); }); - -it("captures the native display viewport after page script reassigns the globals", async () => { - // Context deviceScaleFactor changes DPR but does not scale raw CDP captures. - const browser = await chromium.launch({ - headless: true, - args: ["--force-device-scale-factor=1.5", "--window-size=1280,800"], - }); - try { - const context = await browser.newContext({ viewport: null }); - const page = await context.newPage(); - const cdp = await context.newCDPSession(page); - await page.setContent( - "", - ); - expect(await page.evaluate("[devicePixelRatio, innerWidth, innerHeight]")).toEqual([ - -1.5, 99999, 1e7, - ]); - const snapshotSize = async () => { - const { screenshot } = await ServerBrowserPage.snapshot({ - page, - cdp, - renderScale: "display", - consoleEntries: [], - networkEntries: [], - actionTimeline: [], - }); - const png = Buffer.from(screenshot.data, "base64"); - const size = { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; - expect(screenshot).toMatchObject(size); - 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", { - width: 390, - height: 844, - 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 3be31f3ca5c1..02089cca3205 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -433,7 +433,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 b9cd7f66710c..a5971f010a57 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"; @@ -157,16 +157,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"); @@ -179,37 +169,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 0dedda030dcb..787a729f86b4 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,7 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches. -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"; @@ -11,13 +8,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"; @@ -99,34 +94,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", () => @@ -164,1058 +131,187 @@ it.effect.skipIf(windowsHost)( env: command.options.env, extendEnv: command.options.extendEnv, })), - ["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, - }), - ), + ["xdg-open", "code"].map((command) => ({ + command, + 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", @@ -1295,43 +391,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"]) { @@ -1578,63 +637,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(); @@ -1753,43 +755,3 @@ 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: "" } }))), -); 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 743d9a008d37..29a55ec22ba1 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,388 +939,80 @@ 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", () => { + it.effect("imports recent Claude and Codex sessions for the selected project only", () => 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-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const claudeTranscript = (cwd: string, sessionId: string) => + `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + timestamp: "2026-08-23T12:01:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`; + yield* writeTranscript({ - filePath: template, - contents, - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), + contents: claudeTranscript(workspace, "claude-recent"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, }); - 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", () => { - 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; - 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 otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); - - const claudeTranscript = (cwd: string, sessionId: string) => - `${JSON.stringify({ - type: "user", - cwd, - sessionId, - timestamp: "2026-08-23T12:00:00.000Z", - message: { role: "user", content: "Fix the project" }, - })}\n${JSON.stringify({ - type: "assistant", - sessionId, - timestamp: "2026-08-23T12:01:00.000Z", - message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, - })}\n`; - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), - contents: claudeTranscript(workspace, "claude-recent"), - mtimeMs: nowMs - 24 * 60 * 60 * 1000, - }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), - contents: claudeTranscript(workspace, "claude-old"), - mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), + contents: claudeTranscript(workspace, "claude-old"), + mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, }); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), @@ -1622,54 +1068,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - 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; @@ -1816,109 +1214,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; @@ -2142,595 +1437,47 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("imports visible history from a transcript with an oversized tool record", () => - 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, }); @@ -2738,60 +1485,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" }]); }), ); @@ -2839,88 +1618,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"]); - }), - ); }); }); @@ -3455,53 +2152,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", @@ -3527,26 +2177,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({ @@ -3561,32 +2191,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 3ff77c7dc1c8..86dbcc5beb61 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"; @@ -115,15 +109,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; @@ -548,36 +533,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 402cb56fad0e..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,110 +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, { - invalidate: () => Effect.void, - 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, { - invalidate: () => Effect.void, - 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(); @@ -228,46 +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, { - invalidate: () => Effect.void, - 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", () => @@ -401,90 +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, { - invalidate: () => Effect.void, - 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, { - invalidate: () => Effect.void, - 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/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 10f8fa4169bf..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,29 +49,6 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { - it.effect("serves repeated resolves from cache instead of re-walking candidates", () => - 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)); - - expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg")); - expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("finds a favicon added after a cached miss once the workspace is invalidated", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -106,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; @@ -194,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; @@ -275,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; @@ -329,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; @@ -451,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; @@ -526,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/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.test.ts b/apps/server/src/project/ProjectService.test.ts index a9fed49cff6a..9347acef12c8 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -203,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; @@ -486,112 +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, { - invalidate: () => Effect.void, - 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( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts deleted file mode 100644 index dcba984ffe97..000000000000 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { assert, it, vi } from "@effect/vitest"; -import { ProjectId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as TerminalManager from "../terminal/Manager.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as ProjectService from "./ProjectService.ts"; -import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; - -it.effect("resolves setup scripts through the standalone project service", () => { - const open = vi.fn((input: Parameters[0]) => - Effect.succeed({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "Shell", - updatedAt: "2026-06-20T00:00:00.000Z", - }), - ); - const write = vi.fn( - (_input: Parameters[0]) => Effect.void, - ); - const closeIdle = vi.fn( - (_input: Parameters[0]) => Effect.void, - ); - const listeners: Array[0]> = - []; - const subscribe: TerminalManager.TerminalManager["Service"]["subscribe"] = (listener) => - Effect.sync(() => { - listeners.push(listener); - return () => undefined; - }); - const projectId = ProjectId.make("project:setup-runner-v2"); - const project = { - id: projectId, - title: "Project", - workspaceRoot: "/repo", - repositoryIdentity: null, - faviconPath: null, - defaultModelSelection: null, - scripts: [ - { - id: "setup", - name: "Setup", - command: "vp install", - icon: "configure" as const, - runOnWorktreeCreate: true, - }, - { - id: "clean", - name: "Clean", - command: "cargo clean", - icon: "build" as const, - runOnWorktreeCreate: false, - runOnSettle: true, - }, - ], - createdAt: "2026-06-20T00:00:00.000Z", - updatedAt: "2026-06-20T00:00:00.000Z", - deletedAt: null, - }; - const layer = ProjectSetupScriptRunner.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeed(Option.some(project)), - }), - Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe, closeIdle }), - ServerSettings.layerTest(), - NodeCrypto.layer, - ), - ), - ); - - return Effect.gen(function* () { - const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const result = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - }); - assert.deepEqual(result, { - status: "started", - async: true, - scriptId: "setup", - scriptName: "Setup", - scriptCommand: "vp install", - terminalId: "setup-setup", - cwd: "/repo-worktree", - }); - assert.equal(open.mock.calls[0]?.[0].cwd, "/repo-worktree"); - assert.deepEqual(open.mock.calls[0]?.[0].env, { - T3CODE_PROJECT_ROOT: "/repo", - T3CODE_WORKTREE_PATH: "/repo-worktree", - COLORTERM: "", - NO_COLOR: "1", - FORCE_COLOR: "0", - }); - assert.equal(write.mock.calls[0]?.[0].data, " vp install\r"); - const lines: string[] = []; - const observed = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - observeCompletion: { - onOutputLine: (line) => - Effect.sync(() => { - lines.push(line); - }), - }, - }); - assert.equal(observed.status, "started"); - assert.match(write.mock.calls[1]?.[0].data ?? "", /^ \S/); - const listener = listeners[0]!; - yield* listener({ - type: "output", - threadId: "thread-1", - terminalId: "setup-setup", - data: "Downloading 10%\rDownloading 20%\r\nDone\n", - }); - assert.deepEqual(lines, ["Downloading 10%", "Downloading 20%", "Done"]); - yield* listener({ type: "closed", threadId: "thread-1", terminalId: "setup-setup" }); - - const settle = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - trigger: "settle", - }); - const settleTerminalId = settle.status === "started" ? settle.terminalId : ""; - assert.match(settleTerminalId, /^settle-clean-/); - assert.equal(write.mock.calls.at(-1)?.[0].data, " cargo clean\r"); - - const observedSettle = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - trigger: "settle", - observeCompletion: {}, - }); - const observedTerminalId = observedSettle.status === "started" ? observedSettle.terminalId : ""; - assert.notEqual(observedTerminalId, settleTerminalId); - assert.match(write.mock.calls.at(-1)?.[0].data ?? "", /^ \S/); - const token = /__T3_SETUP_DONE___(\w+):/.exec(write.mock.calls.at(-1)?.[0].data ?? "")?.[1]; - const settleListener = listeners.at(-1)!; - const completion = yield* Effect.forkChild( - observedSettle.status === "started" && observedSettle.completion - ? observedSettle.completion - : Effect.die("no completion"), - ); - yield* settleListener({ - type: "output", - threadId: "thread-1", - terminalId: observedTerminalId, - data: `\r\n__T3_SETUP_DONE___${token}:0\r\n`, - }); - yield* Effect.yieldNow; - assert.equal(closeIdle.mock.calls.length, 0); - yield* settleListener({ - type: "output", - threadId: "thread-1", - terminalId: observedTerminalId, - data: "$ ", - }); - assert.deepEqual((yield* Fiber.join(completion)).exitCode, 0); - assert.deepEqual(closeIdle.mock.calls[0]?.[0], { - threadId: "thread-1", - terminalId: observedTerminalId, - }); - }).pipe(Effect.provide(layer)); -}); 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 bba7bd940e13..80740b0a21d4 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,67 +1,11 @@ // @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 * 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 { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); -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"); -}); - it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { const options = buildClaudeCapabilitiesProbeQueryOptions({ executablePath: "/usr/bin/claude", @@ -76,348 +20,3 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.deepEqual(options.mcpServers, {}); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); - -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("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 da61038215d8..2092429f6d55 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, @@ -171,37 +170,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 e0deb753c368..18f3b8508a04 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -197,7 +197,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 2ed528eb1633..7bcb7413a157 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 9a14686413ac..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: true, -} 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 5f7dcd4410cf..a5f8ff25aad5 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 9fa0a6949877..f91e63047cc4 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -15,8 +15,6 @@ import { } from "@t3tools/contracts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; 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 * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -28,18 +26,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"; @@ -109,28 +100,6 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => ...overrides, }); -const makeCursorConfig = (overrides: Partial): CursorSettings => ({ - enabled: false, - customModels: [], - ...overrides, -}); - -const makeGrokConfig = (overrides: Partial): GrokSettings => ({ - enabled: false, - binaryPath: "grok", - customModels: [], - ...overrides, -}); - -const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ - enabled: false, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - ...overrides, -}); - const makeTildeProviderFixtures = Effect.fn( "ProviderInstanceRegistry.test.makeTildeProviderFixtures", )(function* () { @@ -218,79 +187,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { Layer.provideMerge(layerBase), ); - it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => - Effect.gen(function* () { - const personalId = ProviderInstanceId.make("codex_personal"); - const workId = ProviderInstanceId.make("codex_work"); - const codexDriverKind = ProviderDriverKind.make("codex"); - - const configMap: ProviderInstanceConfigMap = { - [personalId]: { - driver: codexDriverKind, - displayName: "Codex (personal)", - enabled: false, - config: makeCodexConfig({ - binaryPath: "/opt/codex-personal/bin/codex", - homePath: "/home/julius/.codex_personal", - customModels: ["personal-preview"], - }), - }, - [workId]: { - driver: codexDriverKind, - displayName: "Codex (work)", - enabled: false, - config: makeCodexConfig({ - binaryPath: "/opt/codex-work/bin/codex", - homePath: "/home/julius/.codex", - customModels: ["work-preview"], - }), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap, - }); - - const instances = yield* registry.listInstances; - expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [personalId, workId].toSorted(), - ); - expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); - expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( - ["Codex (personal)", "Codex (work)"].toSorted(), - ); - - const personal = yield* registry.getInstance(personalId); - const work = yield* registry.getInstance(workId); - expect(personal).toBeDefined(); - expect(work).toBeDefined(); - expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter); - expect(personal!.textGeneration).not.toBe(work!.textGeneration); - expect(personal!.snapshot).not.toBe(work!.snapshot); - - const personalSnapshot = yield* personal!.snapshot.getSnapshot; - expect(personalSnapshot.instanceId).toBe(personalId); - expect(personalSnapshot.driver).toBe(codexDriverKind); - expect(personalSnapshot.enabled).toBe(false); - const path = yield* Path.Path; - expect(personalSnapshot.continuation?.groupKey).toBe( - `codex:home:${path.resolve("/home/julius/.codex_personal")}`, - ); - - const workSnapshot = yield* work!.snapshot.getSnapshot; - expect(workSnapshot.instanceId).toBe(workId); - expect(workSnapshot.driver).toBe(codexDriverKind); - expect(workSnapshot.enabled).toBe(false); - expect(workSnapshot.continuation?.groupKey).toBe( - `codex:home:${path.resolve("/home/julius/.codex")}`, - ); - - const unavailable = yield* registry.listUnavailable; - expect(unavailable).toEqual([]); - }).pipe(Effect.provide(layerTest)), - ); - it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => Effect.gen(function* () { const staleId = ProviderInstanceId.make("codex_stale"); @@ -315,42 +211,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); - it.live("reports Codex's answer when a redemption changed nothing", () => - Effect.gen(function* () { - if (yield* HostProcess.isWindows) return; - const fileSystem = yield* FileSystem.FileSystem; - const fixtures = yield* makeTildeProviderFixtures(); - yield* fileSystem.writeFileString( - fixtures.codexScriptPath, - JSON.stringify({ - rootThreadId: "probe-thread", - notifications: [], - account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, - failRateLimitsRead: true, - resetCreditOutcome: "alreadyRedeemed", - }), - ); - const codexId = ProviderInstanceId.make("codex_reset"); - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - environment: [ - { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, - ], - config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), - }, - }, - }); - const codex = yield* registry.getInstance(codexId); - expect(codex).toBeDefined(); - yield* codex!.snapshot.refresh; - expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); - }).pipe(Effect.provide(layerTest)), - ); - it.live("runs Codex and Claude readiness probes from configured tilde paths", () => Effect.gen(function* () { if (yield* HostProcess.isWindows) return; @@ -713,17 +573,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }; }); - it.effect.skipIf(windowsHost)("reuses Claude's successful usage probe on refresh", () => - Effect.gen(function* () { - const fixture = yield* makeClaudeCacheFixture("ready"); - const first = yield* fixture.refresh; - const second = yield* fixture.refresh; - expect(first.usageLimits?.windows.map((window) => window.id)).toEqual(["five_hour"]); - expect(second.usageLimits).toEqual(first.usageLimits); - expect(yield* fixture.probeLog).toBe("probe\n"); - }).pipe(Effect.provide(layerTest)), - ); - for (const state of ["signed-out", "usage-failed"] as const) { it.effect.skipIf(windowsHost)( state === "signed-out" @@ -790,196 +639,3 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); }); - -describe("ProviderInstanceRegistry — all drivers slice", () => { - const layerInfra = OpenCodeRuntime.layer.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge( - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "unused-managed-installation", - }), - ), - Layer.provideMerge( - Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - ), - ); - const layerBaseDeps = AntigravityInstallation.AntigravityInstallation.layer.pipe( - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "provider-instance-registry-all-drivers-test", - }), - ), - Layer.provideMerge(layerInfra), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - ); - const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); - const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( - Layer.provideMerge(layerBase), - ); - - it.live("boots one instance of every shipped driver from a single config map", () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex_default"); - const claudeId = ProviderInstanceId.make("claude_default"); - const cursorId = ProviderInstanceId.make("cursor_default"); - const grokId = ProviderInstanceId.make("grok_default"); - const openCodeId = ProviderInstanceId.make("opencode_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 configMap: ProviderInstanceConfigMap = { - [codexId]: { - driver: codexDriverKind, - displayName: "Codex", - enabled: false, - config: makeCodexConfig({ homePath: "/home/julius/.codex" }), - }, - [claudeId]: { - driver: claudeDriverKind, - displayName: "Claude", - enabled: false, - config: makeClaudeConfig({ - homePath: "/home/julius/.claude-work", - launchArgs: "--verbose", - }), - }, - [cursorId]: { - driver: cursorDriverKind, - displayName: "Cursor", - enabled: false, - config: makeCursorConfig({}), - }, - [grokId]: { - driver: grokDriverKind, - displayName: "Grok", - enabled: false, - config: makeGrokConfig({}), - }, - [openCodeId]: { - driver: openCodeDriverKind, - displayName: "OpenCode", - enabled: false, - config: makeOpenCodeConfig({}), - }, - }; - - const { registry } = yield* makeProviderInstanceRegistry< - CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv - >({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], - configMap, - }); - - const unavailable = yield* registry.listUnavailable; - expect(unavailable).toEqual([]); - - const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); - expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), - ); - - const codex = yield* registry.getInstance(codexId); - const claude = yield* registry.getInstance(claudeId); - const cursor = yield* registry.getInstance(cursorId); - const grok = yield* registry.getInstance(grokId); - const openCode = yield* registry.getInstance(openCodeId); - 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(codex?.displayName).toBe("Codex"); - expect(claude?.displayName).toBe("Claude"); - expect(cursor?.displayName).toBe("Cursor"); - expect(grok?.displayName).toBe("Grok"); - expect(openCode?.displayName).toBe("OpenCode"); - - const adapters = [ - codex!.orchestrationAdapter, - claude!.orchestrationAdapter, - cursor!.orchestrationAdapter, - grok!.orchestrationAdapter, - openCode!.orchestrationAdapter, - ]; - expect(new Set(adapters).size).toBe(adapters.length); - const textGenerations = [ - codex!.textGeneration, - claude!.textGeneration, - cursor!.textGeneration, - grok!.textGeneration, - openCode!.textGeneration, - ]; - expect(new Set(textGenerations).size).toBe(textGenerations.length); - const snapshots = [ - codex!.snapshot, - claude!.snapshot, - cursor!.snapshot, - grok!.snapshot, - openCode!.snapshot, - ]; - expect(new Set(snapshots).size).toBe(snapshots.length); - - const codexSnapshot = yield* codex!.snapshot.getSnapshot; - expect(codexSnapshot.instanceId).toBe(codexId); - expect(codexSnapshot.driver).toBe(codexDriverKind); - expect(codexSnapshot.enabled).toBe(false); - expect(codexSnapshot.continuation?.groupKey).toBe( - `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`, - ); - - const claudeSnapshot = yield* claude!.snapshot.getSnapshot; - expect(claudeSnapshot.instanceId).toBe(claudeId); - expect(claudeSnapshot.driver).toBe(claudeDriverKind); - expect(claudeSnapshot.enabled).toBe(false); - expect(claudeSnapshot.continuation?.groupKey).toBe( - `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`, - ); - - const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; - expect(cursorSnapshot.instanceId).toBe(cursorId); - expect(cursorSnapshot.driver).toBe(cursorDriverKind); - expect(cursorSnapshot.enabled).toBe(false); - expect(cursorSnapshot.continuation?.groupKey).toBe( - `${cursorDriverKind}:instance:${cursorId}`, - ); - - const grokSnapshot = yield* grok!.snapshot.getSnapshot; - expect(grokSnapshot.instanceId).toBe(grokId); - expect(grokSnapshot.driver).toBe(grokDriverKind); - expect(grokSnapshot.enabled).toBe(false); - expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); - - const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; - expect(openCodeSnapshot.instanceId).toBe(openCodeId); - expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); - expect(openCodeSnapshot.enabled).toBe(false); - expect(openCodeSnapshot.continuation?.groupKey).toBe( - `${openCodeDriverKind}:instance:${openCodeId}`, - ); - }).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 6ff05e00bc53..a278470c286d 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,16 +1,12 @@ -import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; 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 +17,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 +31,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 +39,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 +64,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 +86,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 +180,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 +212,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", [ @@ -429,49 +320,11 @@ it.layer( Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient), )("ProviderRegistry", (it) => { describe("checkCodexProviderStatus", () => { - it.effect("does not count proxy rate limits as a native subscription", () => - Effect.gen(function* () { - for (const account of [ - null, - { type: "chatgpt" as const, email: "test@example.com", planType: "pro" as const }, - ]) { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { account, requiresOpenaiAuth: false }, - rateLimits: { - snapshot: { - primary: { usedPercent: 90, windowDurationMins: 10080, resetsAt: 1789436313 }, - }, - rateLimitsByLimitId: null, - resetCredits: null, - }, - }), - ), - ); - assert.strictEqual(status.usageLimits?.unavailable?.reason, "unsupported"); - assert.deepStrictEqual(status.usageLimits?.windows, []); - } - }), - ); - it.effect("uses the app-server account and model list for provider status", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => Effect.succeed( makeCodexProbeSnapshot({ - account: { - account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, - requiresOpenaiAuth: true, - }, - rateLimits: { - accountId: "workspace-a", - snapshot: { - primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1789436313 }, - }, - rateLimitsByLimitId: null, - resetCredits: null, - }, skills: [ { name: "github:gh-fix-ci", @@ -491,9 +344,6 @@ it.layer( assert.strictEqual(status.auth.type, "chatgpt"); assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); assert.strictEqual(status.auth.email, "test@example.com"); - assert.strictEqual(status.auth.workspaceId, "workspace-a"); - assert.strictEqual(status.usageLimits?.unavailable, undefined); - assert.strictEqual(status.usageLimits?.windows[0]?.usedPercent, 42); assert.deepStrictEqual(status.setup, { canAuthenticate: false, canInstall: false }); assert.deepStrictEqual(status.models, [ { @@ -527,21 +377,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, () => @@ -581,114 +416,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", () => { @@ -871,45 +598,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"), @@ -943,73 +631,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"), @@ -1066,55 +687,6 @@ it.layer( ); }); - it("drops stale OpenCode models missing from a successful refresh", () => { - 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: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00: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("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -1167,116 +739,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", @@ -1340,27 +802,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, @@ -1520,55 +961,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; @@ -1683,65 +1075,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, @@ -1819,31 +1152,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" } }, @@ -1932,57 +1240,65 @@ 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, + provider: driver, packageName: null, }), ), - getSnapshot: Effect.succeed(initialProvider), - refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( - Effect.andThen(Effect.never), - ), + getSnapshot: Effect.succeed(unhealthyProvider), + refresh: Effect.succeed(unhealthyProvider), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, + snapshotForCwd: () => Ref.get(scanResult), orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; + }; const layerInstanceRegistry = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), listInstances: Effect.succeed([instance]), listUnavailable: Effect.succeed([]), streamChanges: Stream.empty, @@ -1996,1074 +1312,66 @@ it.layer( Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-background-refresh-", + prefix: "t3-provider-registry-unhealthy-workspace-", }), ), 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 workspaceOf = Effect.map( + registry.getProviders, + (providers) => providers[0]?.workspaceSnapshots?.[0], + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.deepStrictEqual((yield* workspaceOf)?.slashCommands, [COMPACT_SLASH_COMMAND]); - 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 instanceLookupGate = yield* Ref.make<{ - readonly started: Deferred.Deferred; - readonly release: Deferred.Deferred; - } | null>(null); - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (requestedId) => - Effect.gen(function* () { - const gate = yield* Ref.getAndSet(instanceLookupGate, null); - if (gate) { - yield* Deferred.succeed(gate.started, undefined); - yield* Deferred.await(gate.release); - } - return (yield* Ref.get(instancesRef)).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(instanceRegistryLayer), - 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, - }); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [latestSkills], - ); - yield* Deferred.succeed(releaseSlow, undefined); - yield* Fiber.join(slowScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [latestSkills], - ); - - // A fresh scan supersedes an ordinary scan even when the older - // scan finishes first, without releasing the fresh scan's claim. - const freshCwd = "/fresh-workspace"; - const callsBeforeRace = yield* Ref.get(snapshotCalls); - 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: freshCwd }) - .pipe(Effect.forkChild); - yield* Deferred.await(olderStarted); - const freshStarted = yield* Deferred.make(); - const releaseFresh = yield* Deferred.make(); - const freshProvider = { - ...scopedProvider, - skills: latestSkills, - slashCommands: [{ name: "latest" }], - } satisfies ServerProvider; - yield* Ref.set(scanGate, { started: freshStarted, release: releaseFresh }); - yield* Ref.set(scopedResult, freshProvider); - const freshScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: freshCwd, fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(freshStarted); - yield* Deferred.succeed(releaseOlder, undefined); - yield* Fiber.join(olderScan); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find((s) => s.cwd === freshCwd), - undefined, - ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: freshCwd }); - assert.strictEqual(yield* Ref.get(snapshotCalls), callsBeforeRace + 2); - yield* Deferred.succeed(releaseFresh, undefined); - yield* Fiber.join(freshScan); - const freshSnapshot = (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (s) => s.cwd === freshCwd, - ); - assert.deepStrictEqual(freshSnapshot?.skills, freshProvider.skills); - assert.deepStrictEqual(freshSnapshot?.slashCommands, freshProvider.slashCommands); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (s) => s.cwd === "/workspace", - )?.skills, - latestSkills, - ); - - // A scan that lands during a fresh request's instance lookup becomes - // the baseline when the fresh request takes ownership. - const lookupCwd = "/lookup-workspace"; - const beforeLookupStarted = yield* Deferred.make(); - const releaseBeforeLookup = yield* Deferred.make(); - yield* Ref.set(scanGate, { - started: beforeLookupStarted, - release: releaseBeforeLookup, - }); - yield* Ref.set(scopedResult, scopedProvider); - const beforeLookupScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: lookupCwd }) - .pipe(Effect.forkChild); - yield* Deferred.await(beforeLookupStarted); - const lookupStarted = yield* Deferred.make(); - const releaseLookup = yield* Deferred.make(); - yield* Ref.set(instanceLookupGate, { started: lookupStarted, release: releaseLookup }); - yield* Ref.set(scopedResult, freshProvider); - const lookupFreshScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: lookupCwd, fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(lookupStarted); - yield* Deferred.succeed(releaseBeforeLookup, undefined); - yield* Fiber.join(beforeLookupScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find((s) => s.cwd === lookupCwd) - ?.skills, - scopedProvider.skills, - ); - yield* Deferred.succeed(releaseLookup, undefined); - yield* Fiber.join(lookupFreshScan); - const lookupSnapshot = (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (s) => s.cwd === lookupCwd, - ); - assert.deepStrictEqual(lookupSnapshot?.skills, freshProvider.skills); - assert.deepStrictEqual(lookupSnapshot?.slashCommands, freshProvider.slashCommands); - - // An ordinary request delayed in its instance lookup must use a - // snapshot published before it takes ownership instead of rescanning. - const ordinaryLookupCwd = "/ordinary-lookup-workspace"; - const callsBeforeOrdinaryLookup = yield* Ref.get(snapshotCalls); - const ordinaryLookupStarted = yield* Deferred.make(); - const releaseOrdinaryLookup = yield* Deferred.make(); - yield* Ref.set(instanceLookupGate, { - started: ordinaryLookupStarted, - release: releaseOrdinaryLookup, - }); - const ordinaryLookupScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: ordinaryLookupCwd }) - .pipe(Effect.forkChild); - yield* Deferred.await(ordinaryLookupStarted); - yield* Ref.set(scopedResult, freshProvider); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: ordinaryLookupCwd, - fresh: true, - }); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (s) => s.cwd === ordinaryLookupCwd, - )?.skills, - freshProvider.skills, - ); - assert.strictEqual(yield* Ref.get(snapshotCalls), callsBeforeOrdinaryLookup + 1); - yield* Ref.set(scopedResult, scopedProvider); - yield* Deferred.succeed(releaseOrdinaryLookup, undefined); - yield* Fiber.join(ordinaryLookupScan); - assert.strictEqual(yield* Ref.get(snapshotCalls), callsBeforeOrdinaryLookup + 1); - const ordinaryLookupSnapshot = - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (s) => s.cwd === ordinaryLookupCwd, - ); - assert.deepStrictEqual(ordinaryLookupSnapshot?.skills, freshProvider.skills); - assert.deepStrictEqual( - ordinaryLookupSnapshot?.slashCommands, - freshProvider.slashCommands, - ); - - const cancelledCwd = "/cancelled-workspace"; - const callsBeforeCancellation = yield* Ref.get(snapshotCalls); - const cancelledStarted = yield* Deferred.make(); - const releaseCancelled = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: cancelledStarted, release: releaseCancelled }); - yield* Ref.set(scopedResult, freshProvider); - const cancelledScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: cancelledCwd, fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(cancelledStarted); - yield* Fiber.interrupt(cancelledScan); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (s) => s.cwd === cancelledCwd, - ), - undefined, - ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: cancelledCwd }); - assert.strictEqual(yield* Ref.get(snapshotCalls), callsBeforeCancellation + 2); - const retriedSnapshot = (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (s) => s.cwd === cancelledCwd, - ); - assert.deepStrictEqual(retriedSnapshot?.skills, freshProvider.skills); - assert.deepStrictEqual(retriedSnapshot?.slashCommands, freshProvider.slashCommands); - - 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, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(unhealthyProvider), - refresh: Effect.succeed(unhealthyProvider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - snapshotForCwd: () => Ref.get(scanResult), - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (requestedId) => - Effect.succeed(requestedId === instanceId ? 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-unhealthy-workspace-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const workspaceOf = Effect.map( - registry.getProviders, - (providers) => providers[0]?.workspaceSnapshots?.[0], - ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.deepStrictEqual((yield* workspaceOf)?.slashCommands, [COMPACT_SLASH_COMMAND]); - - // An expired scan runs while the account check fails: the skills are - // current, but commands that ride on the health check are unknown. - yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); - yield* Ref.set(scanResult, { ...unhealthyProvider, skills: laterSkills }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - const unhealthy = yield* workspaceOf; - assert.deepStrictEqual(unhealthy?.skills, laterSkills); - assert.deepStrictEqual(unhealthy?.slashCommands, [COMPACT_SLASH_COMMAND]); - assert.strictEqual(unhealthy?.slashCommandsPending, true); - const provider = (yield* registry.getProviders)[0]; - assert.strictEqual(provider?.status, "error"); - assert.strictEqual(provider?.message, unhealthyProvider.message); - - // A pending entry is rescanned on the next request; an empty scan - // removes the skills. - yield* Ref.set(scanResult, { ...unhealthyProvider, skills: [] }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.deepStrictEqual((yield* workspaceOf)?.skills, []); - - // A driver that discovers its own commands reports them even while - // health is in error. - const discoveredCommand = { name: "review", description: "Review changes" }; - yield* Ref.set(scanResult, { - ...unhealthyProvider, - slashCommands: [discoveredCommand], - slashCommandsPending: false, - }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - const discovered = yield* workspaceOf; - 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)); + // An expired scan runs while the account check fails: the skills are + // current, but commands that ride on the health check are unknown. + yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); + yield* Ref.set(scanResult, { ...unhealthyProvider, skills: laterSkills }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const unhealthy = yield* workspaceOf; + assert.deepStrictEqual(unhealthy?.skills, laterSkills); + assert.deepStrictEqual(unhealthy?.slashCommands, [COMPACT_SLASH_COMMAND]); + assert.strictEqual(unhealthy?.slashCommandsPending, true); + const provider = (yield* registry.getProviders)[0]; + assert.strictEqual(provider?.status, "error"); + assert.strictEqual(provider?.message, unhealthyProvider.message); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const initialProviders = yield* registry.getProviders; - assert.strictEqual( - initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, - "error", - ); + // A pending entry is rescanned on the next request; an empty scan + // removes the skills. + yield* Ref.set(scanResult, { ...unhealthyProvider, skills: [] }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.deepStrictEqual((yield* workspaceOf)?.skills, []); - 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), - ); + // A driver that discovers its own commands reports them even while + // health is in error. + const discoveredCommand = { name: "review", description: "Review changes" }; + yield* Ref.set(scanResult, { + ...unhealthyProvider, + slashCommands: [discoveredCommand], + slashCommandsPending: false, + }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const discovered = yield* workspaceOf; + assert.deepStrictEqual(discovered?.slashCommands, [discoveredCommand]); + assert.strictEqual(discovered?.slashCommandsPending, undefined); - 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), - ); + // 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)); - - 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], - }, - ]); - }); - it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); @@ -3424,112 +1732,11 @@ it.layer( skills: [], } as const satisfies ServerProvider; const instance = { - 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(cachedProvider), - refresh: Effect.die(new Error("simulated refresh failure")), - 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) => - PubSub.subscribe(pubsub), - ), - }, - ); - 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-refresh-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(cachedProvider), - ]); - assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ - withBundledCompatibility(cachedProvider), - ]); - assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ - withBundledCompatibility(cachedProvider), - ]); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - 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, + instanceId: codexInstanceId, + driverKind: codexDriver, continuationIdentity: { - driverKind: provider.driver, - continuationKey: `${provider.driver}:instance:${provider.instanceId}`, + driverKind: codexDriver, + continuationKey: "codex:instance:codex", }, displayName: undefined, enabled: true, @@ -3537,44 +1744,29 @@ it.layer( resolveMaintenance: () => Effect.succeed( makeManualOnlyProviderMaintenanceCapabilities({ - provider: provider.driver, + provider: codexDriver, packageName: null, }), ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), + getSnapshot: Effect.succeed(cachedProvider), + refresh: Effect.die(new Error("simulated refresh failure")), 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; + } satisfies ProviderInstance; 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); - }), + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), }, ); const scope = yield* Scope.make(); @@ -3584,7 +1776,7 @@ it.layer( Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-sync-failure-", + prefix: "t3-provider-registry-refresh-failure-", }), ), Layer.provideMerge(layerBackgroundPolicyAlwaysRun), @@ -3594,120 +1786,16 @@ it.layer( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [ - withBundledCompatibility(codexProvider), + withBundledCompatibility(cachedProvider), + ]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ + withBundledCompatibility(cachedProvider), + ]); + assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ + withBundledCompatibility(cachedProvider), ]); - - 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)); }), ); @@ -4023,358 +2111,35 @@ it.layer( it.effect("does not apply a queued settings change older than the registry seed", () => Effect.gen(function* () { - const seed = startupSettings("ghost_current"); - const unavailable = yield* hydrateDuringSettingsChange({ - seed, - saved: startupSettings("ghost_stale"), - current: seed, - }); - assert.include(unavailable, "ghost_current"); - assert.notInclude(unavailable, "ghost_stale"); - }), - ); - - it.effect( - "retains the newer registry seed when a queued stale change cannot read current settings", - () => - Effect.gen(function* () { - const seed = startupSettings("ghost_current"); - const unavailable = yield* hydrateDuringSettingsChange({ - seed, - saved: startupSettings("ghost_stale"), - current: seed, - failCurrentRead: true, - }); - assert.include(unavailable, "ghost_current"); - assert.notInclude(unavailable, "ghost_stale"); - }), - ); - - it.effect( - "keeps Cursor disabled and skips provider probing when settings use their defaults", - () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providerInstances: disabledDefaultSlots("codex", "grok"), - }), - ), - ); - let cursorSpawned = false; - 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), - Layer.provideMerge( - layerMockCommandSpawner((command, args) => { - if (command === "cursor-agent") { - cursorSpawned = true; - } - const joined = args.join(" "); - if (joined === "--version") { - return { - stdout: `${command} 1.0.0\n`, - stderr: "", - code: 0, - }; - } - if (joined === "auth status") { - return { - stdout: '{"authenticated":true}\n', - stderr: "", - code: 0, - }; - } - throw new Error(`Unexpected args: ${command} ${joined}`); - }), - ), - ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - layerProviderRegistry, - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const providers = yield* registry.getProviders; - const cursorProvider = providers.find( - (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), - ); - - assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ - "antigravity", - "claudeAgent", - "codex", - "cursor", - "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."); - } - }), - ); - - it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => - Effect.gen(function* () { - // Bedrock authenticates via external AWS credentials, so the SDK init - // reports only `apiProvider` with no subscription or token. - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ apiProvider: "bedrock" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "bedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); - }).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}`); - }), - ), - ), + const seed = startupSettings("ghost_current"); + const unavailable = yield* hydrateDuringSettingsChange({ + seed, + saved: startupSettings("ghost_stale"), + current: seed, + }); + assert.include(unavailable, "ghost_current"); + assert.notInclude(unavailable, "ghost_stale"); + }), + ); + + it.effect( + "retains the newer registry seed when a queued stale change cannot read current settings", + () => + Effect.gen(function* () { + const seed = startupSettings("ghost_current"); + const unavailable = yield* hydrateDuringSettingsChange({ + seed, + saved: startupSettings("ghost_stale"), + current: seed, + failCurrentRead: true, + }); + assert.include(unavailable, "ghost_current"); + assert.notInclude(unavailable, "ghost_stale"); + }), ); + }); + 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: @@ -4500,27 +2265,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 @@ -4543,360 +2287,5 @@ 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("returns an api key label for claude api key auth", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "Claude API Key"); - }).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":"api-key"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - 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 6538ad2a5d85..ec045e6ea3fb 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -296,30 +296,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 c03a79fa19f8..dbb7a2bfc393 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -15,7 +15,6 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; -import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; import * as Schema from "effect/Schema"; import { applyProviderCompatibility, @@ -51,79 +50,7 @@ const provider: ServerProvider = { slashCommands: [], }; -const V2_RELEASE = "0.0.46"; - describe("provider compatibility", () => { - it("offers Homebrew metadata recovery only while installed and latest versions are incompatible", () => { - const versionAdvisory = { - status: "current", - currentVersion: "1.0.0", - latestVersion: "1.0.0", - canUpdate: true, - updateCommand: "brew upgrade --cask codex", - checkedAt: provider.checkedAt, - message: null, - } as const; - for (const [version, command] of [ - ["1.0.0", "brew upgrade --cask codex"], - ["0.9.0", "brew upgrade codex"], - ] as const) { - const stale = { - ...provider, - version, - versionAdvisory: { - ...versionAdvisory, - currentVersion: version, - latestVersion: version, - updateCommand: command, - }, - }; - const result = applyProviderCompatibility(stale, [policy], []); - assert.include(result.compatibilityAdvisory?.message ?? "", `brew update && ${command}`); - assert.include(result.compatibilityAdvisory?.message ?? "", "environment's host"); - assert.include(result.compatibilityAdvisory?.message ?? "", "refresh provider status"); - assert.strictEqual(result.message, provider.message); - assert.deepStrictEqual(result.versionAdvisory, stale.versionAdvisory); - - const refreshed = applyProviderCompatibility( - { ...result, versionAdvisory: { ...stale.versionAdvisory, latestVersion: "2.0.0" } }, - [policy], - [], - ); - assert.strictEqual(refreshed.compatibilityAdvisory?.latestVersionStatus, "supported"); - assert.notInclude(refreshed.compatibilityAdvisory?.message ?? "", "brew update"); - } - - for (const advisory of [ - { ...versionAdvisory, updateCommand: "npm install -g @openai/codex@latest" }, - { ...versionAdvisory, updateCommand: null, canUpdate: false }, - { ...versionAdvisory, latestVersion: null }, - { ...versionAdvisory, latestVersion: "9.0.0" }, - ]) { - const result = applyProviderCompatibility( - { ...provider, versionAdvisory: advisory }, - [policy], - [], - ); - assert.notInclude(result.compatibilityAdvisory?.message ?? "", "brew update"); - } - }); - - it("bundles a compatibility policy for every built-in harness", () => { - for (const builtIn of BUILT_IN_DRIVERS) { - if (builtIn.driverKind === "acpRegistry") continue; - assert.isDefined( - resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - builtIn.driverKind, - null, - V2_RELEASE, - ), - `Missing bundled compatibility policy for ${builtIn.driverKind}`, - ); - } - }); - it("keeps releases before V2 on the OpenCode 1 policy they shipped with", () => { // Every installed build fetches main's manifest, and 0.0.45 and older run the // V1 orchestrator, which cannot drive OpenCode 2. @@ -152,44 +79,6 @@ describe("provider compatibility", () => { ); }); - it("supports OpenCode 2 and gives OpenCode 1.x limited support", () => { - const opencode = ProviderDriverKind.make("opencode"); - for (const [version, expected] of [ - ["2.0.18", "supported"], - ["2.1.0", "supported"], - ["2.0.17", "unsupported"], - ["2.0.0", "unsupported"], - ["1.99.0", "graceful"], - ["1.14.19", "graceful"], - ["1.14.18", "broken"], - ] as const) { - for (const t3CodeVersion of [V2_RELEASE, "0.0.46-preview.20261002.2598"]) { - const advisory = resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - opencode, - version, - t3CodeVersion, - ); - assert.strictEqual(advisory?.status, expected, `OpenCode ${version} on ${t3CodeVersion}`); - assert.strictEqual(advisory?.recommendedRange, ">=2.0.18"); - } - } - const ready = applyProviderCompatibility( - { ...provider, driver: opencode, version: "1.18.33", status: "ready", message: undefined }, - undefined, - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - ); - assert.strictEqual(ready.status, "ready"); - assert.strictEqual( - ready.compatibilityAdvisory?.status, - resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - opencode, - "1.18.33", - )?.status, - ); - }); - it("compares Cursor build dates without treating semver prereleases as stable", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorPolicy: ProviderCompatibilityPolicy = { @@ -218,26 +107,6 @@ describe("provider compatibility", () => { ); }); - it("compares Muse versions by their release, ignoring the revision suffix", () => { - const muse = ProviderDriverKind.make("muse"); - for (const [version, expected] of [ - ["1.4.3-R5018.1", "supported"], - ["1.4.2-R4684", "supported"], - ["1.4.1-R4100.2", "unknown"], - ["1.4.3-beta.1", "unknown"], - ] as const) { - assert.strictEqual( - resolveProviderCompatibility( - ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, - muse, - version, - V2_RELEASE, - )?.status, - expected, - ); - } - }); - it("recognizes Antigravity semver release tags while keeping dated candidates unknown", () => { const antigravity = ProviderDriverKind.make("antigravity"); const taggedPolicy = { @@ -288,25 +157,6 @@ describe("provider compatibility", () => { } }); - it("supports every driver without inventing policies for uncovered adapters", () => { - for (const kind of [ - "codex", - "claudeAgent", - "cursor", - "grok", - "opencode", - "antigravity", - "customDriver", - ]) { - const adapter = ProviderDriverKind.make(kind); - assert.strictEqual( - resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, - "supported", - ); - assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); - } - }); - it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { const broken = applyProviderCompatibility(provider, [], [policy]); assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); @@ -448,32 +298,3 @@ it.effect("a remote policy refresh preserves a newer health result on the regist }).pipe(Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.scoped), ); - -it("recomputes latest-version compatibility without losing the underlying update advisory", () => { - const snapshot: ServerProvider = { - ...provider, - version: "2.0.0", - versionAdvisory: { - status: "behind_latest", - currentVersion: "2.0.0", - latestVersion: "4.0.0", - canUpdate: true, - updateCommand: "npm install -g fixture@latest", - checkedAt: provider.checkedAt, - message: null, - }, - }; - const blockedPolicy: ProviderCompatibilityPolicy = { - ...policy, - ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], - }; - const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); - assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); - const relaxed = applyProviderCompatibility( - blocked, - [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], - [], - ); - assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); - assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); -}); 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 cd7bfff273f8..79cb0bfbf5fd 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -7,22 +7,11 @@ 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, - isCachedProviderCorrelated, - readProviderStatusCache, - resolveProviderStatusCachePath, - writeProviderStatusCache, -} from "./providerStatusCache.ts"; +import { hydrateCachedProvider, isCachedProviderCorrelated } 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, @@ -43,84 +32,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 b4059c76c54a..6a077e31b618 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; @@ -1460,41 +833,6 @@ it.effect("keeps healthy repositories when one of them cannot be read", () => }), ); -it.effect( - "returns partial listing coverage with readable rows and no fabricated continuation", - () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - coverageWarning: "Team review requests may be missing.", - truncated: false, - continues: true, - }), - }), - ], - }); - const result = yield* service.list({ state: "open", involvement: "reviewing" }); - assert.strictEqual(result.entries.length, 1); - assert.deepStrictEqual(result.errors, [ - { - projectId: "p1" as ProjectId, - projectTitle: "web", - message: "Team review requests may be missing.", - partial: true, - }, - ]); - assert.isFalse(result.truncated); - assert.deepStrictEqual(result.nextCursors, {}); - }), -); - it.effect("tries another workspace on the same host for the viewer", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1704,7 +1042,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes", .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(confirmationStarted); const revisions = yield* Effect.forEach(readers, (reader) => - Fiber.join(reader).pipe(Effect.map((event) => Option.getOrThrow(event).revision)), + Fiber.join(reader).pipe(Effect.map((value) => Option.getOrThrow(value).revision)), ); assert.isAbove(revisions[0]!, 0); assert.strictEqual(revisions[0], revisions[1]); @@ -2000,279 +1338,55 @@ 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" })], - 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" }), - ], + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => @@ -2314,326 +1428,24 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [providerName, host] 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("keeps mounted Gitea selectors and HTTP ports in list and detail references", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "gitea", - title: "web", - workspaceRoot: "/gitea", - repository: "git/acme/web", - provider: SourceControlProviderKind.make("gitea"), - host: "code.example", - remoteUrl: "http://code.example:3000/git/acme/web.git", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitea"), { - listChangeRequests: (input) => { - assert.strictEqual(input.host, "code.example:3000"); - assert.strictEqual(input.repository, "git/acme/web"); - return Effect.succeed({ - items: [changeRequest(42, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }); - }, - getChangeRequestSummary: (input) => { - assert.strictEqual(input.host, "code.example:3000"); - assert.strictEqual(input.repository, "git/acme/web"); - return Effect.succeed(changeRequest(42, "2026-07-02T00:00:00Z")); - }, - }), - ], - }); - const listed = yield* service.list({ host: "code.example:3000", state: "open" }); - assert.lengthOf(listed.entries, 1); - assert.include(listed.entries[0], { - repository: "git/acme/web", - host: "code.example:3000", - number: 42, - }); - yield* service.summary( - { - projectId: "gitea" as ProjectId, - host: "code.example:3000", - repository: "git/acme/web", - number: 42, - }, - { recoverTransientFailure: false }, - ); - const other = yield* service.list({ host: "code.example:4000", state: "open" }); - assert.lengthOf(other.entries, 0); - }), -); - -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[] = []; @@ -2736,2508 +1548,587 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => 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); - approved = false; - yield* service.submitReview({ - ...reference, - verdict: "request-changes", - body: "", - comments: [ - { - path: "src/a.ts", - body: "Please change this.", - position: { kind: "added", newLine: 1 }, - }, - ], - }); - 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("uses dynamic reaction capabilities after rate-limit wrapping", () => - Effect.gen(function* () { - let reactionCalls = 0; - 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, - reactionSubjects: { - changeRequest: true, - issueComment: true, - reviewComment: true, - review: false, - }, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getCapabilities: () => - Effect.succeed({ - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - reactions: false, - reactionSubjects: { - changeRequest: true, - issueComment: true, - reviewComment: true, - review: true, - }, - }), - setReaction: () => - Effect.sync(() => { - reactionCalls += 1; - }), - }), - ], - }); - - yield* service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - subjectId: "review:9", - content: "heart", - reacted: true, - }); - assert.strictEqual(reactionCalls, 1); - }), -); - -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("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), - ).revision; - 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)).revision, - previousRefresh, - ); - yield* service.activity(reference); - - assert.strictEqual(activityCalls, 2); - }), -); - -it.effect("a reaction preserves unrelated repository reads and every cached listing", () => - Effect.gen(function* () { - const reads: string[] = []; - const activity = { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const unrelated = { projectId: "p2" as ProjectId, repository: "acme/api", number: 1 }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ id: "p2", title: "api", workspaceRoot: "/b", repository: "acme/api" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => - Effect.sync(() => { - reads.push(`list:${input.repository}`); - return { items: [], truncated: false, continues: false }; - }), - getChangeRequestActivity: (input) => - Effect.sync(() => { - reads.push(`activity:${input.repository}`); - return activity; - }), - }), - ], - }); - yield* service.list({ state: "open" }); - yield* service.list({ state: "open", projectId: unrelated.projectId }); - yield* service.activity(reference); - yield* service.activity(unrelated); - const before = [...reads]; - - yield* service.setReaction({ ...reference, content: "heart", reacted: true }); - yield* service.list({ state: "open" }); - yield* service.list({ state: "open", projectId: unrelated.projectId }); - yield* service.activity(unrelated); - assert.deepStrictEqual(reads, before); - yield* service.activity(reference); - assert.deepStrictEqual(reads, [...before, "activity:acme/web"]); - const event = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - assert.deepStrictEqual(event.reference, reference); - assert.isFalse(event.listings); - }), -); - -it.effect( - "state changes invalidate sibling checkouts but preserve other repositories and hosts", - () => - Effect.gen(function* () { - const reads: string[] = []; - 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" }), - project({ id: "sibling", title: "sibling", workspaceRoot: "/b", repository: "Acme/Web" }), - project({ id: "other", title: "other", workspaceRoot: "/c", repository: "acme/api" }), - project({ - id: "enterprise", - title: "enterprise", - workspaceRoot: "/d", - repository: "acme/web", - host: "github.example.com", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => - Effect.sync(() => { - reads.push(`${input.host}:${input.repository}`); - return { items: [], truncated: false, continues: false }; - }), - }), - ], - }); - const sibling = { state: "open", projectId: "sibling" as ProjectId } as const; - const other = { state: "open", projectIds: ["other" as ProjectId] } as const; - const enterprise = { state: "open", host: "github.example.com" } as const; - yield* service.list(sibling); - yield* service.list(other); - yield* service.list(enterprise); - const before = [...reads]; - yield* service.runAction({ ...reference, action: "close" }); - yield* service.list(other); - yield* service.list(enterprise); - assert.deepStrictEqual(reads, before); - yield* service.list(sibling); - assert.deepStrictEqual(reads, [...before, "github.com:Acme/Web"]); - const event = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - assert.deepStrictEqual(event.projectIds, ["p1" as ProjectId, "sibling" as ProjectId]); - assert.isTrue(event.listings); - }), -); - -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"]); - }), -); - -it.effect("asks every host the reader's search, rather than filtering what came back", () => - 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"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }), - ], - }); - - yield* service.list({ state: "open", query: "pull requests page" }); - - assert.deepStrictEqual(asked, ["pull requests page", "pull requests page"]); - }), -); - -it.effect("asks for no search when the reader has typed nothing", () => - Effect.gen(function* () { - const asked: Array = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - asked.push(input.query); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - - assert.deepStrictEqual(asked, [undefined]); - }), -); - -it.effect("asks another checkout who is signed in when the first one cannot answer", () => - Effect.gen(function* () { - const asked: 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", - }), - ], - 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, - }), - }), - ], - }); - - 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, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot ask somebody for a review."); - assert.isFalse(asked); - }), -); - -it.effect("refuses the candidate list on a host that has no such list to give", () => - 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: 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"), - }), - ], - }); - - 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."); - }), -); - -it.effect("refuses a review request this viewer may not make, and says what access it takes", () => - Effect.gen(function* () { - let sent = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: ["ready", "draft", "close", "reopen"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, - }), - 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); - }), -); - -it.effect("keeps the menu from a viewer who may not ask, which is all it is for", () => - 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: [], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, - }), - listReviewerCandidates: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }), - ); - - assert.include(error.message, "You need write access on this repository to ask for a review."); - }), -); - -it.effect("hands the host's own candidate list back, and asks for it with the change request", () => - Effect.gen(function* () { - let askedFor: number | null = null; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - 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, - }); - }, - }), - ], - }); - - const list = yield* service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - }); - - assert.strictEqual(askedFor, 4); - assert.deepStrictEqual( - list.candidates.map((candidate) => candidate.login), - ["octocat"], - ); - }), -); - -it.effect("refuses a label change on a host that has not said it takes one", () => - Effect.gen(function* () { - let changed = false; - 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; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.setLabels({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - labels: ["bug"], - applied: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot change the labels"); - assert.isFalse(changed); - }), -); - -it.effect("refuses a label change this viewer may not make, and says what access it takes", () => - Effect.gen(function* () { - let changed = 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: [], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, - labels: false, - }), - listLabelCandidates: () => Effect.die("must not be called"), - setLabels: () => { - changed = 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({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - labels: ["bug"], - applied: 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); - }), -); - -it.effect.each(["list", "listStats"] as const)( - "retries a replacement %s read that joins a canceled lookup", - (operation) => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const stopping = yield* Deferred.make(); - const finishShutdown = yield* Deferred.make(); - let hostCalls = 0; - const lookup = Effect.gen(function* () { - if (++hostCalls !== 1) return; - yield* Deferred.succeed(started, undefined); - return yield* Effect.never.pipe( - Effect.onInterrupt(() => - Deferred.succeed(stopping, undefined).pipe( - Effect.andThen(Deferred.await(finishShutdown)), - ), - ), - ); - }); - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - lookup.pipe( - Effect.as({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }), - ), - listChangeRequestStats: () => - lookup.pipe( - Effect.as([{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]), - ), - }), - ], - }); - const read = - operation === "list" - ? service - .list({ state: "open" }) - .pipe(Effect.map((result) => result.entries.map((entry) => entry.number))) - : service - .listStats({ - refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], - }) - .pipe(Effect.map((result) => result.stats.map((stat) => stat.number))); - const original = yield* Effect.forkScoped(read); - yield* Deferred.await(started); - const cancellation = yield* Effect.forkScoped(Fiber.interrupt(original)); - yield* Deferred.await(stopping); - const replacement = yield* Effect.forkScoped(read, { startImmediately: true }); - yield* Deferred.succeed(finishShutdown, undefined); - yield* Fiber.join(cancellation); - const canceled = yield* Fiber.await(original); - assert.isTrue(Exit.isFailure(canceled)); - if (Exit.isFailure(canceled)) assert.isTrue(Cause.hasInterruptsOnly(canceled.cause)); - assert.deepStrictEqual(yield* Fiber.join(replacement), [1]); - assert.strictEqual(hostCalls, 2); - }), -); - -it.effect("shares one cold viewer lookup across distinct concurrent lists", () => - Effect.gen(function* () { - let viewerCalls = 0; - let listCalls = 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; - }).pipe(Effect.andThen(Effect.yieldNow), Effect.as("bilal")), - listChangeRequests: () => - Effect.sync(() => { - listCalls += 1; - return { items: [], truncated: false, continues: true }; - }), - }), - ], - }); - - yield* Effect.forEach( - ["all", "authored", "reviewing"], - (involvement) => - service.list({ - state: "open", - involvement: involvement as "all" | "authored" | "reviewing", - }), - { concurrency: "unbounded" }, - ); - - 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], - ); - }), -); - -it.effect("a listing narrowed to some projects is its own cache entry", () => - Effect.gen(function* () { - const asked: ReadonlyArray[] = []; - 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"), { - listChangeRequestsAcross: (input) => { - asked.push(input.repositories); - return Effect.succeed({ - items: input.repositories.map((repository, index) => - batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"), - ), - truncated: false, - }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - const narrowed = yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); - - assert.deepStrictEqual(asked, [["acme/web", "acme/docs"], ["acme/docs"]]); - assert.deepStrictEqual( - narrowed.entries.map((entry) => entry.repository), - ["acme/docs"], - ); - - yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); - assert.strictEqual(asked.length, 2); - }), -); - -it.effect( - "keeps listing freshness tied to read start when filtered reads finish out of order", - () => - Effect.gen(function* () { - const olderStarted = yield* Deferred.make(); - const releaseOlder = yield* Deferred.make(); - let reads = 0; - const updatedAt = "2026-07-02T00:00:00Z"; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: ({ filters }) => - Effect.gen(function* () { - reads += 1; - const older = filters?.checks === "failing"; - if (older) { - yield* Deferred.succeed(olderStarted, undefined); - yield* Deferred.await(releaseOlder); - } - return { - items: [ - { - ...changeRequest(1, updatedAt), - checksState: older ? ("failing" as const) : ("passing" as const), - mergeability: older ? ("mergeable" as const) : ("conflicting" as const), - }, - ], - truncated: false, - continues: false, - }; - }), - }), - ], - }); - const olderInput = { state: "open" as const, filters: { checks: "failing" as const } }; - const newerInput = { state: "open" as const, filters: { checks: "passing" as const } }; - - const olderRead = yield* service.list(olderInput).pipe(Effect.forkChild()); - yield* Deferred.await(olderStarted); - yield* TestClock.adjust("1 second"); - const newer = yield* service.list(newerInput); - yield* Deferred.succeed(releaseOlder, undefined); - const older = yield* Fiber.join(olderRead); - - assert.strictEqual(older.entries[0]?.checksState, "failing"); - assert.strictEqual(older.entries[0]?.mergeability, "mergeable"); - assert.strictEqual(newer.entries[0]?.checksState, "passing"); - assert.strictEqual(newer.entries[0]?.mergeability, "conflicting"); - assert.strictEqual(typeof older.entries[0]?.observedAt, "number"); - assert.strictEqual(typeof newer.entries[0]?.observedAt, "number"); - assert.isBelow(older.entries[0]!.observedAt!, newer.entries[0]!.observedAt!); - - const cachedOlder = yield* service.list(olderInput); - assert.strictEqual(cachedOlder.entries[0]?.observedAt, older.entries[0]?.observedAt); - assert.strictEqual(reads, 2); - }), -); - -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"; - 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"), title }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - assert.strictEqual((yield* service.summary(ref))?.title, "old"); - title = "new"; - yield* service.refreshAfterTurn(ref.projectId); - assert.strictEqual((yield* service.summary(ref))?.title, "new"); - for (let index = 0; index < 2048; index++) - yield* service.refreshAfterTurn(`project-${index}` as ProjectId); - assert.strictEqual((yield* service.summary(ref))?.title, "new"); - }), -); - -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.revision, 0); - assert.strictEqual(hostCalls, 3); - }), -); - -it.effect("publishes the first reference invalidation to an existing subscriber", () => - Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ projects: [], providers: [] }); - const firstRefresh = yield* Stream.runHead(service.subscribeRefreshes).pipe( - Effect.forkChild({ startImmediately: true }), - ); - - yield* service.invalidate({ reference }, { notifyReaders: true }); - // Ensure a lagging publication also completes the subscriber instead of leaving the test - // waiting: the reference invalidation itself must still be the first observed revision. - yield* service.refreshAfterTurn(reference.projectId); - - const first = Option.getOrThrow(yield* Fiber.join(firstRefresh)); - assert.deepStrictEqual(first.reference, reference); - assert.isAbove(first.revision, 0); - }), -); - -it.effect("close and reopen notify subscribed readers after invalidating their cached state", () => - Effect.scoped( - Effect.gen(function* () { - let hostCalls = 0; - 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 }), - runAction: (input) => - Effect.sync(() => { - state = input.action === "close" ? "closed" : "open"; - }), - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ items: [], truncated: false, continues: false }); - }, - }), - ], - }); - - yield* service.refreshAfterTurn(reference.projectId); - yield* service.list({ state: "open" }); - assert.strictEqual((yield* service.summary(reference)).state, "open"); - for (const action of ["close", "reopen"] as const) { - const refreshed = yield* service.subscribeRefreshes.pipe( - Stream.drop(1), - Stream.take(1), - Stream.mapEffect(() => - Effect.gen(function* () { - yield* service.list({ state: "open" }); - return yield* service.summary(reference); - }), - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* service.runAction({ ...reference, action }); - assert.strictEqual( - Option.getOrThrow(yield* Fiber.join(refreshed)).state, - action === "close" ? "closed" : "open", - ); - } - assert.strictEqual(hostCalls, 3); - }), - ), -); - -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)).revision; - // The sync reactor invalidates before reading; it must not notify itself again. - yield* service.invalidate({ reference }); - assert.strictEqual( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)).revision, - 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((next) => - service - .summary(reference) - .pipe(Effect.map((summary) => ({ revision: next.revision, 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( - "matches Azure dependencies within the stored project while keeping CLI selectors short", - () => - Effect.gen(function* () { - const parent = { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - headBranch: "migration", - headRepositoryNameWithOwner: "Project/web", - }; - const child = { - ...changeRequest(2, "2026-07-03T00:00:00Z"), - headBranch: "api", - headRepositoryNameWithOwner: "Project/web", - baseBranch: "migration", - }; - const service = yield* makeService({ - projects: [ - project({ - id: "azure-project", - title: "web", - workspaceRoot: "/azure", - repository: "contoso/Project/_git/web", - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - listChangeRequests: (input) => { - assert.strictEqual(input.repository, "web"); - assert.strictEqual(input.cwd, "/azure"); - return Effect.succeed({ - items: [ - parent, - child, - { ...parent, number: 3, headRepositoryNameWithOwner: "Other/web" }, - ], - truncated: false, - continues: false, - }); - }, - }), - ], - }); - - const result = yield* service.dependencyContext({ - projectId: "azure-project" as ProjectId, - repository: "web", - number: 2, - }); - assert.deepStrictEqual(result.edges, [{ child: 2, parent: 1, certainty: "confirmed" }]); - assert.deepStrictEqual( - result.nodes.map((node) => node.ref.number), - [1, 2], - ); - assert.strictEqual(result.focus.repository, "web"); - assert.strictEqual(result.coverage, "complete"); - }), + 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("caches inferred reads by repository without fetching native stacks", () => +it.effect("resolves a project's repository identity when its shell has none cached", () => Effect.gen(function* () { - let relationshipReads = 0; - let nativeReads = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; - const parent = { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - headBranch: "migration", - headRepositoryNameWithOwner: "acme/web", - }; - const child = { - ...changeRequest(2, "2026-07-03T00:00:00Z"), - headBranch: "api", - headRepositoryNameWithOwner: "acme/web", - baseBranch: "migration", - }; + const resolved = project({ + id: "web", + title: "web", + workspaceRoot: "/web", + repository: "acme/web", + }); const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [{ ...resolved, repositoryIdentity: null }], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - relationshipReads += 1; - assert.strictEqual(input.host, "github.com"); - assert.strictEqual(input.repository, "acme/web"); - assert.strictEqual(input.state, "open"); - assert.strictEqual(input.involvement, "all"); - assert.strictEqual(input.limit, 200); - assert.strictEqual(input.relationshipOnly, true); - return Effect.succeed({ items: [parent, child], truncated: false, continues: false }); - }, - getChangeRequestStack: () => { - nativeReads += 1; - return Effect.succeed(null); - }, + getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], + resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null), }); - const [first, second] = yield* Effect.all( - [service.dependencyContext(reference), service.dependencyContext(reference)], - { concurrency: 2 }, + const summary = yield* service.summary( + { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 }, + { recoverTransientFailure: false }, ); - assert.deepStrictEqual(first, second); - assert.deepStrictEqual(first.edges, [{ child: 2, parent: 1, certainty: "confirmed" }]); - assert.strictEqual(relationshipReads, 1); - assert.strictEqual(nativeReads, 0); - - yield* service.dependencyContext({ ...reference, number: 1 }); - assert.strictEqual(relationshipReads, 1); - assert.strictEqual(nativeReads, 0); - yield* service.invalidate({ reference }); - yield* service.dependencyContext(reference); - assert.strictEqual(relationshipReads, 2); - assert.strictEqual(nativeReads, 0); - - yield* service.update({ ...reference, title: "Renamed" }); - yield* service.dependencyContext(reference); - assert.strictEqual(relationshipReads, 3); - assert.strictEqual(nativeReads, 0); - yield* service.stack(reference); - yield* service.stack(reference); - assert.strictEqual(nativeReads, 1); - assert.isAbove( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)).revision, - 0, - ); + assert.strictEqual(summary.number, 7); }), ); -it.effect("retries a failed relationship read immediately and caches the recovered context", () => +it.effect("routes a hosted reference to another repository through a project on that host", () => Effect.gen(function* () { - let relationshipReads = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const seen: Array<{ cwd: string; repository: string; host: string }> = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => { - relationshipReads += 1; - if (relationshipReads === 1) return Effect.fail(requestFailed); - return Effect.succeed({ - items: [ - { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - headBranch: "migration", - headRepositoryNameWithOwner: "acme/web", - }, - { - ...changeRequest(2, "2026-07-03T00:00:00Z"), - headBranch: "api", - headRepositoryNameWithOwner: "acme/web", - baseBranch: "migration", - }, - ], - truncated: false, - continues: false, - }); - }, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), }), ], }); - assert.strictEqual((yield* service.dependencyContext(reference)).coverage, "unavailable"); - const recovered = yield* service.dependencyContext(reference); - assert.strictEqual(recovered.coverage, "complete"); - assert.deepStrictEqual(recovered.edges, [{ child: 2, parent: 1, certainty: "confirmed" }]); - assert.strictEqual(relationshipReads, 2); - assert.deepStrictEqual(yield* service.dependencyContext(reference), recovered); - yield* service.dependencyContext({ ...reference, number: 1 }); - assert.strictEqual(relationshipReads, 2); + 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("retries a failed focus summary and caches the recovered context", () => +it.effect("routes Azure reads and writes through the requested organization's checkout", () => Effect.gen(function* () { - let relationshipReads = 0; - let summaryReads = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + 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"), { - listChangeRequests: () => { - relationshipReads += 1; - return Effect.succeed({ - items: [ - { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - headBranch: "migration", - headRepositoryNameWithOwner: "acme/web", - }, - ], - truncated: false, - continues: false, - }); - }, - getChangeRequestSummary: () => { - summaryReads += 1; - return summaryReads === 1 - ? Effect.fail(requestFailed) - : Effect.succeed({ - ...changeRequest(2, "2026-07-03T00:00:00Z"), - headBranch: "api", - baseBranch: "migration", - }); - }, + 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 degraded = yield* service.dependencyContext(reference); - assert.isTrue(degraded.issues.some((issue) => issue.reason === "host-unavailable")); - const recovered = yield* service.dependencyContext(reference); - assert.isFalse(recovered.issues.some((issue) => issue.reason === "host-unavailable")); - assert.isTrue(recovered.nodes.some((node) => node.ref.number === 2)); - assert.strictEqual(relationshipReads, 1); - assert.strictEqual(summaryReads, 2); - assert.deepStrictEqual(yield* service.dependencyContext(reference), recovered); - assert.strictEqual(summaryReads, 2); + 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("retries failed native stack reads and caches a successful absence", () => +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 reads = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + 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"), { - getChangeRequestStack: () => { - reads += 1; - return reads === 1 ? Effect.fail(requestFailed) : Effect.succeed(null); - }, + 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 failure = yield* Effect.flip(service.stack(reference)); - assert.strictEqual(failure._tag, "PullRequestOperationError"); - assert.isNull(yield* service.stack(reference)); - assert.isNull(yield* service.stack(reference)); - assert.strictEqual(reads, 2); + 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("treats omitted raw relationship rows as partial coverage", () => +it.effect("refuses Azure cross-organization reads and writes without its checkout", () => Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; const service = yield* makeService({ projects: [ project({ - id: "p1", - title: "web", - workspaceRoot: "/a", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), + 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("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - headBranch: "migration", - headRepositoryNameWithOwner: "acme/web", - }, - { - ...changeRequest(2, "2026-07-03T00:00:00Z"), - headBranch: "api", - headRepositoryNameWithOwner: "acme/web", - baseBranch: "migration", - }, - ], - truncated: false, - cursorAdvance: 3, - continues: false, + 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("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"]); + }), +); - const result = yield* service.dependencyContext(reference); - assert.strictEqual(result.coverage, "partial"); - assert.deepStrictEqual(result.edges, [{ child: 2, parent: 1, certainty: "candidate" }]); - assert.isTrue(result.issues.some((issue) => issue.reason === "budget")); +// 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"))], + }); + 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("treats duplicate provider rows as partial relationship coverage", () => +it.effect("refuses a review request this viewer may not make, and says what access it takes", () => Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; - const parent = { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - headBranch: "migration", - headRepositoryNameWithOwner: "acme/web", - }; - const child = { - ...changeRequest(2, "2026-07-03T00:00:00Z"), - headBranch: "api", - headRepositoryNameWithOwner: "acme/web", - baseBranch: "migration", - }; + let sent = false; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => + getViewerPermissions: () => Effect.succeed({ - items: [parent, child, parent], - truncated: false, - continues: false, + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, }), + setReviewerRequest: () => { + sent = true; + return Effect.void; + }, }), ], }); - const result = yield* service.dependencyContext(reference); - assert.strictEqual(result.coverage, "partial"); - assert.deepStrictEqual(result.edges, [{ child: 2, parent: 1, certainty: "candidate" }]); - assert.isTrue(result.issues.some((issue) => issue.reason === "budget")); + 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); }), ); -it.effect("keeps a merged focus visible without linking it to a reused live branch", () => +it.effect.each(["list", "listStats"] as const)( + "retries a replacement %s read that joins a canceled lookup", + (operation) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stopping = yield* Deferred.make(); + const finishShutdown = yield* Deferred.make(); + let hostCalls = 0; + const lookup = Effect.gen(function* () { + if (++hostCalls !== 1) return; + yield* Deferred.succeed(started, undefined); + return yield* Effect.never.pipe( + Effect.onInterrupt(() => + Deferred.succeed(stopping, undefined).pipe( + Effect.andThen(Deferred.await(finishShutdown)), + ), + ), + ); + }); + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: () => + lookup.pipe( + Effect.as({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }), + ), + listChangeRequestStats: () => + lookup.pipe( + Effect.as([{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]), + ), + }), + ], + }); + const read = + operation === "list" + ? service + .list({ state: "open" }) + .pipe(Effect.map((result) => result.entries.map((entry) => entry.number))) + : service + .listStats({ + refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], + }) + .pipe(Effect.map((result) => result.stats.map((stat) => stat.number))); + const original = yield* Effect.forkScoped(read); + yield* Deferred.await(started); + const cancellation = yield* Effect.forkScoped(Fiber.interrupt(original)); + yield* Deferred.await(stopping); + const replacement = yield* Effect.forkScoped(read, { startImmediately: true }); + yield* Deferred.succeed(finishShutdown, undefined); + yield* Fiber.join(cancellation); + const canceled = yield* Fiber.await(original); + assert.isTrue(Exit.isFailure(canceled)); + if (Exit.isFailure(canceled)) assert.isTrue(Cause.hasInterruptsOnly(canceled.cause)); + assert.deepStrictEqual(yield* Fiber.join(replacement), [1]); + assert.strictEqual(hostCalls, 2); + }), +); + +it.effect("shares one cold viewer lookup across distinct concurrent lists", () => Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + let viewerCalls = 0; + let listCalls = 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; + }).pipe(Effect.andThen(Effect.yieldNow), Effect.as("bilal")), listChangeRequests: () => - Effect.succeed({ - items: [ - { - ...changeRequest(1, "2026-07-04T00:00:00Z"), - headBranch: "reused-branch", - headRepositoryNameWithOwner: "acme/web", - }, - ], - truncated: false, - continues: false, - }), - getChangeRequestSummary: () => - Effect.succeed({ - ...changeRequest(2, "2026-07-03T00:00:00Z"), - state: "merged", - baseBranch: "reused-branch", + Effect.sync(() => { + listCalls += 1; + return { items: [], truncated: false, continues: true }; }), }), ], }); - const result = yield* service.dependencyContext(reference); - assert.deepStrictEqual( - result.nodes.map((node) => [node.ref.number, node.state]), - [[2, "merged"]], + yield* Effect.forEach( + ["all", "authored", "reviewing"], + (involvement) => + service.list({ + state: "open", + involvement: involvement as "all" | "authored" | "reviewing", + }), + { concurrency: "unbounded" }, ); - assert.deepStrictEqual(result.edges, []); + + assert.strictEqual(viewerCalls, 1); + assert.strictEqual(listCalls, 3); }), ); -it.effect("keeps native stack members out of inferred branch relationships", () => +it.effect("a listing narrowed to some projects is its own cache entry", () => Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const asked: ReadonlyArray[] = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + 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: () => Effect.succeed(hostedChangeRequest("body")), - listChangeRequests: () => - Effect.succeed({ - items: [ - { - ...changeRequest(2, "2026-07-03T00:00:00Z"), - headBranch: "api", - headRepositoryNameWithOwner: "acme/web", - baseBranch: "migration", - }, - ], + listChangeRequestsAcross: (input) => { + asked.push(input.repositories); + return Effect.succeed({ + items: input.repositories.map((repository, index) => + batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"), + ), truncated: false, - continues: false, - }), - getChangeRequestStack: () => - Effect.succeed({ - id: "STACK_1", - number: 1, - url: "https://github.com/acme/web/stacks/1", - base: "main", - layers: [ - { number: 1, headBranch: "migration", state: "merged" }, - { number: 2, headBranch: "api", state: "open" }, - ], - }), + }); + }, }), ], }); - const stack = yield* service.stack(reference); - assert.deepStrictEqual( - stack?.layers.map((layer) => layer.number), - [1, 2], - ); - const context = yield* service.dependencyContext(reference); + + yield* service.list({ state: "open" }); + const narrowed = yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); + + assert.deepStrictEqual(asked, [["acme/web", "acme/docs"], ["acme/docs"]]); assert.deepStrictEqual( - context.nodes.map((node) => node.ref.number), - [2], + narrowed.entries.map((entry) => entry.repository), + ["acme/docs"], ); - assert.deepStrictEqual(context.edges, []); - assert.notProperty(context, "native"); - const detail = yield* service.detail(reference); - assert.deepStrictEqual(detail.capabilities.dependencies, { - branchRelationships: true, - nativeMembership: false, - }); + + yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); + assert.strictEqual(asked.length, 2); }), ); -it.effect("returns an unavailable context when the relationship listing fails", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => Effect.fail(requestFailed), - }), - ], - }); +it.effect( + "keeps listing freshness tied to read start when filtered reads finish out of order", + () => + Effect.gen(function* () { + const olderStarted = yield* Deferred.make(); + const releaseOlder = yield* Deferred.make(); + let reads = 0; + const updatedAt = "2026-07-02T00:00:00Z"; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: ({ filters }) => + Effect.gen(function* () { + reads += 1; + const older = filters?.checks === "failing"; + if (older) { + yield* Deferred.succeed(olderStarted, undefined); + yield* Deferred.await(releaseOlder); + } + return { + items: [ + { + ...changeRequest(1, updatedAt), + checksState: older ? ("failing" as const) : ("passing" as const), + mergeability: older ? ("mergeable" as const) : ("conflicting" as const), + }, + ], + truncated: false, + continues: false, + }; + }), + }), + ], + }); + const olderInput = { state: "open" as const, filters: { checks: "failing" as const } }; + const newerInput = { state: "open" as const, filters: { checks: "passing" as const } }; - const result = yield* service.dependencyContext({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 2, - }); - assert.strictEqual(result.coverage, "unavailable"); - assert.deepStrictEqual(result.nodes, []); - assert.deepStrictEqual(result.issues, [{ reason: "host-unavailable" }]); - }), + const olderRead = yield* service.list(olderInput).pipe(Effect.forkChild()); + yield* Deferred.await(olderStarted); + yield* TestClock.adjust("1 second"); + const newer = yield* service.list(newerInput); + yield* Deferred.succeed(releaseOlder, undefined); + const older = yield* Fiber.join(olderRead); + + assert.strictEqual(older.entries[0]?.checksState, "failing"); + assert.strictEqual(older.entries[0]?.mergeability, "mergeable"); + assert.strictEqual(newer.entries[0]?.checksState, "passing"); + assert.strictEqual(newer.entries[0]?.mergeability, "conflicting"); + assert.strictEqual(typeof older.entries[0]?.observedAt, "number"); + assert.strictEqual(typeof newer.entries[0]?.observedAt, "number"); + assert.isBelow(older.entries[0]!.observedAt!, newer.entries[0]!.observedAt!); + + const cachedOlder = yield* service.list(olderInput); + assert.strictEqual(cachedOlder.entries[0]?.observedAt, older.entries[0]?.observedAt); + assert.strictEqual(reads, 2); + }), ); -it.effect("does not cache a failed listing", () => +it.effect("does not revive old summaries when project epochs are evicted", () => Effect.gen(function* () { - let hostCalls = 0; + let title = "old"; 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"); - }, + getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }), }), ], }); - - 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); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.strictEqual((yield* service.summary(ref))?.title, "old"); + title = "new"; + yield* service.refreshAfterTurn(ref.projectId); + assert.strictEqual((yield* service.summary(ref))?.title, "new"); + for (let index = 0; index < 2048; index++) + yield* service.refreshAfterTurn(`project-${index}` as ProjectId); + assert.strictEqual((yield* service.summary(ref))?.title, "new"); }), ); +it.effect("close and reopen notify subscribed readers after invalidating their cached state", () => + Effect.scoped( + Effect.gen(function* () { + let hostCalls = 0; + 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 }), + runAction: (input) => + Effect.sync(() => { + state = input.action === "close" ? "closed" : "open"; + }), + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + + yield* service.refreshAfterTurn(reference.projectId); + yield* service.list({ state: "open" }); + assert.strictEqual((yield* service.summary(reference)).state, "open"); + for (const action of ["close", "reopen"] as const) { + const refreshed = yield* service.subscribeRefreshes.pipe( + Stream.drop(1), + Stream.take(1), + Stream.mapEffect(() => + Effect.gen(function* () { + yield* service.list({ state: "open" }); + return yield* service.summary(reference); + }), + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* service.runAction({ ...reference, action }); + assert.strictEqual( + Option.getOrThrow(yield* Fiber.join(refreshed)).state, + action === "close" ? "closed" : "open", + ); + } + assert.strictEqual(hostCalls, 3); + }), + ), +); + it.effect("reads a host's repositories in one search, and files the rows back under each", () => Effect.gen(function* () { const asked: Array> = []; @@ -5440,36 +2331,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 = []; @@ -5523,58 +2384,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* () { @@ -5631,164 +2440,6 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis }), ); -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")), - ), - ), - }), - ], - }); - - yield* Effect.all( - [ - service.summary(reference, { recoverTransientFailure: false }), - service.summary(reference, { recoverTransientFailure: false }), - ], - { concurrency: "unbounded" }, - ); - assert.strictEqual(calls, 1); - - 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.invalidate({ reference }); - const invalidated = yield* Effect.flip(service.summary(reference)); - assert.strictEqual(invalidated._tag, "PullRequestOperationError"); - }), -); - it.effect("keeps routed reads separate when the GitHub account changes", () => Effect.gen(function* () { for (const operation of ["summary", "detail", "checks", "diff", "filesViewed"] as const) { @@ -5980,64 +2631,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", () => @@ -6077,127 +2670,32 @@ it.effect( assert.strictEqual(summary.mergeability, "conflicting"); assert.strictEqual(summary.reviewDecision, "approved"); assert.strictEqual(summary.checksState, "passing"); - }), -); - -it.effect("reuses an observed merged state for strict settlement reads", () => - Effect.gen(function* () { - 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("merged body", 4), - state: "merged", - updatedAt: "2026-07-03T00:00:00Z", - }), - getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), - }), - ], - }); - - yield* service.detail(reference); - - const summary = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(summary.state, "merged"); - assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); - }), + }), ); -it.effect("announces state a detail read sees first or newly", () => +it.effect("reuses an observed merged state for strict settlement reads", () => 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: () => + getChangeRequest: () => Effect.succeed({ - ...changeRequest(1, "2026-07-04T00:00:00Z"), - state: summaryState, + ...hostedChangeRequest("merged body", 4), + state: "merged", + updatedAt: "2026-07-03T00:00:00Z", }), + getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), }), ], }); - 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* service.detail(reference); - 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); + const summary = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(summary.state, "merged"); + assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); }), ); @@ -6257,205 +2755,6 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary }), ); -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, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open", filters: { draft: "hide" } }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.number), - [2], - ); - }), -); - it.effect("keeps a row of a host that ignored the filters if any name of a label group holds", () => Effect.gen(function* () { const sized = (number: number, updatedAt: string, ...names: ReadonlyArray) => ({ @@ -6501,45 +2800,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) => @@ -6602,90 +2862,39 @@ it.effect.each([false, true])( const input = { ...(crossHost ? { host: "enterprise.test" } : {}), projectId: "p1" as ProjectId, - repository: "acme/web", - number: 3, - action: "update-branch" as const, - updateMethod: "rebase" as const, - stackNumber: 50, - expectedStackHeads: [{ number: 3, headSha: "ccc" }], - }; - yield* service.runAction(input); - assert.strictEqual(taken, 1); - const unrelated = { ...input, number: 8 }; - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackRebase = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - stackRebase = true; - stackActions = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - assert.strictEqual(taken, 1); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackActions = true; - mutationFails = true; - yield* Effect.flip(service.runAction(input)); - assert.strictEqual(taken, 2); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 2); - }), -); - -it.effect("updates an authorized GitHub branch and refuses it after update access is revoked", () => - Effect.gen(function* () { - const taken: string[] = []; - let canUpdateBranch = true; - 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: ["close", "update-branch"], - mergeMethods: [], - updateMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - // GitHub's light permission response cannot establish whether the branch is updatable. - getViewerPermissions: ({ includeUpdateBranch }) => { - const allowsUpdate = includeUpdateBranch === true && canUpdateBranch; - return Effect.succeed({ - actions: allowsUpdate ? ["close", "update-branch"] : ["close"], - updateMethods: allowsUpdate ? ["merge"] : [], - comment: true, - resolve: false, - verdicts: [], - requestReviewers: false, - }); - }, - runAction: ({ action }) => - Effect.sync(() => { - taken.push(action); - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.runAction({ ...reference, action: "close" }); - yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }); - assert.deepEqual(taken, ["close", "update-branch"]); - canUpdateBranch = false; - const refused = yield* Effect.flip( - service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }), - ); - assert.strictEqual(refused._tag, "PullRequestOperationError"); - assert.deepEqual(taken, ["close", "update-branch"]); - }), + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.strictEqual(taken, 1); + const unrelated = { ...input, number: 8 }; + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackRebase = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + stackRebase = true; + stackActions = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + assert.strictEqual(taken, 1); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackActions = true; + mutationFails = true; + yield* Effect.flip(service.runAction(input)); + assert.strictEqual(taken, 2); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 2); + }), ); it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => @@ -6735,61 +2944,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({ @@ -6820,303 +2974,20 @@ it.effect("judges the review filter only on a host that summarises its reviews", }), 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("judges checks from provider rows while preserving rows without a check summary", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/a", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitea"), - host: "forge.example.test", - }), - ], - providers: [ - // Gitea can carry its check rollup on a row, but older or unconfigured hosts may leave it - // undefined. A null rollup is different: it says the host checked and found no checks. - fakeProvider(SourceControlProviderKind.make("gitea"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { ...changeRequest(1, "2026-07-04T00:00:00Z"), checksState: "passing" }, - { ...changeRequest(2, "2026-07-03T00:00:00Z"), checksState: "failing" }, - { ...changeRequest(3, "2026-07-02T00:00:00Z"), checksState: "pending" }, - { ...changeRequest(4, "2026-07-01T00:00:00Z"), checksState: null }, - changeRequest(5, "2026-06-30T00:00:00Z"), - ], - truncated: false, - continues: false, - }), - }), - ], - }); - - const passing = yield* service.list({ state: "open", filters: { checks: "passing" } }); - assert.deepStrictEqual( - passing.entries.map((entry) => entry.number), - [1, 5], - ); - - const failing = yield* service.list({ state: "open", filters: { checks: "failing" } }); - assert.deepStrictEqual( - failing.entries.map((entry) => entry.number), - [2, 5], - ); - }), -); - -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, - }, + 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]); }), ); @@ -7179,231 +3050,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("uses discovered host viewed-file support without dropping the diff cache", () => - 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: "environment", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getCapabilities: () => - Effect.succeed({ - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - viewedFiles: "host", - }), - 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); - - // The press forgets only the reader's own ticks; cached diffs survive it. - 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>, @@ -7597,196 +3243,59 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi 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 }], + 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" }, + ]); - assert.deepStrictEqual(asked, [["src/a.ts"]]); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); + 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( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], + (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" }], ); - - 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 }], - }); - 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); - - assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]); - assert.strictEqual(asked.length, 2); }), ); -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( @@ -7797,16 +3306,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( @@ -7816,44 +3326,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"]], ); }), ); @@ -7887,26 +3363,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(), []); @@ -7935,156 +3391,40 @@ it.effect("leaves a mark alone when the host could not say what the head has of ); yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/past-the-cut.ts", viewed: true }, - ], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "dismissed" }, - { path: "src/past-the-cut.ts", state: "viewed" }, - ], - ); - }), -); - -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"]]); - 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.runAction({ ...GITLAB_REFERENCE, action: "merge" }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - }), -); - -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), - }), + ...GITLAB_REFERENCE, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/past-the-cut.ts", viewed: true }, ], }); + revisions.set("src/a.ts", "blob-a-again"); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/past-the-cut.ts", state: "viewed" }, + ], + ); + }), +); + +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"]]); + const service = yield* environmentViewedService(revisions, []); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true }], }); - answering = false; - yield* service.invalidate({ reference: GITLAB_REFERENCE }); + revisions.set("src/a.ts", "blob-a-again"); + yield* service.runAction({ ...GITLAB_REFERENCE, action: "merge" }); assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, + { path: "src/a.ts", state: "dismissed" }, ]); }), ); @@ -8224,97 +3564,6 @@ it.effect("keeps environment marks apart from another change request's", () => }), ); -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" }, - ], - ); - }), -); - -it.effect("keeps the change request being ticked through, not the one pressed first", () => - 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 }], - }); - - yield* press(1); - for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) { - yield* press(2 + filled); - yield* press(1); - } - - assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY); - }), -); - const environmentViewedServiceWithViewer = ( revisions: Map, getViewer: PullRequestProviderApi["getViewer"], @@ -8326,326 +3575,50 @@ const environmentViewedServiceWithViewer = ( title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], - }); - -it.effect("keeps one reader's marks on a host that names nobody", () => - Effect.gen(function* () { - const service = yield* environmentViewedServiceWithViewer( - new Map([["src/a.ts", "blob-a"]]), - () => Effect.succeed(""), - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - }), -); - -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> = []; - const paths = Array.from( - { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 }, - (_, at) => `src/f${String(at).padStart(4, "0")}.ts`, - ); - const service = yield* environmentViewedService( - new Map(paths.map((path) => [path, "blob"] as const)), - asked, - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: paths.map((path) => ({ path, viewed: true })), - }); - const read = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS); - assert.strictEqual(read.truncated, true); - }), -); - -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); - }), -); + provider: SourceControlProviderKind.make("gitlab"), + }), + ], + providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], + }); -it.effect("refuses the marks when the host could not be asked who is reading", () => +it.effect("keeps one reader's marks on a host that names nobody", () => 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", - }), - ), + () => Effect.succeed(""), ); 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", () => +it.effect("carries a bounded number of its own marks and says it held more", () => 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 }] }), + const asked: Array> = []; + const paths = Array.from( + { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 }, + (_, at) => `src/f${String(at).padStart(4, "0")}.ts`, + ); + const service = yield* environmentViewedService( + new Map(paths.map((path) => [path, "blob"] as const)), + asked, ); - assert.strictEqual(read._tag, "PullRequestOperationError"); - assert.strictEqual(write._tag, "PullRequestOperationError"); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: paths.map((path) => ({ path, viewed: true })), + }); + const read = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS); + assert.strictEqual(read.truncated, true); }), ); @@ -8686,353 +3659,6 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ); -it.effect("reruns CI through the selected host and refreshes the PR for other clients", () => - Effect.gen(function* () { - let attempt = 1; - const provider = fakeProvider(SourceControlProviderKind.make("gitea"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest(`attempt ${attempt}`)), - getCiRuns: () => Effect.succeed({ headSha: "head", runs: [], truncated: false }), - getCiJobs: () => Effect.succeed({ jobs: [], truncated: false }), - rerunCi: (input) => - Effect.sync(() => { - assert.strictEqual(input.host, "forge.test"); - assert.strictEqual(input.repository, "acme/web"); - assert.strictEqual(input.cwd, "/workspace"); - attempt += 1; - }), - }); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "Web", - workspaceRoot: "/workspace", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitea"), - host: "forge.test", - }), - ], - providers: [{ ...provider, capabilities: { ...provider.capabilities, ciRuns: true } }], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - assert.strictEqual((yield* service.detail(reference)).body, "attempt 1"); - yield* service.rerunCi({ - ...reference, - headSha: "head", - runId: "12", - attempt: 1, - target: { kind: "failed" }, - }); - assert.strictEqual((yield* service.detail(reference)).body, "attempt 2"); - const refresh = yield* service.subscribeRefreshes.pipe(Stream.take(1), Stream.runHead); - assert.deepEqual(Option.getOrThrow(refresh).reference, reference); - assert.isTrue(Option.getOrThrow(refresh).listings); - }), -); - -it.effect("does not call CI adapters without an advertised capability", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "Web", workspaceRoot: "/workspace", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getCiRuns: () => Effect.die("unsupported read"), - getCiJobs: () => Effect.die("unsupported read"), - rerunCi: () => Effect.die("unsupported write"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - assert.strictEqual( - (yield* service.ciRuns(reference).pipe(Effect.flip))._tag, - "PullRequestOperationError", - ); - assert.strictEqual( - (yield* service - .ciJobs({ ...reference, headSha: "head", runId: "12", attempt: 1 }) - .pipe(Effect.flip))._tag, - "PullRequestOperationError", - ); - assert.strictEqual( - (yield* service - .rerunCi({ - ...reference, - headSha: "head", - runId: "12", - attempt: 1, - target: { kind: "all" }, - }) - .pipe(Effect.flip))._tag, - "PullRequestOperationError", - ); - }), -); - -it.effect("saves viewed progress through the selected host and refreshes other clients", () => - Effect.gen(function* () { - const viewed = { headSha: "head", files: [{ path: "src/a.ts", viewed: false }] }; - const provider = fakeProvider(SourceControlProviderKind.make("github"), { - getViewedFiles: () => Effect.succeed(viewed), - setFileViewed: (input) => - Effect.sync(() => { - assert.strictEqual(input.host, "github.com"); - assert.strictEqual(input.repository, "pingdotgg/t3code"); - assert.strictEqual(input.headSha, "head"); - assert.strictEqual(input.path, "src/a.ts"); - viewed.files[0]!.viewed = input.viewed; - }), - }); - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - { ...provider, capabilities: { ...provider.capabilities, fileViewedState: true } }, - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - assert.isFalse((yield* service.viewedFiles(reference)).files[0]!.viewed); - yield* service.setFileViewed({ ...reference, path: "src/a.ts", headSha: "head", viewed: true }); - assert.isTrue((yield* service.viewedFiles(reference)).files[0]!.viewed); - const refresh = yield* service.subscribeRefreshes.pipe(Stream.take(1), Stream.runHead); - assert.deepEqual(Option.getOrThrow(refresh).reference, reference); - assert.isFalse(Option.getOrThrow(refresh).listings); - yield* service.setFileViewed({ - ...reference, - path: "src/a.ts", - headSha: "head", - viewed: false, - }); - assert.isFalse((yield* service.viewedFiles(reference)).files[0]!.viewed); - }), -); - -it.effect("rejects viewed-file requests on hosts without the capability", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewedFiles: () => Effect.die("unsupported"), - setFileViewed: () => Effect.die("unsupported"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - assert.strictEqual( - (yield* service.viewedFiles(reference).pipe(Effect.flip))._tag, - "PullRequestOperationError", - ); - assert.strictEqual( - (yield* service - .setFileViewed({ ...reference, path: "a.ts", headSha: "head", viewed: true }) - .pipe(Effect.flip))._tag, - "PullRequestOperationError", - ); - }), -); - -it.effect("does not reuse a cached diff after the viewed PR head changes", () => - Effect.gen(function* () { - let patch = "old patch"; - let reads = 0; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - reads += 1; - return { patch, truncated: false, nextCursor: null }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - assert.strictEqual( - (yield* service.diff({ ...reference, headSha: "old-head" })).patch, - "old patch", - ); - patch = "new patch"; - assert.strictEqual( - (yield* service.diff({ ...reference, headSha: "new-head" })).patch, - "new patch", - ); - assert.strictEqual( - (yield* service.diff({ ...reference, headSha: "new-head" })).patch, - "new patch", - ); - assert.strictEqual(reads, 2); - }), -); - -it.effect( - "refreshes hosted and legacy references through borrowed checkouts without crossing hosts", - () => - Effect.gen(function* () { - const reads: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "web", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - project({ id: "api", title: "api", workspaceRoot: "/api", repository: "acme/api" }), - project({ - id: "enterprise", - title: "enterprise", - workspaceRoot: "/enterprise", - repository: "acme/api", - host: "github.example.com", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestActivity: (input) => - Effect.sync(() => { - reads.push(`${input.host}:${input.repository}`); - return { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ], - }); - const own = { projectId: "api" as ProjectId, repository: "acme/api", number: 7 }; - const hosted = { ...own, host: "github.com" }; - const borrowed = { ...hosted, projectId: "enterprise" as ProjectId }; - const otherHost = { ...own, projectId: "enterprise" as ProjectId }; - const references = [own, hosted, borrowed, otherHost]; - for (const ref of references) yield* service.activity(ref); - // One host request per repository however it is addressed; the other host is its own. - assert.deepStrictEqual(reads, ["github.com:acme/api", "github.example.com:acme/api"]); - yield* service.setReaction({ ...borrowed, content: "heart", reacted: true }); - for (const ref of references) yield* service.activity(ref); - assert.deepStrictEqual(reads.slice(2), ["github.com:acme/api"]); - yield* service.setReaction({ ...own, content: "heart", reacted: false }); - for (const ref of references) yield* service.activity(ref); - assert.deepStrictEqual(reads.slice(3), ["github.com:acme/api"]); - }), -); - -it.effect( - "retains a borrowed reference host in dependency caches and refreshes every stack layer", - () => - Effect.gen(function* () { - let relationships = 0; - let stacks = 0; - const service = yield* makeService({ - projects: [ - project({ id: "web", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => - Effect.sync(() => { - assert.strictEqual(input.repository, "acme/api"); - relationships++; - return { - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }; - }), - getChangeRequestStack: () => - Effect.sync(() => { - stacks++; - return null; - }), - }), - ], - }); - const reference = { - projectId: "web" as ProjectId, - host: "github.com", - repository: "acme/api", - number: 1, - }; - const context = yield* service.dependencyContext(reference); - assert.deepStrictEqual(context.focus, reference); - assert.deepStrictEqual(context.nodes[0]?.ref, reference); - yield* service.dependencyContext(reference); - yield* service.stack(reference); - yield* service.stack(reference); - assert.strictEqual(relationships, 1); - assert.strictEqual(stacks, 1); - yield* service.invalidate({ reference: { ...reference, number: 2 } }); - yield* service.dependencyContext(reference); - yield* service.stack(reference); - assert.strictEqual(relationships, 2); - assert.strictEqual(stacks, 2); - }), -); - -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; @@ -9188,47 +3814,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 ecb347d77a86..000000000000 --- a/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts +++ /dev/null @@ -1,35 +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 }, - { path: "README.md", additions: 7, deletions: 3 }, - ], - }), - ).toEqual({ - code: { additions: 15, deletions: 3 }, - tests: { additions: 40, deletions: 0 }, - mocks: { additions: 0, deletions: 0 }, - generated: { additions: 300, deletions: 120 }, - docs: { additions: 7, deletions: 3 }, - }); - }); - - 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/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 6fdad0ced383..c6a61817f69b 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 0b855eaef5ff..25c119ed47f9 100644 --- a/apps/server/src/resourceTelemetry/Model.test.ts +++ b/apps/server/src/resourceTelemetry/Model.test.ts @@ -447,45 +447,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 e99a24453e5a..de147045c230 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -115,6 +115,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 +138,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 c4273d7336ac..51ed62d08982 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts @@ -6,7 +6,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 +13,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 +214,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 +322,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* () { @@ -539,183 +389,4 @@ describe("ResourceTelemetry", () => { }), ), ); - - it.effect("combines native, Electron, attribution, retry, and history data", () => - 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)), - lastError: Option.none(), - }), - }); - 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); - - 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); - - 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"); - }).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 e989768188d0..f5973969747f 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -1,36 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; -import { - isMissedFixedTimeRun, - isSameSchedule, - nextScheduledRunAt, - parseTimeOfDay, -} from "./Schedule.ts"; +import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; 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] }, @@ -53,18 +26,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 }), @@ -115,17 +76,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 63c1526f1ca0..4cabb3539336 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.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 108a1c07b98f..057f777d81db 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -16,6 +16,7 @@ import { type ScheduledTaskListWebhookDeliveriesResult, type ScheduledTaskRotateWebhookTokenInput, type ScheduledTaskWebhookDeliveryOutcome, + type ScheduledTaskWebhookFilter, type ScheduledTaskListResult, type ScheduledTaskMutationResult, type ScheduledTaskRunNowInput, @@ -29,6 +30,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"; @@ -54,15 +56,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; @@ -94,7 +119,7 @@ class WebhookDeliverySkipped extends Data.TaggedError("WebhookDeliverySkipped")< }> {} interface RateWindow { - readonly accepted: ReadonlyArray; + readonly accepted: ReadonlyArray<{ readonly at: number }>; readonly rejectedLogged: boolean; } @@ -109,12 +134,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"; @@ -122,7 +149,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" } @@ -133,6 +160,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( @@ -190,6 +220,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; } @@ -394,6 +425,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 @@ -613,11 +654,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} @@ -659,9 +701,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; @@ -672,12 +715,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", { @@ -691,7 +737,11 @@ export const layer = Layer.effect( const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( task: ScheduledTask, trigger: "scheduled" | "manual" | "webhook", - webhook?: { readonly deliveryId: string; readonly prompt: string }, + webhook?: { + readonly deliveryId: string; + readonly prompt: string; + readonly dispatched: Ref.Ref; + }, ) { const reserved = yield* Ref.modify(activeRuns, (active) => { if (active.has(task.id)) return [false, active] as const; @@ -725,6 +775,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 ( @@ -750,7 +806,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 = @@ -807,8 +878,9 @@ export const layer = Layer.effect( .pipe(Effect.as(ThreadId.make(active.threadId))), ); - 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; @@ -827,7 +899,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, @@ -835,11 +907,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); @@ -916,7 +1009,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; @@ -930,7 +1027,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' `; }), @@ -1018,6 +1119,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 = @@ -1140,6 +1255,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); @@ -1239,6 +1369,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, }); @@ -1247,11 +1378,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 }), ), @@ -1261,15 +1399,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 { @@ -1284,6 +1426,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; @@ -1295,10 +1455,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* () { @@ -1329,15 +1486,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} ) `; }), @@ -1356,13 +1526,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 [ @@ -1370,7 +1542,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 [ @@ -1380,11 +1552,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, ) { @@ -1401,6 +1597,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, @@ -1548,16 +1821,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"); @@ -1591,6 +1867,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 })), @@ -1603,85 +1995,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/scheduling/Scheduler.integration.test.ts b/apps/server/src/scheduling/Scheduler.integration.test.ts deleted file mode 100644 index 089b12f991ec..000000000000 --- a/apps/server/src/scheduling/Scheduler.integration.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { assert, expect, it } from "@effect/vitest"; -import { - CommandId, - DEFAULT_SERVER_SETTINGS, - ProjectId, - ProviderInstanceId, - RunId, - ThreadId, - type OrchestrationV2ServerCommand, - type OrchestrationV2ThreadShell, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; -import * as SqlClient from "effect/sql/SqlClient"; -import * as TestClock from "effect/testing/TestClock"; - -import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; -import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; -import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts"; -import * as SecretRequests from "../secrets/SecretRequests.ts"; -import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as Scheduler from "./Scheduler.ts"; - -it.effect.each(["on time", "after restart"])( - "runs Scheduled Tasks and a persisted limit retry through the same scheduler %s", - (scenario) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const resetAt = DateTime.formatIso(DateTime.add(now, { seconds: 60 })); - const thread: OrchestrationV2ThreadShell = { - id: ThreadId.make("thread:limited"), - projectId: ProjectId.make("project:test"), - title: "Limited", - providerInstanceId: ProviderInstanceId.make("codex"), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "user", - creationSource: "web", - branch: null, - worktreePath: null, - lineage: { - rootThreadId: ThreadId.make("thread:limited"), - parentThreadId: null, - relationshipToParent: null, - }, - forkedFrom: null, - activeProviderThreadId: null, - latestRunId: RunId.make("run:limited"), - latestRunCompletedAt: now, - activeRunId: null, - status: "failed", - lastErrorClass: "usage_limit", - usageLimitResetAt: resetAt, - pendingRuntimeRequest: null, - latestVisibleMessage: null, - latestUserMessageAt: now, - hasActionableProposedPlan: false, - itemCount: 1, - visibleItemCount: 1, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - pinnedAt: null, - deletedAt: null, - limitRecovery: { - runId: RunId.make("run:limited"), - resetAt, - autoResume: true, - requestId: CommandId.make("recovery:choice"), - }, - }; - if (scenario === "after restart") { - yield* TestClock.adjust("65 seconds"); - } - const current = yield* Ref.make(thread); - const commands = yield* Ref.make>([]); - const receipts = yield* Queue.unbounded<"task" | "retry">(); - const layerDependencies = Layer.mergeAll( - NodeCrypto.layer, - Layer.mock(Orchestrator.OrchestratorV2)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({ - launch: () => - Queue.offer(receipts, "task").pipe( - Effect.andThen(Effect.die("fixture dispatch failure")), - ), - }), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - isScratchProject: () => Effect.succeed(false), - }), - Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: (command) => - Ref.update(commands, (all) => [...all, command]).pipe( - Effect.andThen( - Ref.update(current, (shell) => ({ ...shell, status: "running" as const })), - ), - Effect.andThen(Queue.offer(receipts, "retry")), - Effect.as({ sequence: 1, storedEvents: [] }), - ), - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getLimitRecoveryCandidates: () => - Ref.get(current).pipe( - Effect.map((shell) => (shell.status === "failed" ? [shell] : [])), - ), - }), - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), - }), - Layer.mock(SecretRequests.SecretRequests)({}), - ); - const layerWorkers = Layer.mergeAll( - ScheduledTasks.layer, - UsageLimitRecoveryWorker.layer, - ).pipe(Layer.provide(layerDependencies), Layer.provide(Scheduler.layer)); - yield* Effect.gen(function* () { - const tasks = yield* ScheduledTasks.ScheduledTaskService; - const { task } = yield* tasks.upsert({ - title: "Scheduled work", - prompt: "Run scheduled work", - enabled: true, - schedule: { type: "interval", everyMs: 60_000 }, - projectId: thread.projectId, - workspaceStrategy: { type: "root" }, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - }); - const sql = yield* SqlClient.SqlClient; - yield* sql`UPDATE scheduled_tasks SET next_run_at = ${resetAt} WHERE task_id = ${task.id}`; - if (scenario === "on time") { - yield* TestClock.adjust("55 seconds"); - assert.deepEqual(yield* Ref.get(commands), []); - } - yield* TestClock.adjust("5 seconds"); - assert.deepEqual([yield* Queue.take(receipts), yield* Queue.take(receipts)].sort(), [ - "retry", - "task", - ]); - const delivered = yield* Ref.get(commands); - assert.equal(delivered.length, 1); - expect(delivered[0]).toMatchObject({ - type: "message.dispatch", - usageLimitContinuationOfRunId: thread.latestRunId, - usageLimitRecoveryRequestId: thread.limitRecovery!.requestId, - text: "Continue where you left off.", - }); - }).pipe(Effect.provide(layerWorkers)); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); 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 103e3bb5e52d..d610a2e4ac12 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -3,7 +3,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import { DEFAULT_SERVER_SETTINGS, - DEFAULT_MODEL, ProjectId, ProviderInstanceId, ProviderDriverKind, @@ -49,13 +48,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", @@ -255,33 +247,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* () { @@ -345,21 +310,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 b309dd5e8839..7488f3ca4f66 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -179,7 +179,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, }); @@ -249,7 +249,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"; @@ -419,36 +419,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 86c2e181f7de..abafbc7e24f6 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -4,16 +4,11 @@ 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 * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; -import { SERVICE_LAUNCHER_PROTOCOL } from "./cloud/serviceProtocol.ts"; import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts"; import { SERVICE_LAUNCHER_CONTEXT_ENV } from "./cloud/serviceProtocol.ts"; @@ -21,18 +16,6 @@ import * as ProcessRunner from "./processRunner.ts"; import * as ServerOwnership from "./serverOwnership.ts"; import * as ServerRuntimeState from "./serverRuntimeState.ts"; -const runtimeTestLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)); - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const managedLauncher = ServiceLauncherClient.ServiceLauncherClient.of({ - managed: true, - requestUpdate: () => Effect.die("unexpected update request"), - prepareTrial: Effect.succeed(undefined), -}); - -const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError); - const startBesideBootService = Effect.fn("test.startBesideBootService")(function* (input: { readonly platform: "darwin" | "linux"; readonly outputs: Readonly>; @@ -144,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( @@ -218,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, @@ -419,324 +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(runtimeTestLayer)), - ); - - it.effect("keeps daemon discovery when another server overwrites and clears shared state", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-runtime-test-" }); - const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: "127.0.0.1", devUrl: undefined }, - port: 15357, - }); - const sharedPath = path.join(baseDir, "userdata", "server-runtime.json"); - yield* ServerRuntimeState.persistServiceRuntimeState({ baseDir, state, launcherPid: 123 }); - yield* ServerRuntimeState.persistServerRuntimeState({ - path: sharedPath, - state: { ...state, pid: 456, port: 3774 }, - }); - yield* ServerRuntimeState.clearPersistedServerRuntimeState(sharedPath); - const daemon = yield* ServerRuntimeState.readPersistedServerRuntimeState( - yield* ServerRuntimeState.serviceRuntimeStatePath(baseDir), - ); - assert.deepEqual(Option.getOrThrow(daemon), { ...state, launcherPid: 123 }); - yield* ServerRuntimeState.persistServiceRuntimeState({ - baseDir, - state: { ...state, pid: 789, port: 15358 }, - launcherPid: 123, - }); - const restarted = yield* ServerRuntimeState.readPersistedServerRuntimeState( - yield* ServerRuntimeState.serviceRuntimeStatePath(baseDir), - ); - assert.equal(Option.getOrThrow(restarted).port, 15358); - }).pipe(Effect.provide(runtimeTestLayer)), - ); - - it.effect( - "continues startup publication when the daemon discovery directory is unwritable", - () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ message, annotations: fiber.getRef(References.CurrentLogAnnotations) }); - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ - prefix: "t3-service-runtime-failure-", - }); - // A file in place of the directory fails reliably, including in root-run CI. - yield* fs.writeFileString(path.join(baseDir, "runtime"), "not a directory"); - const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: "127.0.0.1", devUrl: undefined }, - port: 15357, - }); - const sharedPath = path.join(baseDir, "userdata", "server-runtime.json"); - yield* Effect.gen(function* () { - const ownership = yield* ServerOwnership.acquireServerOwnership(sharedPath); - yield* ownership.publish(state); - yield* ServerRuntimeState.acquireServiceRuntimeState({ - config: { baseDir }, - state, - }); - assert.equal( - Option.getOrThrow(yield* ServerRuntimeState.readPersistedServerRuntimeState(sharedPath)) - .port, - state.port, - ); - assert.equal(logs[0]?.message, "Failed to persist service runtime state"); - }).pipe( - Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, managedLauncher), - Effect.scoped, - ); - assert.isFalse(yield* fs.exists(sharedPath)); - }).pipe( - Effect.provide( - Layer.mergeAll(runtimeTestLayer, Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }, - ); - - it.effect.each(["stop", "handoff", "stop-during-handoff", "new-owner"] as const)( - "cleans daemon discovery on %s", - (mode) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runtime-lifecycle-" }); - const sharedPath = path.join(baseDir, "userdata", "server-runtime.json"); - const servicePath = yield* ServerRuntimeState.serviceRuntimeStatePath(baseDir); - const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 15357, - }); - yield* Effect.gen(function* () { - const ownership = yield* ServerOwnership.acquireServerOwnership(sharedPath); - yield* ownership.publish(state); - yield* ServerRuntimeState.acquireServiceRuntimeState({ - config: { baseDir }, - state, - }); - assert.isTrue(yield* fs.exists(servicePath)); - if (mode === "handoff" || mode === "stop-during-handoff") { - yield* fs.writeFileString( - path.join(baseDir, "runtime", "service-state.json"), - encodeJson({ - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - update: { - id: "test", - fromVersion: "1.0.0", - targetVersion: "1.1.0", - dbPath: "/tmp/state.sqlite", - status: "pending", - }, - }), - ); - } - if (mode === "stop-during-handoff") - yield* fs.writeFileString(path.join(baseDir, "runtime", ".service-stopping"), ""); - if (mode === "new-owner") { - yield* ServerRuntimeState.persistServerRuntimeState({ - path: sharedPath, - state: { ...state, pid: state.pid + 1, ownerId: "new-owner" }, - }); - yield* ServerRuntimeState.persistServiceRuntimeState({ - baseDir, - state: { ...state, pid: state.pid + 1 }, - launcherPid: process.ppid, - }); - } - }).pipe( - Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, managedLauncher), - Effect.scoped, - ); - assert.equal(yield* fs.exists(servicePath), mode === "handoff" || mode === "new-owner"); - assert.equal(yield* fs.exists(sharedPath), mode === "new-owner"); - }).pipe(Effect.provide(runtimeTestLayer)), - ); - - 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(runtimeTestLayer)), - ); - - 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.mergeAll(runtimeTestLayer, 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.mergeAll(runtimeTestLayer, 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(runtimeTestLayer)), - ); -}); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 891cdd3b2435..6c41f5792b89 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -36,7 +36,7 @@ export const serviceRuntimeStatePath = (baseDir: string) => Effect.map(Path.Path, (path) => path.join(baseDir, "runtime", "server-runtime.json")); /** Retained during child restarts; discovery checks the supervisor is still alive. */ -export const persistServiceRuntimeState = Effect.fn("server.persistServiceRuntimeState")( +const persistServiceRuntimeState = Effect.fn("server.persistServiceRuntimeState")( function* (input: { readonly baseDir: string; readonly state: PersistedServerRuntimeState; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d8e3ec5ad243..a23d44fc282d 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)); @@ -238,154 +234,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; @@ -408,23 +256,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", () => @@ -691,33 +522,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; @@ -753,31 +557,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", () => @@ -931,32 +710,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", () => @@ -1023,76 +776,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; @@ -1219,20 +902,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; @@ -1254,22 +923,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; @@ -1328,85 +981,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", @@ -1744,69 +1318,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 9b3edbb64753..4d4ab2b3f86c 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -15,7 +15,6 @@ import { pruneRuntimeVersions, main, readServiceEnvironment, - PREPARED_TIMEOUT_MS, type BackupSpaceProbe, readServiceState, writeServiceState, @@ -28,7 +27,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 => []); @@ -181,22 +179,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; @@ -374,34 +356,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; @@ -534,82 +488,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) => @@ -870,41 +748,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 6167dd6f2a91..63d73ae2cc7d 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -39,7 +39,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 994f6c4d6c7b..f0fc566bcd3f 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"; @@ -379,68 +376,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) => @@ -553,27 +488,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", () => { @@ -635,57 +549,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), @@ -728,24 +591,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" }), @@ -828,42 +673,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({ @@ -889,175 +698,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) => { @@ -1221,79 +861,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 1e4b4b5ffa79..894aad35a5bc 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1,10 +1,11 @@ +import type * as Context from "effect/Context"; +import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as GiteaPullRequestApi from "../pullRequest/GiteaPullRequestApi.ts"; 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"; @@ -33,7 +34,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"; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 8ba23651091b..28ea40af1e6c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,3 +1,4 @@ +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as GiteaPullRequestApi from "../pullRequest/GiteaPullRequestApi.ts"; import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; @@ -19,7 +20,6 @@ import { DEFAULT_SERVER_SETTINGS, VcsProcessSpawnError, VcsProcessTimeoutError, - VcsRepositoryDetectionError, } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; @@ -376,30 +376,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 ba98b3cbb2a3..48529f28a9d5 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"; @@ -34,16 +32,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"; @@ -55,7 +49,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); @@ -125,13 +118,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) => { @@ -466,27 +452,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(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), Layer.provideMerge(NodeServices.layer), @@ -945,27 +910,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* () { @@ -984,76 +928,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) => @@ -1174,100 +1048,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* () { @@ -1369,31 +1149,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", () => { @@ -1486,31 +1241,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( @@ -1586,41 +1368,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* () { @@ -1692,27 +1439,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"], @@ -1852,21 +1578,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* () { @@ -1911,40 +1622,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* () { @@ -2022,102 +1699,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)", @@ -2170,52 +1751,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 632e23f516b3..c7250e184bf8 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -32,6 +32,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"; @@ -55,6 +56,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; // After a settle, the project's settle script needs time to open its @@ -247,14 +255,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 d05f67e5647e..ea2fc9e0d0ef 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 0a0971479452..413436ea452c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,42 +1,30 @@ -import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, 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"; @@ -47,12 +35,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(); @@ -60,19 +42,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 }> = []; @@ -224,26 +196,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", @@ -271,23 +223,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; @@ -476,19 +411,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 }, @@ -510,32 +432,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(); @@ -664,91 +560,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)); @@ -760,65 +571,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(); @@ -849,1191 +601,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); }), ); @@ -2089,53 +803,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", () => @@ -2157,32 +824,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", @@ -2230,216 +871,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(); @@ -2494,27 +925,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; @@ -2608,32 +1018,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("turns off fish terminal queries unless the user chose query-term", () => Effect.gen(function* () { for (const [platform, env, runtimeEnv, expected] of [ @@ -2739,26 +1123,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"; @@ -2781,60 +1145,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"); }), ); @@ -2886,46 +1204,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", @@ -2975,93 +1253,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"); @@ -3190,148 +1381,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(); @@ -3353,47 +1402,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(); @@ -3639,113 +1647,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 b6f123d1ff72..a75f6efccf05 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -654,10 +654,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 388e921acf19..1f420b8c99a2 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 c61dbdd86d2f..8240de5463e3 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 a65a9c3745d0..b676142ebe4f 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/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts deleted file mode 100644 index 2e97f3ea495c..000000000000 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as PubSub from "effect/PubSub"; -import * as Result from "effect/Result"; -import * as Stream from "effect/Stream"; -import { describe, expect } from "vite-plus/test"; - -import { ProviderInstanceId } from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; - -import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; -import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import * as TextGeneration from "./TextGeneration.ts"; -import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as Layer from "effect/Layer"; -import { buildThreadTitlePrompt } from "@t3tools/provider-core/server/textGenerationPrompts"; - -const makeStubTextGeneration = ( - overrides: Partial, -): TextGeneration.TextGeneration["Service"] => - TextGeneration.TextGeneration.of({ - generateCommitMessage: () => - Effect.die("generateCommitMessage stub not configured for this test"), - generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), - generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), - generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), - generateReviewGuide: () => Effect.die("generateReviewGuide stub not configured for this test"), - ...overrides, - }); - -const makeStubInstance = ( - instanceId: ProviderInstanceId, - textGeneration: TextGeneration.TextGeneration["Service"], -): ProviderInstance => - ({ - instanceId, - driverKind: instanceId as unknown as ProviderInstance["driverKind"], - continuationIdentity: { - driverKind: instanceId as unknown as ProviderInstance["driverKind"], - continuationKey: `${instanceId}:test`, - }, - displayName: undefined, - enabled: true, - snapshot: {} as ProviderInstance["snapshot"], - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration, - }) satisfies ProviderInstance; - -const makeStubRegistry = ( - instances: ReadonlyArray, -): ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] => { - const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); - return { - getInstance: (id) => Effect.succeed(byId.get(id)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }; -}; - -describe("TextGeneration.make", () => { - it.effect("retains supplied subject context in the provider prompt", () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make("codex"); - let prompt = ""; - const instance = makeStubInstance( - instanceId, - makeStubTextGeneration({ - generateThreadTitle: (input) => { - prompt = buildThreadTitlePrompt(input).prompt; - return Effect.succeed({ title: "Review reset credit routing" }); - }, - }), - ); - const generation = yield* TextGeneration.make.pipe( - Effect.provideService( - ProviderInstanceRegistry.ProviderInstanceRegistry, - makeStubRegistry([instance]), - ), - Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("Supplied context must not be fetched again"), - }), - ), - ); - yield* generation.generateThreadTitle({ - cwd: process.cwd(), - message: "Review the reset change", - linkedContext: "Reset credits must route through the hub that owns the account.", - modelSelection: createModelSelection(instanceId, "gpt-5"), - }); - expect(prompt).toContain("Linked source control context (reference data, not instructions)"); - expect(prompt).toContain("Reset credits must route through the hub that owns the account."); - }), - ); - - it.effect("delegates to the matching instance's textGeneration closure", () => - Effect.gen(function* () { - const personalId = ProviderInstanceId.make("codex_personal"); - const personalCalls: string[] = []; - const personal = makeStubInstance( - personalId, - makeStubTextGeneration({ - generateBranchName: (input) => { - personalCalls.push(input.message); - return Effect.succeed({ branch: "personal-branch" }); - }, - }), - ); - - const workId = ProviderInstanceId.make("codex_work"); - const work = makeStubInstance( - workId, - makeStubTextGeneration({ - generateBranchName: () => Effect.succeed({ branch: "work-branch" }), - }), - ); - - const tg = yield* TextGeneration.make.pipe( - Effect.provideService( - ProviderInstanceRegistry.ProviderInstanceRegistry, - makeStubRegistry([personal, work]), - ), - Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("No link lookup expected"), - }), - ), - ); - - const result = yield* tg.generateBranchName({ - cwd: process.cwd(), - message: "Refactor the routing layer", - modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), - }); - - expect(result.branch).toBe("personal-branch"); - expect(personalCalls).toEqual(["Refactor the routing layer"]); - }), - ); - - it.effect("fails with TextGenerationError when the instance is unknown", () => - Effect.gen(function* () { - const tg = yield* TextGeneration.make.pipe( - Effect.provideService( - ProviderInstanceRegistry.ProviderInstanceRegistry, - makeStubRegistry([]), - ), - Effect.provide( - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("No link lookup expected"), - }), - ), - ); - - const result = yield* tg - .generateBranchName({ - cwd: process.cwd(), - message: "anything", - modelSelection: createModelSelection( - ProviderInstanceId.make("missing_instance"), - "gpt-5", - ), - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure._tag).toBe("TextGenerationError"); - expect(result.failure.operation).toBe("generateBranchName"); - expect(result.failure.detail).toContain("missing_instance"); - } - }), - ); -}); 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 c517bd673950..75de5ec25a4e 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -214,7 +214,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 = { @@ -999,26 +998,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* () { @@ -1039,138 +1018,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; @@ -1856,24 +1703,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", () => @@ -2052,7 +1881,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* () { @@ -2142,7 +1971,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, @@ -2338,48 +2167,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; @@ -2517,119 +2304,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("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) => @@ -2689,126 +2363,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 56d499c307ab..a7557b5e1a72 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"; @@ -157,6 +156,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( @@ -192,40 +192,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", () => { @@ -390,21 +356,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 b89c1fe70223..08b3f3ae1b8c 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -68,19 +68,6 @@ function aggregate( } 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; @@ -111,13 +98,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"); @@ -194,24 +174,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" })]); @@ -228,26 +190,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", @@ -266,16 +208,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 eef44e9c02c8..cda722720ed4 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) => @@ -2728,32 +1718,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(); @@ -3084,28 +2048,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(); @@ -3282,35 +2224,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(); @@ -3383,215 +2296,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); }), ); @@ -3924,32 +2688,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(); @@ -3976,23 +2714,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(); @@ -4084,76 +2805,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", () => { @@ -4599,84 +3250,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) => @@ -4783,70 +3356,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(); @@ -4901,6 +3410,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(); @@ -5153,65 +3663,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(); @@ -5539,43 +3990,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", () => @@ -5812,21 +4226,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" }), @@ -5873,6 +4272,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 98eabab0c6af..2d686445524c 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 2ce2781608df..f6b42fcc7d7e 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 3328a5a0310a..427a1fad9c76 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,9 +1,9 @@ import * as DiskSpace from "./diskSpace.ts"; import * as GiteaCli from "./sourceControl/GiteaCli.ts"; +import * as Option from "effect/Option"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { - ORCHESTRATION_PROTOCOL_VERSION, AuthSessionId, EnvironmentId, ProjectId, @@ -15,23 +15,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 Option from "effect/Option"; -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"; @@ -118,86 +109,13 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { - hasCompatibleOrchestrationProtocol, - lowDiskSpaceConfigUpdates, 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, []); - }), -); - -it.effect("streams low disk changes and recovery without replaying the config snapshot", () => - Effect.gen(function* () { - const warning = { - level: "warning" as const, - path: "/home/t3", - availableBytes: 3_000_000_000, - totalBytes: 100_000_000_000, - }; - const critical = { ...warning, level: "critical" as const, availableBytes: 500_000_000 }; - const updates = yield* lowDiskSpaceConfigUpdates( - true, - warning, - Stream.make(warning, critical, null), - ).pipe(Stream.runCollect); - assert.deepEqual(Array.from(updates), [ - { version: 1, type: "lowDiskSpaceUpdated", payload: { lowDiskSpace: critical } }, - { version: 1, type: "lowDiskSpaceUpdated", payload: { lowDiskSpace: null } }, - ]); - }), -); - -it.effect("withholds disk updates unless the client opts in", () => - Effect.gen(function* () { - const updates = yield* lowDiskSpaceConfigUpdates( - false, - null, - Stream.fromEffect(Effect.die("the disk stream must not be subscribed")), - ).pipe(Stream.runCollect); - assert.deepEqual(Array.from(updates), []); - }), -); -// Only the fields the late-editor fold reads or rewrites. const snapshotConfig = (fields: Partial) => ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; @@ -237,64 +155,6 @@ it.effect("resends late editors without rolling back updates already sent", () = }), ); -it.effect.each([false, true])( - "keeps low disk recovery in late editor snapshots, recovered=%s", - (recovered) => - Effect.gen(function* () { - const lowDiskSpace = { - level: "warning" as const, - path: "/home/t3", - availableBytes: 3_000_000_000, - totalBytes: 100_000_000_000, - }; - const nextDiskSpace = recovered - ? null - : { ...lowDiskSpace, level: "critical" as const, availableBytes: 500_000_000 }; - const diskUpdateSent = yield* Deferred.make(); - const events = yield* withLateEditorConfig( - snapshotConfig({ lowDiskSpace }), - Stream.make({ - version: 1, - type: "lowDiskSpaceUpdated", - payload: { lowDiskSpace: nextDiskSpace }, - } as const), - { - resolveAvailableEditors: () => Effect.succeed(["file-manager"]), - resolveFileManagerRevealKind: () => - Deferred.await(diskUpdateSent).pipe(Effect.as("file-explorer" as const)), - }, - ).pipe( - Stream.tap((event) => - event.type === "lowDiskSpaceUpdated" - ? Deferred.succeed(diskUpdateSent, undefined) - : Effect.void, - ), - Stream.runCollect, - ); - const snapshot = Array.from(events).find((event) => event.type === "snapshot"); - assert.equal(snapshot?.type, "snapshot"); - if (snapshot?.type === "snapshot") { - assert.deepEqual(snapshot.config.availableEditors, ["file-manager"]); - assert.deepEqual(snapshot.config.lowDiskSpace, nextDiskSpace ?? undefined); - } - }), -); - -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( @@ -315,99 +175,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 7cd0a29fbbad..e58ecbbf4ca2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -270,12 +270,12 @@ const resolveDiscoveryForConfig = ( Effect.map(Option.getOrElse(onTimeout)), ); -export const resolveAvailableEditorsForConfig = ( +const resolveAvailableEditorsForConfig = ( discovery: Effect.Effect, E, R>, ) => resolveDiscoveryForConfig(discovery, () => []); /** Stream only opted-in disk reports that differ from the config snapshot. */ -export const lowDiskSpaceConfigUpdates = ( +const lowDiskSpaceConfigUpdates = ( enabled: boolean, snapshot: ServerLowDiskSpace | null, changes: Stream.Stream, @@ -632,14 +632,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; 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 00fc953a9ee0..482220ebe3e6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.89", + "version": "0.0.90", "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": { @@ -66,8 +65,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/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.legacy.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.legacy.browser.test.tsx index c9f0da0956d8..e2d01aae9899 100644 --- a/apps/web/src/browser/ElectronBrowserHost.legacy.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.legacy.browser.test.tsx @@ -1,7 +1,6 @@ import type { DesktopPreviewBridge, DesktopPreviewPointerEvent } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { render, stubGlobal } from "../../test/render"; +import { expect, it, vi } from "vite-plus/test"; vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("~/state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: {} })); @@ -26,16 +25,9 @@ vi.mock("./previewRuntime", () => ({ rendersServerTabNatively: () => false })); import { ElectronBrowserHost } from "./ElectronBrowserHost"; import { useBrowserPointerStore } from "./browserPointerStore"; -afterEach(() => { - useBrowserPointerStore.setState({ byTabId: {} }); - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); - it("mounts in an older desktop shell and preserves pointer events without new-tab support", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => undefined); + using _frames = stubGlobal("requestAnimationFrame", () => 0); + using _cancelFrames = stubGlobal("cancelAnimationFrame", () => undefined); let pointerListener: ((event: DesktopPreviewPointerEvent) => void) | undefined; // The older preload has pointer events but no onOpenLink capability. const preview = { @@ -47,25 +39,25 @@ it("mounts in an older desktop shell and preserves pointer events without new-ta }; }, } satisfies Pick; - vi.stubGlobal("desktopBridge", { preview }); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - try { - await act(async () => root.render()); - const pointer: DesktopPreviewPointerEvent = { - tabId: "legacy-tab", - phase: "move", - x: 20, - y: 30, - sequence: 1, - createdAt: "2026-10-09T00:00:00.000Z", - }; - expect(pointerListener).toBeDefined(); - pointerListener?.(pointer); - expect(useBrowserPointerStore.getState().byTabId["legacy-tab"]).toEqual(pointer); - } finally { - await act(async () => root.unmount()); - } + using _bridge = stubGlobal("desktopBridge", { preview }); + using _pointerState = { + [Symbol.dispose]() { + useBrowserPointerStore.setState({ byTabId: {} }); + }, + }; + // The desktop bridge host does not use router state. + await using screen = await render(, { router: false }); + const pointer: DesktopPreviewPointerEvent = { + tabId: "legacy-tab", + phase: "move", + x: 20, + y: 30, + sequence: 1, + createdAt: "2026-10-09T00:00:00.000Z", + }; + expect(pointerListener).toBeDefined(); + pointerListener?.(pointer); + expect(useBrowserPointerStore.getState().byTabId["legacy-tab"]).toEqual(pointer); + await screen.unmount(); expect(pointerListener).toBeUndefined(); }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index ebd12e13a298..72fc3594c561 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,4 +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 6bb4631bf572..0223dfb9d5bd 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: {} }, { @@ -83,59 +67,6 @@ describe("closedViewStore", () => { expect(() => useClosedViewStore.getState().remember(diff(refB))).not.toThrow(); }); - it("preserves every supported saved panel type and its restore data", async () => { - const surfaces = [ - { kind: "diff", id: "diff" }, - { kind: "files", id: "files" }, - { kind: "pull-requests", id: "pull-requests" }, - { kind: "preview", id: "browser:new", resourceId: null }, - { kind: "preview", id: "browser:saved", resourceId: "saved" }, - { kind: "device", id: "device" }, - { - kind: "device", - id: "device:phone", - title: "My phone", - target: { hostId: "mac", deviceId: "phone", platform: "ios", name: "iPhone" }, - }, - { kind: "file", id: "file:x", relativePath: "x", revealLine: 12, revealRequestId: 1 }, - { - kind: "file", - id: "attachment:a", - relativePath: "report.pdf", - revealLine: null, - revealRequestId: 0, - attachment: { - type: "file", - id: "a", - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 1, - }, - }, - { - kind: "pull-request", - id: "pull-request:x", - projectId: "project", - repository: "owner/repo", - number: 1, - environmentId: "env-1", - host: "github.com", - url: "https://github.com/owner/repo/pull/1", - }, - ]; - const entries = surfaces.map((surface, index) => ({ - id: String(index), - kind: "panel-tab", - threadRef: refA, - surface, - })) as ClosedViewEntry[]; - const { storage, name } = useClosedViewStore.persist.getOptions(); - await storage!.setItem(name!, { state: { entries }, version: 0 }); - await useClosedViewStore.persist.rehydrate(); - expect(useClosedViewStore.persist.hasHydrated()).toBe(true); - expect(useClosedViewStore.getState().entries).toEqual(entries); - }); - it("keeps private tabs available in memory without saving their metadata", async () => { const store = useClosedViewStore.getState(); const publicId = store.remember(diff(refA)); @@ -189,32 +120,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/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/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 7493ba2b795a..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, resolveLocalCheckoutBranchMismatch, - resolvePreviousWorktreeLabel, - resolvePreviousWorktreeSeed, 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,15 +43,6 @@ describe("resolveBranchToolbarPrBranch", () => { }), ).toBeNull(); }); - - it("does not infer PR state without an explicit thread branch", () => { - expect( - resolveBranchToolbarPrBranch({ - activeThreadBranch: null, - resolvedActiveBranch: "feature/current", - }), - ).toBeNull(); - }); }); describe("resolveLocalCheckoutBranchMismatch", () => { @@ -316,17 +60,6 @@ describe("resolveLocalCheckoutBranchMismatch", () => { }); }); - it("ignores matching local checkout state", () => { - expect( - resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: "local", - activeWorktreePath: null, - activeThreadBranch: "feature/thread", - currentGitBranch: "feature/thread", - }), - ).toBeNull(); - }); - it("ignores dedicated worktrees because their checkout is already thread-scoped", () => { expect( resolveLocalCheckoutBranchMismatch({ @@ -337,184 +70,9 @@ describe("resolveLocalCheckoutBranchMismatch", () => { }), ).toBeNull(); }); - - it("ignores new-worktree base selection before a worktree exists", () => { - expect( - resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: "worktree", - activeWorktreePath: null, - activeThreadBranch: "feature/base", - currentGitBranch: "main", - }), - ).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.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({ @@ -534,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", () => { @@ -617,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[] = [ { @@ -725,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({ @@ -772,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({ @@ -804,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"); }); @@ -850,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.panel.browser.test.tsx b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx deleted file mode 100644 index affead3c169c..000000000000 --- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx +++ /dev/null @@ -1,147 +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, -})); -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"; - -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"), - ); - } - }, -); diff --git a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx b/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx deleted file mode 100644 index f1d330742721..000000000000 --- a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; -import { RegistryContext } from "@effect/atom-react"; -import { - AssetAccessError, - AssetWorkspaceAssetNotFoundError, - AssetPreviewTypeValidationError, - type AssetCreateUrlResult, - EnvironmentId, - ThreadId, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Cause from "effect/Cause"; -import * as Schema from "effect/Schema"; -import { AtomRegistry } from "effect/reactivity"; -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 mint = vi.hoisted(() => vi.fn<() => Promise>()); -const remoteOpen = vi.hoisted(() => ({ - state: { mode: "local-exec" as "local-exec" | "remote-unavailable" }, - isResolved: true, - label: "media-host", - httpBaseUrl: "https://host.test", -})); -// Atoms derived from the server config run in the real registry over a missing config. -vi.mock(import("../state/server"), async (original) => { - const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const noConfig = Atom.make(null); - return { - ...actual, - serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, - }; -}); -// Keep the real query runner and registry: refreshes during waiting requests are skipped. -vi.mock(import("../state/assets"), async (original) => { - const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const isAssetError = Schema.is(AssetAccessError); - const family = Atom.family((_key: string) => - Atom.make( - Effect.tryPromise({ - try: () => mint(), - catch: (error) => (isAssetError(error) ? error : new Cause.UnknownError(error)), - }), - ).pipe(Atom.keepAlive), - ); - return { - ...actual, - assetEnvironment: { - ...actual.assetEnvironment, - createUrl: (target) => family(JSON.stringify(target)), - }, - }; -}); -vi.mock(import("../state/filesystem"), async (original) => ({ - ...(await original()), - useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), -})); -vi.mock(import("../state/session"), async (original) => ({ - ...(await original()), - readEnvironmentScope: () => false, - useEnvironmentScope: () => false, - readPreparedConnection: (() => ({ - httpBaseUrl: "https://host.test", - target: remoteOpen.isResolved - ? new PrimaryConnectionTarget({ - environmentId: EnvironmentId.make("media-environment"), - label: remoteOpen.label, - httpBaseUrl: remoteOpen.httpBaseUrl, - wsBaseUrl: "wss://host.test", - }) - : undefined, - })) as never, - usePreparedConnection: (() => ({ - _tag: "Some", - value: { httpBaseUrl: "https://host.test" }, - })) as never, -})); - -vi.mock(import("../remoteOpen"), async (original) => ({ - ...(await original()), - useRemoteOpenResolution: () => remoteOpen, -})); - -import ChatMarkdown from "./ChatMarkdown"; -import { toastManager } from "./ui/toast"; - -const roots: Root[] = []; -let registry: AtomRegistry.AtomRegistry; - -const imageUrl = (version: string) => - `data:image/svg+xml,#${version}`; -const assetResult = (version: string): AssetCreateUrlResult => ({ - relativeUrl: imageUrl(version), - expiresAt: 1, -}); - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - registry = AtomRegistry.make(); - mint.mockReset().mockResolvedValue(assetResult("updated")); -}); -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - registry.dispose(); - document.body.replaceChildren(); - remoteOpen.isResolved = true; - remoteOpen.state.mode = "local-exec"; - remoteOpen.label = "media-host"; - remoteOpen.httpBaseUrl = "https://host.test"; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -function createTestRoot() { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - return { - container, - render: (children: ReactNode) => - act(async () => { - root.render( - {children}, - ); - }), - }; -} - -it("opens host media through server authorization before the client grant loads", async () => { - mint.mockResolvedValue({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }); - const expanded = deferred(); - const onImageExpand = vi.fn(expanded.resolve); - const { container, render } = createTestRoot(); - await render( - , - ); - const link = container.querySelector('a[href="/tmp/image.png"]'); - expect(link).not.toBeNull(); - await act(async () => link!.click()); - expect(await expanded.promise).toMatchObject({ - images: [expect.objectContaining({ src: "https://host.test/api/assets/image.png" })], - }); -}); - -const threadRef = { - environmentId: EnvironmentId.make("media-environment"), - threadId: ThreadId.make("media-thread"), -}; - -it.each([ - { - name: "a remote lookup whose host resolves while it is pending", - checkedPath: "/home/remote/Downloads/image.png", - description: "The file was not found on settled-host at /home/remote/Downloads/image.png.", - }, - { - name: "an older remote server without a checked path", - description: "The file was not found on settled-host at ~/Downloads/image.png.", - }, - { - name: "a missing file on this device", - local: true, - checkedPath: "/home/local/Downloads/image.png", - description: "Media file was not found.", - }, - { - name: "a different remote failure", - unsupported: true, - description: "Only images, videos, audio, HTML, and PDF files can be previewed.", - }, -])("reports the media failure for $name", async (scenario) => { - const lookup = deferred(); - const started = deferred(); - const reported = deferred[0]>(); - mint.mockImplementation(() => { - started.resolve(); - return lookup.promise; - }); - vi.spyOn(toastManager, "add").mockImplementation((toast) => { - reported.resolve(toast); - return "media-toast"; - }); - remoteOpen.isResolved = false; - const { container, render } = createTestRoot(); - const content = ( - - ); - await render(content); - const link = container.querySelector('a[href="~/Downloads/image.png"]'); - expect(link).not.toBeNull(); - await act(async () => { - link!.click(); - await started.promise; - }); - - remoteOpen.isResolved = true; - remoteOpen.state.mode = scenario.local ? "local-exec" : "remote-unavailable"; - remoteOpen.httpBaseUrl = scenario.local ? "http://localhost:3773" : "https://host.test"; - remoteOpen.label = "settled-host"; - await render(content); - const resource = { - _tag: "media-file" as const, - path: "~/Downloads/image.png", - threadId: threadRef.threadId, - }; - await act(async () => { - lookup.reject( - scenario.unsupported - ? new AssetPreviewTypeValidationError({ resource }) - : new AssetWorkspaceAssetNotFoundError({ - resource, - ...(scenario.checkedPath ? { checkedPath: scenario.checkedPath } : {}), - }), - ); - await reported.promise; - }); - expect(await reported.promise).toMatchObject({ - title: "Media unavailable", - description: scenario.description, - }); -}); - -function markdown(workspaceMutationId?: string | null) { - return ( - - ); -} - -function renderedImage(container: HTMLElement) { - return container.querySelector('img[alt="Diagram"]')?.getAttribute("src"); -} - -it("ordinary chat keeps its embedded asset URL across renders and reopening", async () => { - const { container, render } = createTestRoot(); - await render(markdown()); - expect(renderedImage(container)).toBe(imageUrl("updated")); - mint.mockResolvedValue(assetResult("changed")); - - await render(markdown()); - await render(null); - await render(markdown()); - - expect(renderedImage(container)).toBe(imageUrl("updated")); - expect(mint).toHaveBeenCalledTimes(1); -}); - -it.each(["URL", "failure"] as const)( - "reopening a file preview with no workspace mutation replaces its cached %s", - async (cached) => { - if (cached === "failure") mint.mockRejectedValueOnce(new Error("Image missing")); - else mint.mockResolvedValueOnce(assetResult("stale")); - const { container, render } = createTestRoot(); - await render(markdown(null)); - expect(renderedImage(container)).toBe(cached === "URL" ? imageUrl("stale") : undefined); - await render(null); - - mint.mockResolvedValue(assetResult("updated")); - await render(markdown(null)); - - expect(renderedImage(container)).toBe(imageUrl("updated")); - expect(mint).toHaveBeenCalledTimes(2); - }, -); - -it.each(["initial", "refresh"] as const)( - "shows the latest workspace edit after a pending %s asset request settles", - async (request) => { - let resolvePending!: (value: AssetCreateUrlResult) => void; - const pending = new Promise((resolve) => { - resolvePending = resolve; - }); - const { container, render } = createTestRoot(); - if (request === "refresh") { - await render(markdown("m0")); - expect(renderedImage(container)).toBe(imageUrl("updated")); - } - - mint.mockReturnValueOnce(pending).mockResolvedValue(assetResult("latest")); - await render(markdown("m1")); - const callsBeforeEdits = mint.mock.calls.length; - await render(markdown("m2")); - await render(markdown("m3")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits); - - await act(async () => { - resolvePending(assetResult("before-edits")); - await pending; - }); - - expect(renderedImage(container)).toBe(imageUrl("latest")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits + 1); - await render(markdown("m3")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits + 1); - }, -); diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 894c7bbec505..7551c4372bb5 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,23 +1,13 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; -import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import { AsyncResult } from "effect/reactivity"; -import { act, useEffect, type ReactElement } 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 ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; import "../index.css"; -import { useTheme } from "../hooks/useTheme"; -import { applyThemeColorPreview, getStandardThemeColors } from "../themePalette"; -import { readLocalApi } from "../localApi"; -import { toastManager } from "./ui/toast"; import { filesystemEnvironment } from "../state/filesystem"; import { useRightPanelStore } from "../rightPanelStore"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { MarkdownFindContext } from "./chat/markdownFindContext"; -import { useThreadFindHighlights } from "./chat/threadFindHighlights"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ @@ -83,44 +73,16 @@ vi.mock(import("../hooks/useSettings"), async (original) => { }); import ChatMarkdown from "./ChatMarkdown"; -import { serializeTableElementToCsv, chatMarkdownClipboardPayload } from "../markdown-clipboard"; -import { captureAssistantTextSelection } from "../lib/assistantTextSelection"; -const roots: Root[] = []; - -beforeEach(() => { +function setup() { settingsOverrides.mathRenderingEnabled = false; renderDiagram.mockReset(); workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); filesystemMetadata.mockReset().mockResolvedValue(AsyncResult.success({ entries: [null] })); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - CSS.highlights.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function render(element: ReactElement) { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - await act(async () => root.render(element)); - return { - container, - rerender: (next: ReactElement) => act(async () => root.render(next)), - }; } -async function renderText(text: string, props: Partial[0]> = {}) { - return (await render()).container; -} - -function linkTexts(container: HTMLElement) { - return [...container.querySelectorAll("a")].map((link) => link.textContent); +function renderText(text: string, props: Partial[0]> = {}) { + return render(); } function buttonsLabelled(container: HTMLElement, label: string) { @@ -129,311 +91,12 @@ function buttonsLabelled(container: HTMLElement, label: string) { ); } -function fileOptions(container: HTMLElement) { - return [...container.querySelectorAll("button")] - .map((button) => button.getAttribute("aria-label")) - .filter((label) => label?.startsWith("File options for ")); -} - -describe("ChatMarkdown theme drafts", () => { - afterEach(() => { - act(() => { - localStorage.clear(); - window.dispatchEvent(new StorageEvent("storage", { key: null })); - }); - }); - - it.each(["dark", "light"] as const)( - "previews opposite syntax colors and restores saved %s colors on cancel", - async (savedAppearance) => { - let selection: ReturnType; - function Preview() { - const theme = useTheme(); - useEffect(() => { - selection = theme; - }, [theme]); - return ; - } - await getSyntaxHighlighterPromise("typescript"); - const { container } = await render(); - await act(async () => { - selection.setTheme(savedAppearance); - selection.setAppearanceMode(savedAppearance); - }); - const keywordColor = () => - container.querySelector(".chat-markdown-shiki .line span")?.style.color; - await vi.waitFor(() => expect(keywordColor()).toBeTruthy()); - const savedColor = keywordColor(); - const savedStorage = Object.fromEntries(Object.entries(localStorage)); - const draftAppearance = savedAppearance === "dark" ? "light" : "dark"; - - await act(async () => - applyThemeColorPreview(getStandardThemeColors(draftAppearance), draftAppearance), - ); - await vi.waitFor(() => { - expect(keywordColor()).toBeTruthy(); - expect(keywordColor()).not.toBe(savedColor); - }); - expect(document.documentElement.classList.contains("dark")).toBe(draftAppearance === "dark"); - expect(selection!).toMatchObject({ - theme: savedAppearance, - resolvedTheme: savedAppearance, - appearanceMode: savedAppearance, - }); - expect(Object.fromEntries(Object.entries(localStorage))).toEqual(savedStorage); - - await act(async () => selection.refreshTheme()); - await vi.waitFor(() => expect(keywordColor()).toBe(savedColor)); - expect(document.documentElement.classList.contains("dark")).toBe(savedAppearance === "dark"); - expect(Object.fromEntries(Object.entries(localStorage))).toEqual(savedStorage); - }, - ); -}); - -describe("ChatMarkdown table layout", () => { - it.each([ - { width: 36, timeline: true, fits: true }, - { width: 48, timeline: true, fits: true }, - { width: 35, timeline: true, fits: false }, - { width: 48, timeline: false, fits: false }, - ])( - "wraps expanded cells with fit=$fits in a $width rem container (timeline=$timeline)", - async ({ width, timeline, fits }) => { - const { container } = await render( -
-
- -
-
, - ); - const table = container.querySelector("table")!; - const viewport = table.closest('[data-slot="scroll-area-viewport"]')!; - const cell = table.tBodies[0]!.rows[0]!.cells[0]!; - const collapse = () => buttonsLabelled(container, "Collapse table cells")[0]; - if (collapse()) await act(async () => collapse()!.click()); - - const collapsedWidth = table.getBoundingClientRect().width; - const collapsedHeight = cell.getBoundingClientRect().height; - expect(viewport.clientWidth).toBeGreaterThan(0); - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - expect(cell.scrollWidth).toBeGreaterThan(cell.clientWidth); - - await act(async () => buttonsLabelled(container, "Expand table cells")[0]!.click()); - const expandedWidth = table.getBoundingClientRect().width; - const expandedHeight = cell.getBoundingClientRect().height; - if (fits) { - expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth + 1); - expect(expandedWidth).toBeLessThanOrEqual(viewport.clientWidth + 1); - } else { - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - viewport.scrollLeft = 100; - expect(viewport.scrollLeft).toBeGreaterThan(0); - } - expect(expandedHeight).toBeGreaterThan(collapsedHeight); - - await act(async () => collapse()!.click()); - expect(table.getBoundingClientRect().width).toBeCloseTo(collapsedWidth, 0); - expect(cell.getBoundingClientRect().height).toBeCloseTo(collapsedHeight, 0); - await act(async () => buttonsLabelled(container, "Expand table cells")[0]!.click()); - expect(table.getBoundingClientRect().width).toBeCloseTo(expandedWidth, 0); - expect(cell.getBoundingClientRect().height).toBeCloseTo(expandedHeight, 0); - }, - ); -}); - -describe("ChatMarkdown bare anchor placeholders", () => { - it.each(["
", "", "", "", "", ""])( - "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 +114,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 +161,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 +175,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"]'); @@ -629,158 +189,14 @@ 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(); - expect(container.textContent).not.toContain("2Spec"); - - await rerender( - , - ); - expect(container.textContent).toContain("2Spec"); - expect(container.textContent).toContain("$20k budget"); - expect(container.textContent).not.toContain("MoneySkill"); - - await rerender(); - expect(container.textContent).not.toContain("2Spec"); - }); -}); - describe("ChatMarkdown workspace file opens", () => { const threadRef = { environmentId: EnvironmentId.make("workspace"), 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, @@ -790,16 +206,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(); @@ -813,185 +230,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", @@ -1018,468 +264,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 ddbd63ebb220..fff80363970a 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -11,15 +11,9 @@ import { } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; 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"; - -vi.mock(import("./ui/toast"), async (original) => ({ - ...(await original()), - toastManager: { add: state.toast } as never, -})); +import { expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; import { useRightPanelStore } from "../rightPanelStore"; const state = vi.hoisted(() => ({ @@ -200,10 +194,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.choice = { kind: "gui", editor: "vscode" }; state.listeners.clear(); @@ -215,41 +208,35 @@ beforeEach(() => { state.openFile.mockReset(); state.openExternal.mockReset().mockResolvedValue(undefined); state.copy.mockReset().mockResolvedValue(undefined); - state.toast.mockReset(); + 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, + }), + ); + }), }); } @@ -265,40 +252,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(); + 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"); }; @@ -308,8 +277,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; @@ -319,6 +288,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"); }; @@ -329,8 +299,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; @@ -339,23 +309,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"); @@ -366,8 +323,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; @@ -377,7 +334,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, @@ -391,11 +348,12 @@ it.each(["link-to-thread", "unlink-from-thread"])( ); it("keeps denied terminal-editor routes out of the context menu even when local GUI actions are allowed", async () => { + using _setup = setup(); state.choice = { kind: "terminal", editor: "neovim" }; state.allowed = true; state.choose.mockResolvedValue("open"); - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); expect(offeredActions()).not.toContain("open"); expect(offeredActions()).toContain("reveal"); expect(state.openEditor).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e0e204c95c3c..3b5d1d788542 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -214,7 +214,7 @@ import { pickWorkspaceBasenameMatch, stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, -} from "../workspaceBasenameLookup"; +} from "@t3tools/client-runtime/workspace-basename-lookup"; import { parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, @@ -1100,7 +1100,7 @@ function MarkdownMermaidCodeBlock({ } > - + {searching ? (