diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index d53722f57dd75..7a6a1aa122a70 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -3,22 +3,38 @@ description: Install Vite+ and the workspace dependencies from cache. runs: using: composite steps: - # setup-vp reuses an installed exact version, so restoring its home skips the - # installer download on every job. + # ~/.vite-plus holds vp, the Node.js runtime, and pnpm. A cache entry never + # updates, so the key names all three versions; otherwise a pnpm or Node.js + # bump makes every job download them from npm again. - name: Resolve Vite+ version id: vp shell: bash run: | echo "version=$(sed -nE 's/^ vite-plus: ([0-9][^ ]*)$/\1/p' pnpm-workspace.yaml)" >> "$GITHUB_OUTPUT" + echo "node=$(jq -r '.engines.node' package.json)" >> "$GITHUB_OUTPUT" + echo "pnpm=$(jq -r '.packageManager' package.json)" >> "$GITHUB_OUTPUT" - name: Cache Vite+ + id: vp_cache if: steps.vp.outputs.version != '' uses: actions/cache@v6 with: path: ~/.vite-plus - key: vite-plus-home-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }} + key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} + + # setup-vp always runs the installer, which fetches vp from npm even when + # this exact version is installed. A cache hit already has everything, so + # skip setup-vp and do its remaining work here. This keeps npm outages from + # failing jobs. + - name: Use cached Vite+ + if: steps.vp_cache.outputs.cache-hit == 'true' + shell: bash + run: | + echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" + "$HOME/.vite-plus/bin/vp" env use "${{ steps.vp.outputs.node }}" - name: Setup Vite+ + if: steps.vp_cache.outputs.cache-hit != 'true' uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 64d8c8d6c0bd6..f55d29c10dc6a 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/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 6a7fc4a6d5b5f..d7ebb1a0790cd 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -5,6 +5,8 @@ // node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint // node ci-changes.cjs changed paths unknown: run everything // +// scripts/check-changed.ts requires the same decisions so local runs match CI. +// // Every changed path maps to workspace packages, which then pull in their // dependents from package.json. A path this file does not recognize runs // everything, so a new root file or directory can only cost minutes. @@ -201,4 +203,6 @@ function main() { for (const [key, value] of Object.entries(outputs)) console.log(`${key}=${value}`); } -main(); +module.exports = { readWorkspace, decide }; + +if (require.main === module) main(); diff --git a/.github/scripts/ci-changes.d.cts b/.github/scripts/ci-changes.d.cts new file mode 100644 index 0000000000000..afe6f61339362 --- /dev/null +++ b/.github/scripts/ci-changes.d.cts @@ -0,0 +1,27 @@ +interface WorkspacePackage { + readonly name: string; + readonly dir: string; + readonly hasTest: boolean; + readonly deps: ReadonlyArray; +} + +export function readWorkspace(): Array; + +export function decide( + files: ReadonlyArray | null, + packages: ReadonlyArray, + full: boolean, +): { + affected: Array; + outputs: { + typecheck: boolean; + build: boolean; + test: boolean; + test_packages: string; + test_web: boolean; + test_server: boolean; + rust: boolean; + release_smoke: boolean; + mobile_native: boolean; + }; +}; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index fa149f6d11741..753c3eb759072 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/scripts/vercel-deployment-output.test.cjs b/.github/scripts/vercel-deployment-output.test.cjs new file mode 100644 index 0000000000000..a249653350f22 --- /dev/null +++ b/.github/scripts/vercel-deployment-output.test.cjs @@ -0,0 +1,87 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, readFileSync, rmSync, existsSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { test } = require("node:test"); + +const release = readFileSync(join(__dirname, "../workflows/release.yml"), "utf8"); +const preview = readFileSync(join(__dirname, "../workflows/web-preview.yml"), "utf8"); +const stepPattern = + / name: (?:Build staged deployment|Deploy preview)\n shell: bash\n run: \|\n((?: .*\n|\n)+)/g; +const steps = [ + ...Array.from(release.matchAll(stepPattern), (match, index) => [ + index === 0 ? "hosted web" : "marketing", + match[1], + ]), + ...Array.from(preview.matchAll(stepPattern), (match) => ["web preview", match[1]]), +]; +assert.equal(steps.length, 3, "Expected both release steps and the preview step"); + +const url = "https://t3code-fixture-pinglabs.vercel.app"; +const warning = 'Error while parsing config file: "/runner/t3code/pnpm-lock.yaml"'; + +function runStep(script, stdout, exitCode = 0) { + const directory = mkdtempSync(join(tmpdir(), "t3-vercel-output-")); + const outputFile = join(directory, "output"); + try { + const result = spawnSync( + "bash", + [ + "-c", + 'vp() { printf "%s\\n" "$FIXTURE_STDOUT"; return "$FIXTURE_EXIT"; }\n' + + 'curl() { printf \'{"id":"fixture-project"}\\n\'; }\n' + + script.replace(/^ /gm, "").replace(/\$\{\{.*?\}\}/g, "fixture"), + ], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + VERCEL_TOKEN: "fixture-token", + VERCEL_ORG_ID: "fixture-org", + VERCEL_PROJECT_ID: "fixture-project", + GITHUB_OUTPUT: outputFile, + FIXTURE_STDOUT: stdout, + FIXTURE_EXIT: String(exitCode), + }, + }, + ); + assert.ifError(result.error); + return { + ...result, + output: existsSync(outputFile) ? readFileSync(outputFile, "utf8") : "", + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +for (const [name, script] of steps) { + for (const [label, stdout] of [ + ["plain URL", url], + ["lockfile warning", `${warning}\n${url}`], + ["trailing diagnostic", `${url}\nDone`], + ]) { + test(`${name}: saves one deployment URL after ${label}`, () => { + const result = runStep(script, stdout); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + result.output.split("\n").filter((line) => !line.startsWith("project_id=")), + [`deployment_url=${url}`, ""], + ); + }); + } + + for (const [label, stdout, exitCode] of [ + ["missing URL", warning, 0], + ["invalid URL", "https://example.invalid/deployment", 0], + ["multiple URLs", `${url}\nhttps://other.vercel.app`, 0], + ["failed deployment", url, 1], + ]) { + test(`${name}: rejects ${label} without saving output`, () => { + const result = runStep(script, stdout, exitCode); + assert.notEqual(result.status, 0); + assert.equal(result.output, ""); + }); + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b3339732610..6707b0151bb3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -132,6 +140,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 @@ -222,6 +238,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 @@ -229,7 +253,7 @@ jobs: run: python3 -B .github/scripts/stage-preview-bundle.test.py - name: Test release checks - run: node --test .github/scripts/check-nightly-release.test.cjs + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs - name: Finish installing browser secret helper build libraries timeout-minutes: 5 @@ -252,6 +276,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 19c82229e98fc..0000000000000 --- 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 08552d1cd53a1..516a0a7785a95 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,140 @@ 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 + + # Setup Vite+ selects the exact engines.node version from package.json. + # Native builds embed that executable; cross-target builds download the + # same version for their target. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # 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 +368,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 +433,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 +459,9 @@ jobs: --skip-build --verbose ) + if [[ -n "${{ inputs.verify_runner }}" ]]; then + args+=(--keep-stage) + fi has_all() { for value in "$@"; do @@ -488,107 +517,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' - 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 + # 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: - 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 +618,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 21e9e7ba99c56..6a80220c28a87 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 @@ -551,7 +596,7 @@ 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: macOS arm64 - runner: macos-26-xlarge + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: arm64 @@ -574,7 +619,7 @@ 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: macOS x64 - runner: macos-26-xlarge + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 @@ -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: @@ -1022,7 +1072,7 @@ jobs: fi echo "Building hosted web app for $channel_name channel." - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ @@ -1041,6 +1091,13 @@ jobs: --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" @@ -1169,7 +1226,7 @@ jobs: )" export VERCEL_PROJECT_ID - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ @@ -1178,6 +1235,13 @@ jobs: --token "$VERCEL_TOKEN" \ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" echo "project_id=$VERCEL_PROJECT_ID" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index f9cc3b063fcdb..3def8478bbdc2 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -77,13 +77,20 @@ jobs: vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - deployment_url="$( + deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ --scope "$vercel_scope" )" + printf '%s\n' "$deployment_output" + # Vercel can print lockfile warnings to stdout before the deployment URL. + deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" + if [[ "$deployment_url" == *$'\n'* ]]; then + echo "Vercel returned multiple deployment URLs." >&2 + exit 1 + fi echo "Deployed $deployment_url" echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 0000000000000..e79d0e71dc863 --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,30 @@ +// 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: { + // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside + // vite-plus-core's typescript peer range, so pnpm gave the vite-plus that @effect/vitest + // imports apps/marketing's typescript@6.0.3 instead, and tests loaded a second vitest. + readPackage(pkg) { + if (pkg.name === "@voidzero-dev/vite-plus-core" && pkg.peerDependencies?.typescript) { + pkg.peerDependencies.typescript = "*"; + } + return pkg; + }, + 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/.vercelignore b/.vercelignore new file mode 100644 index 0000000000000..e3576c995f40a --- /dev/null +++ b/.vercelignore @@ -0,0 +1,2 @@ +# Vercel's default .pnp* rule also excludes pnpm's hook, whose checksum is in the lockfile. +!/.pnpmfile.cjs diff --git a/AGENTS.md b/AGENTS.md index 928a06d070eb3..ec5085e479f85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,29 +78,49 @@ The most common defect in this repo is a change that works on the path you teste ## Dev servers - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. +- Setup runs in the background. While `.t3/setup-running` names a live PID, wait for it and do not run `vp i`. It ends with `.t3/setup-done` or `.t3/setup-failed` (with the error tail). With no marker at all, setup never ran here, so run `vp i` yourself. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. - Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. - To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. +- Need a second checkout? `git worktree add` it next to this worktree, not in `/tmp`. Remove it when done. ## Test data An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- Run `vp run migrate-dev-db` with your dev server stopped. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. ## Verifying -- Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- While you iterate, run `vp test run `. For big files, add `-t ""`. +- Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). +- 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. -- Web component tests are `*.browser.test.tsx` and render through `apps/web/test/render.tsx`. Acquire everything a test changes with `using`/`await using` so disposal undoes it even when the test fails; no `beforeEach`/`afterEach`, no module-level screens. Shared mock state that `vi.mock` factories read gets reset by a `setup()` call at the top of the test. `t3code/require-disposable-render` flags the old patterns. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- 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 () => { @@ -124,7 +144,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 @@ -157,7 +177,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - `packages/contracts` - Effect/Schema contracts plus small derived helpers. No heavy runtime logic. - `packages/shared` - shared runtime utils, subpath exports, no barrel. - `packages/client-runtime` - client code shared by web and mobile. -- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. +- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. If it is missing (sparse checkout), read a file with `git show origin/main:.repos/`. ## Taste diff --git a/apps/desktop/gnome-extension/captureFeedback.test.js b/apps/desktop/gnome-extension/captureFeedback.test.js index 501f2586f1bf3..fb6003bf39706 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 b8313f920f72a..fa45cd604a0d0 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 a6fe979100d64..c652ccabc768d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,13 +1,12 @@ { "name": "@t3tools/desktop", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" }, @@ -24,7 +23,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.4.5", + "electron": "44.9.3", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 780cf639fd18c..0fb4377f07be0 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 2b1dc2cb3fb28..fa6e56b5800eb 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 ae6cd05961b67..933a763dfa354 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 d2055e2c68177..8af6ae00c3f92 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 0bd63143e0ab6..043484d35cebf 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -148,7 +148,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 6e0c76842f244..2bf1e716a99a8 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 7a889c2e91d34..ab20c7610909d 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 1cd8485a1ec9b..32e7b3b3118d9 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 6d2179f0256ab..0000000000000 --- 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 58fff8297898b..1e572412d4693 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 7b44fab01855c..c9a79a81d3b55 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -151,22 +151,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); - it.effect("does not read a large binary another t3 links to", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const binary = path.join(home, "native-t3"); - yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); - yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); - yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); - const command = yield* commandIn({ home }); - expect((yield* command.state).installedPath).toBeNull(); - yield* command.uninstall; - expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); - }).pipe(Effect.scoped), - ); - it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -237,15 +221,4 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(registry.path).toBe(userPath); }).pipe(Effect.scoped), ); - - it.effect("offers nothing for a development build", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const command = yield* commandIn({ - home: yield* fs.makeTempDirectoryScoped(), - isPackaged: false, - }); - expect((yield* command.state).supported).toBe(false); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index 92f42dd2b247e..e043b4d16d8de 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 a8ac69b345358..0b446f8ada300 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 6f72aa1f509de..6d40bd128b77f 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 fb6c5a8b1afdb..200745f11136e 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 295f63eafe481..854f43c5623c1 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 ef462f19a29b0..f865ed312063a 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 299db7019dd7a..906ca1f8e4a27 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 542f6b9875943..e76c29eee1e2c 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 d27251d7b2f92..143a6e5fd87c7 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 41e497463a0d7..78bab4085627b 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 8c97b5fe83561..9e87cfb7a9c02 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 ec92473d80a38..8d70ab4c4791f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -5,12 +5,9 @@ import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -22,22 +19,6 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; -const PersistedServerObservabilitySettingsDocument = Schema.Struct({ - observability: Schema.Struct({ - otlpTracesUrl: Schema.String, - otlpMetricsUrl: Schema.String, - otlpLogsUrl: Schema.String, - }), -}); - -const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( - Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), -); - -const isDesktopBackendObservabilitySettingsReadError = Schema.is( - DesktopBackendConfiguration.DesktopBackendObservabilitySettingsReadError, -); - const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), @@ -333,44 +314,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("only the local primary bootstrap claims the prepared shell environment", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - const primary = yield* configuration.resolvePrimary; - const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); - - assert.equal(primary.bootstrap.shellEnvironmentPrepared, true); - assert.isUndefined(wsl.bootstrap.shellEnvironmentPrepared); - }), - ), - ); - - it.effect("keeps server hydration enabled when desktop preparation failed", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const primary = yield* configuration.resolvePrimary; - assert.equal(primary.bootstrap.shellEnvironmentPrepared, false); - }), - false, - ), - ); - - it.effect("resolveWsl reuses the primary's bootstrap token", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - const primary = yield* configuration.resolvePrimary; - const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); - - assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); - }), - ), - ); - it.effect("resolveWsl pins a default-tracking run to the concrete default distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -616,71 +559,6 @@ describe("DesktopBackendConfiguration", () => { ); }); - it.effect("resolveWsl preserves the staging error when the mounted fallback is fatal", () => - withPackagedWslHarness( - { - archiveHash: "b".repeat(64), - wsl: () => ({ - prepareRuntime: () => ({ - ok: false, - reason: - "t3: error while loading shared libraries: libatomic.so.1: cannot open shared object file", - }), - ensureNodePty: () => ({ - ok: false, - reason: "the packaged Linux node-pty binary was not included", - fatal: true, - }), - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "libatomic.so.1"); - }), - ), - ); - - it.effect("resolveWsl keeps the staged runtime when the mounted tree fails too", () => { - const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; - const invalidatedRuntimeIds: string[] = []; - return withPackagedWslHarness( - { - archiveHash: "d".repeat(64), - wsl: () => ({ - prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), - invalidateRuntime: (_distro, runtimeId) => - Effect.sync(() => { - invalidatedRuntimeIds.push(runtimeId); - }), - probeRuntime: () => ({ - ok: false, - reason: "unsupported CPU architecture or incompatible system libraries", - }), - ensureNodePty: () => ({ - ok: false, - reason: "mounted tree is broken in some other way", - fatal: true, - }), - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "unsupported CPU architecture"); - assert.deepEqual(invalidatedRuntimeIds, []); - }), - ); - }); - it.effect("resolveWsl keeps WSL retryable when the mounted fallback fails transiently", () => { const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; const invalidatedRuntimeIds: string[] = []; @@ -800,226 +678,6 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolvePrimary surfaces persisted backend observability endpoints", () => - withHarness( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { - recursive: true, - }); - yield* fileSystem.writeFileString( - environment.serverSettingsPath, - yield* encodePersistedServerObservabilitySettingsDocument({ - observability: { - otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", - otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", - otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", - }, - }), - ); - - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); - assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - }), - ), - ); - - it.effect("resolvePrimary omits backend observability endpoints when settings are missing", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - - assert.isUndefined(config.bootstrap.otlpTracesUrl); - assert.isUndefined(config.bootstrap.otlpMetricsUrl); - assert.isUndefined(config.bootstrap.otlpLogsUrl); - }), - ), - ); - - it.effect("resolveWsl carries environment-configured observability endpoints", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); - assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge( - layerEnvironment(baseDir, { - platform: "win32", - otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", - otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", - otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("environment observability endpoints win over the persisted settings file", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { - recursive: true, - }); - yield* fileSystem.writeFileString( - environment.serverSettingsPath, - yield* encodePersistedServerObservabilitySettingsDocument({ - observability: { - otlpTracesUrl: "http://persisted:4318/v1/traces", - otlpMetricsUrl: "http://persisted:4318/v1/metrics", - otlpLogsUrl: "http://persisted:4318/v1/logs", - }, - }), - ); - - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); - assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { otlpLogsUrl: "http://env:4318/v1/logs" }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("logs structured context when persisted observability settings cannot be read", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - const settingsPath = path.join(baseDir, "userdata", "settings.json"); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: settingsPath, - }); - const messages: Array = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - const layerFailingFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(cause), - }), - ); - - const config = yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - return yield* configuration.resolvePrimary; - }).pipe( - Effect.provide( - Layer.mergeAll( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge(layerEnvironment(baseDir)), - Layer.provideMerge(layerFailingFileSystem), - ), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - - assert.isUndefined(config.bootstrap.otlpTracesUrl); - assert.isUndefined(config.bootstrap.otlpMetricsUrl); - assert.isUndefined(config.bootstrap.otlpLogsUrl); - - const error = messages - .flatMap((message) => (Array.isArray(message) ? message : [message])) - .find(isDesktopBackendObservabilitySettingsReadError); - assert.isDefined(error); - assert.equal(error.settingsPath, settingsPath); - assert.equal(error.cause, cause); - assert.equal( - error.message, - `Failed to read persisted backend observability settings at ${settingsPath}.`, - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimary captures backend output in dev so child logs can be persisted", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - assert.equal(config.captureOutput, true); - assert.notInclude(config.args, "--require"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { - isPackaged: false, - devServerUrl: "http://127.0.0.1:5733", - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl carries the kill switch into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1068,71 +726,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect( - "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - const standard = { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret", - OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token", - T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", - }; - const previousWslEnv = process.env.WSLENV; - const ambientOtel = Object.entries(process.env).filter( - ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), - ); - try { - for (const [name] of ambientOtel) delete process.env[name]; - delete process.env.WSLENV; - Object.assign(process.env, standard); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal( - config.env.OTEL_EXPORTER_OTLP_ENDPOINT, - "https://collector.example.com:4318/base?api_key=secret", - ); - assert.equal( - config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, - "authorization=Bearer%20token", - ); - const wslEnv = (config.env.WSLENV ?? "").split(":"); - assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); - assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); - assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces"); - assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - } finally { - for (const name of Object.keys(standard)) delete process.env[name]; - restoreEnv("WSLENV", previousWslEnv); - for (const [name, value] of ambientOtel) restoreEnv(name, value); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1280,42 +873,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolveWsl keeps a transient distro-list failure retryable", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "timed out"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distroListError: new DesktopWslEnvironment.DesktopWslDistroListError({ - reason: "wsl.exe --list --verbose timed out", - }), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl marks a missing packaged server entry as fatal", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1349,113 +906,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "could not be extracted"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge( - DesktopWslServerTree.layerTest({ - result: { - ok: false, - reason: "WSL server files could not be extracted", - fatal: false, - }, - }), - ), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolveWsl marks a missing selected distro as a fatal preflight failure", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Removed-Distro" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "Removed-Distro"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimaryLabel reports the WSL distro when wsl-only and WSL is available", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "WSL (Ubuntu)"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: true, - wslDistro: "Ubuntu", - }), - ), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1505,93 +955,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("prefers the release resource monitor when both development builds exist", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - const dirname = path.join(baseDir, "apps/desktop/src"); - const releaseMonitorPath = path.join( - baseDir, - "native/resource-monitor/target/release/t3-resource-monitor", - ); - const debugMonitorPath = path.join( - baseDir, - "native/resource-monitor/target/debug/t3-resource-monitor", - ); - yield* fileSystem.makeDirectory(path.dirname(releaseMonitorPath), { recursive: true }); - yield* fileSystem.makeDirectory(path.dirname(debugMonitorPath), { recursive: true }); - yield* fileSystem.writeFileString(releaseMonitorPath, "release"); - yield* fileSystem.writeFileString(debugMonitorPath, "debug"); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.resourceMonitorPath, releaseMonitorPath); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { - dirname, - devServerUrl: "http://127.0.0.1:5733", - isPackaged: false, - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "Local environment"); - }), - ), - ); - - it.effect("resolvePrimaryLabel reports Windows when wsl-only but WSL is unavailable", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "Windows"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: true, - wslDistro: "Ubuntu", - }), - ), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This test intentionally replicates the sync IPC handler's runSync path to catch a regression to async-only resolution; it.effect would mask it. const runtime = ManagedRuntime.make( diff --git a/apps/desktop/src/backend/DesktopBackendManager.fork.test.ts b/apps/desktop/src/backend/DesktopBackendManager.fork.test.ts new file mode 100644 index 0000000000000..5ccd54933c4a8 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendManager.fork.test.ts @@ -0,0 +1,382 @@ +import { DesktopBackendBootstrap } from "@t3tools/contracts"; +import type { DesktopBackendBootstrap as DesktopBackendBootstrapValue } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess } from "effect/process"; + +import { assert, describe, it } from "@effect/vitest"; +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 PlatformError from "effect/PlatformError"; + +import * as Sink from "effect/Sink"; + +import * as Stream from "effect/Stream"; + +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; + +const decodeDesktopBackendBootstrap = Schema.decodeEffect( + Schema.fromJsonString(DesktopBackendBootstrap), +); + +const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + args: ["/server/bin.mjs", "--bootstrap-fd", "3"], + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + desktopTelemetryFd: 4, + desktopTelemetryControlFd: 5, + }, + bootstrapDelivery: "fd3", + extendEnv: true, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, + preflightFailure: Option.none(), +}; + +const configWithObservability: DesktopBackendBootstrapValue = { + ...baseConfig.bootstrap, + tailscaleServeEnabled: true, + desktopTelemetryFd: 4, + otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", +}; + +function makeProcess(options?: { + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; + readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; + readonly getOutputFd?: ChildProcessSpawner.ChildProcessHandle["getOutputFd"]; +}): ChildProcessSpawner.ChildProcessHandle { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: options?.stdout ?? Stream.empty, + stderr: options?.stderr ?? Stream.empty, + all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), + exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: options?.kill ?? (() => Effect.void), + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: options?.getOutputFd ?? (() => Stream.empty), + unref: Effect.succeed(Effect.void), + }); +} + +function responseForRequest( + request: HttpClientRequest.HttpClientRequest, + status: number, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb(request, new Response(null, { status })); +} + +function layerHttpClient( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +const layerHealthyHttpClient = layerHttpClient((request) => + Effect.succeed(responseForRequest(request, 200)), +); + +function decodeBootstrap(raw: string) { + return decodeDesktopBackendBootstrap(raw); +} + +interface MakeInstanceInput { + readonly spawnerLayer: Layer.Layer; + readonly httpClientLayer?: Layer.Layer; + readonly backendOutputLog?: Partial; + readonly onReady?: Effect.Effect; + readonly onShutdown?: Effect.Effect; + readonly onStateDirOwned?: Effect.Effect; + readonly onPreflightFailed?: ( + failure: DesktopBackendManager.PreflightFailure, + ) => Effect.Effect; + readonly config?: DesktopBackendManager.DesktopBackendStartConfig; + readonly configResolve?: Effect.Effect< + DesktopBackendManager.DesktopBackendStartConfig, + PlatformError.PlatformError + >; + readonly desktopTelemetryStream?: Stream.Stream; + readonly desktopTelemetryPublisher?: Partial< + DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] + >; + readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; +} + +function makeTestInstance(input: MakeInstanceInput) { + const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { + beginSession: () => Effect.void, + writeOutputChunk: () => Effect.void, + persistFailureSnapshot: () => Effect.void, + persistFailure: () => Effect.void, + discardSession: Effect.void, + ...input.backendOutputLog, + }; + const layerServices = Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + }), + input.spawnerLayer, + input.httpClientLayer ?? layerHealthyHttpClient, + Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { + forInstance: () => Effect.succeed(stubLog), + } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), + Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { + latest: Effect.succeedNone, + changes: Stream.empty, + encoded: input.desktopTelemetryStream ?? Stream.empty, + handleControlForSource: () => Effect.void, + removeControlSource: () => Effect.void, + publishUpdateReport: () => Effect.void, + updateRequests: Stream.empty, + updateCommits: Stream.empty, + updateCancellations: Stream.empty, + ...input.desktopTelemetryPublisher, + }), + DesktopBrowserHost.layer, + DesktopWslEnvironment.layerTest( + input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes }, + ), + ); + + const instance = DesktopBackendManager.makeBackendInstance({ + id: DesktopBackendManager.PRIMARY_INSTANCE_ID, + label: Effect.succeed("Windows"), + configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig), + ...(input.onReady ? { onReady: () => input.onReady! } : {}), + ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), + ...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}), + ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), + }); + + return instance.pipe(Effect.provide(layerServices)); +} + +describe("DesktopBackendManager", () => { + it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => + Effect.scoped( + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let telemetryJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } + const fd4 = command.options.additionalFds?.fd4; + if (fd4?.type === "input" && fd4.stream) { + telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); + } + } + + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer: layerSpawner, + desktopTelemetryStream: Stream.encodeText( + Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), + ), + onReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + }); + + yield* instance.start; + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand._tag !== "StandardCommand") { + throw new Error("Expected backend to spawn a standard command."); + } + + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); + assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 10_000, + ); + + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + assert.equal( + telemetryJson, + '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', + ); + }), + ), + ); + + it.effect("recognizes migration status across output chunks and keeps streams separate", () => + Effect.gen(function* () { + const statuses: boolean[] = []; + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.encodeText( + Stream.make( + "unrelated log\nRunning database ", + "migrations\nMigrations ran success", + "fully\nRunning database ", + ), + ), + stderr: Stream.encodeText(Stream.make("migrations\n")), + }), + ), + ), + ); + yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + onMigrationStatus: (running) => + Effect.sync(() => { + statuses.push(running); + }), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); + assert.deepEqual(statuses, [true, false]); + }), + ); + + it.effect("notifies shutdown when the backend exits before readiness", () => + Effect.scoped( + Effect.gen(function* () { + const stopped = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(makeProcess())), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + onShutdown: Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(stopped); + assert.isFalse((yield* instance.snapshot).ready); + }), + ), + ); + + it.effect("bounds backend teardown after the quit grace 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 quitFiber = yield* Effect.forEach( + [instance1, instance2], + (instance) => instance.stop({ timeout: DesktopBackendManager.QUIT_BACKEND_STOP_TIMEOUT }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + + const started = yield* Queue.takeN(teardownStarted, 2); + assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); + + // Quit must outlast the 10 s kill grace before giving up on a hung backend. + yield* TestClock.adjust(Duration.millis(10_500)); + assert.isUndefined(quitFiber.pollUnsafe()); + yield* TestClock.adjust(Duration.millis(1_500)); + 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.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e1395a8ea2e6b..9b6ceef93a37a 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,136 +295,6 @@ describe("DesktopBackendManager", () => { }), ); - it.effect("recognizes migration status across output chunks and keeps streams separate", () => - Effect.gen(function* () { - const statuses: boolean[] = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.encodeText( - Stream.make( - "unrelated log\nRunning database ", - "migrations\nMigrations ran success", - "fully\nRunning database ", - ), - ), - stderr: Stream.encodeText(Stream.make("migrations\n")), - }), - ), - ), - ); - yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - onMigrationStatus: (running) => - Effect.sync(() => { - statuses.push(running); - }), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); - assert.deepEqual(statuses, [true, false]); - }), - ); - - 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* () { @@ -663,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* () { @@ -1022,273 +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("notifies shutdown when the backend exits before readiness", () => - Effect.scoped( - Effect.gen(function* () { - const stopped = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.succeed(makeProcess())), - ), - httpClientLayer: layerHttpClient(() => Effect.never), - onShutdown: Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(stopped); - assert.isFalse((yield* instance.snapshot).ready); - }), - ), - ); - - 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); @@ -1300,364 +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"]); - - // Quit must outlast the 10 s kill grace before giving up on a hung backend. - yield* TestClock.adjust(Duration.millis(10_500)); - assert.isUndefined(quitFiber.pollUnsafe()); - yield* TestClock.adjust(Duration.millis(1_500)); - 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 bae9aad3cd20f..6eccd339bc973 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 0b090d96772f9..0621b930aded3 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -91,22 +91,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 900afce9b860a..348d3a7050f70 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 679d19a53d0a3..0000000000000 --- 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 75e1c8609a60e..f53cde3e451d7 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -66,13 +66,6 @@ function layerMockSpawner(statusJson: string | (() => string) = "{}", onSpawn?: ); } -function layerDieOnSpawn() { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), - ); -} - function layerEnvironmentFor(baseDir: string, env: Record = {}) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -198,20 +191,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("returns a typed error when network access is explicitly unavailable", () => - withHarness( - emptyNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); - assert.equal(error.port, 4173); - }), - ), - ); - it.effect("persists network-accessible mode and updates backend binding state", () => withHarness( lanNetworkInterfaces, @@ -259,22 +238,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("keeps a host bridge's LAN address ahead of later adapters", () => - withHarness( - { - vmbr0: [{ address: "192.168.1.30", family: "IPv4", internal: false }], - enx00e04c680001: [{ address: "192.168.42.10", family: "IPv4", internal: false }], - }, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "192.168.1.30"); - }), - ), - ); - it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => withHarness( emptyNetworkInterfaces, @@ -306,71 +269,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("preserves persistence request context and the settings failure chain", () => { - const diskFailure = new Error("disk exploded"); - const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ - operation: "replace-settings-file", - path: "/tmp/desktop-settings.json", - cause: diskFailure, - }); - const layerSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), - setServerExposureMode: () => Effect.fail(settingsFailure), - setTailscaleServe: () => Effect.fail(settingsFailure), - setUpdateChannel: () => Effect.die("unexpected update channel change"), - setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), - setWslDistro: () => Effect.die("unexpected WSL distro change"), - setWslOnly: () => Effect.die("unexpected WSL-only toggle"), - setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), - applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), - applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), - } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); - - return withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.instanceOf( - modeError, - DesktopServerExposure.DesktopServerExposureModePersistenceError, - ); - assert.equal(modeError.mode, "network-accessible"); - assert.strictEqual(modeError.cause, settingsFailure); - assert.strictEqual(modeError.cause.cause, diskFailure); - assert.equal( - modeError.message, - "Failed to persist desktop server exposure mode network-accessible.", - ); - assert.notInclude(modeError.message, diskFailure.message); - - const tailscaleError = yield* serverExposure - .setTailscaleServeEnabled({ enabled: true, port: 8443 }) - .pipe(Effect.flip); - assert.instanceOf( - tailscaleError, - DesktopServerExposure.DesktopTailscaleServePersistenceError, - ); - assert.equal(tailscaleError.enabled, true); - assert.equal(tailscaleError.port, 8443); - assert.strictEqual(tailscaleError.cause, settingsFailure); - assert.strictEqual(tailscaleError.cause.cause, diskFailure); - assert.equal( - tailscaleError.message, - "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", - ); - assert.notInclude(tailscaleError.message, diskFailure.message); - }), - {}, - undefined, - layerSettings, - ); - }); - it.effect("keeps a Tailscale Serve change made while a mode change is saving", () => Effect.gen(function* () { const modeWriteStarted = yield* Deferred.make(); @@ -522,24 +420,6 @@ describe("DesktopServerExposure", () => { }, ); - it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/"], - ); - }), - {}, - layerDieOnSpawn(), - ), - ); - it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, @@ -563,104 +443,4 @@ describe("DesktopServerExposure", () => { }, ), ); - - it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 3773 }); - yield* serverExposure.setMode("network-accessible"); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual(endpoints, [ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - description: "Loopback endpoint for this desktop app.", - }, - { - id: "desktop-lan:http://192.168.1.20:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://192.168.1.20:3773/", - wsBaseUrl: "ws://192.168.1.20:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }, - { - id: "manual:https://desktop.example.ts.net", - label: "Custom HTTPS", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "https://desktop.example.ts.net/", - wsBaseUrl: "wss://desktop.example.ts.net/", - reachability: "public", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured HTTPS endpoint for this desktop backend.", - }, - { - id: "manual:http://desktop.example.test:3773", - label: "Custom endpoint", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://desktop.example.test:3773/", - wsBaseUrl: "ws://desktop.example.test:3773/", - reachability: "public", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured endpoint for this desktop backend.", - }, - ]); - }), - { - T3CODE_DESKTOP_HTTPS_ENDPOINTS: - "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", - }, - ), - ); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index c3cdaaa317e6d..054655917cecc 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 ec3819ae98afb..044625a32b445 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 84f37b6f5f537..0000000000000 --- 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 2ed5a1f2f913a..0000000000000 --- 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 320988051e5f1..0000000000000 --- 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 4c9da1dd9259b..45d1771f26f07 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 9abe17d77512a..d6f476205f4c5 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 7e75d99981415..0e72a46146e0a 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 b4028930af661..0000000000000 --- 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 1e005d26fdf5d..0000000000000 --- 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 f272f84b5d825..0000000000000 --- 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 ef94312ed6f06..4f17ca7e33010 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 3ac4b408f3768..60975495976cf 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 47ecd7fd71f8a..a124697e15bf2 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 46193ca2c3417..ac15b145e0c6a 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 4c07e4671fb2d..0000000000000 --- 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 e90491f1f4e7d..0000000000000 --- 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 40d1df6a33662..7126b1cd31945 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 952745b4b7c6c..39eeeeeb1f308 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 32460966ab135..802f2f3fb18b6 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 00a58ecce4d5e..b659054540c23 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 138bcbc36d302..7e1ae602f979d 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 4b5e189c2089e..dff64081b70c6 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 0ed188ca61b59..1b40db955fe6e 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 3459719e2f549..b5fe707f4caee 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 644597eaf451d..af42c7264748d 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -17,7 +17,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts" import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; -import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; +import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); @@ -84,73 +84,6 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("WSL IPC", () => { - it.effect("does not probe WSL when local execution is disabled", () => - Effect.gen(function* () { - const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; - const state = yield* getWslState.handler(undefined).pipe( - Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { - ...wsl, - isAvailable: Effect.die("must not probe WSL"), - listDistros: Effect.die("must not enumerate distros"), - }), - Effect.flatMap(decodeWslState), - ); - assert.deepEqual(state, { - enabled: true, - distro: "Ubuntu", - available: false, - wslOnly: true, - distros: [], - preflightError: null, - }); - }).pipe( - Effect.provide( - Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - localEnvironmentEnabled: false, - wslBackendEnabled: true, - wslDistro: "Ubuntu", - wslOnly: true, - }), - DesktopWslEnvironment.layerTest(), - layerWslBackend(), - ), - ), - ), - ); - - it.effect("stages dual-backend preferences before enabling without relaunching", () => { - const relaunchReasons: Array = []; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: true, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend(), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - yield* invokeSetWslOnly(false); - yield* invokeSetWslDistro("Debian"); - const state = yield* invokeSetWslBackendEnabled(true); - - assert.deepEqual(state, { - enabled: true, - distro: "Debian", - available: true, - wslOnly: false, - distros: [], - preflightError: null, - }); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); - it.effect("stages WSL-only preferences and relaunches only after enabling", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( @@ -187,70 +120,6 @@ describe("WSL IPC", () => { }).pipe(Effect.provide(layer)); }); - it.effect("relaunches when enabling the WSL backend while wsl-only is already persisted", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: true, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(true); - - assert.deepEqual(state, { - enabled: true, - distro: null, - available: true, - wslOnly: true, - distros: [], - preflightError: null, - }); - assert.equal(reconcileCount, 0); - assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reconciles in dual-backend mode without relaunching", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: false, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(true); - - assert.equal(state.enabled, true); - assert.equal(state.wslOnly, false); - assert.equal(reconcileCount, 1); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); - it.effect("clears wsl-only before relaunching when disabling a WSL-only backend", () => { const relaunchReasons: Array = []; let reconcileCount = 0; @@ -289,33 +158,4 @@ describe("WSL IPC", () => { assert.deepEqual(relaunchReasons, ["wslBackendEnabled=false"]); }).pipe(Effect.provide(layer)); }); - - it.effect("clears dual-backend WSL without relaunching", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: false, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(false); - - assert.equal(state.enabled, false); - assert.equal(state.wslOnly, false); - assert.equal(reconcileCount, 1); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index 157175e533324..aaf0c11bcce92 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 e1160d9bbe381..0704f52254ef4 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 dfc378cfd1c46..4a8e38961f20a 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 3071b00c32645..d07b4f5f9a76d 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 86eeccc92ab60..1104c422f3eea 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 4124e548bec12..11fb9100e398f 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 22e384834789f..4815da11cba1a 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 eace39003e39d..91be0a43d1be8 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 890d9e2715865..0000000000000 --- 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 cdeb67e0e8cc7..6fc5b7ae9faa1 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 18bcd7581b4fc..402397d3fde60 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 9f28387d4f140..d764b5bd6d007 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 fa369c8edc62d..5420b9fc8bd50 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 30bf5a7fd9234..cc92563c5847c 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 59a89eadfa73e..3e1cae508a69e 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 d421898732a16..45d2aeafdbfac 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 5932db57a8637..2d17a28744545 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 0d281c46f8605..04dcd22cf3b2c 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 8446116bcd4cb..2163dc002b703 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 7c7f499bc2936..6cb297ff81e90 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 ad8ba85c9cbe9..6ded53bfaf1ba 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, - }), + const pending = Promise.withResolvers(); + const capturePage = vi.fn(() => pending.promise); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), ); - expect(states.at(-1)?.pictureInPicture).toBe(true); + yield* TestClock.adjust(0); expect(capturePage).toHaveBeenCalledOnce(); - const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length; - - yield* manager.startRecording("tab_pip"); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(recordingFrames).toHaveLength(0); - - yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording); - expect(recordingFrames).toHaveLength(0); - - yield* manager.stopRecording("tab_pip"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; - yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(4); - expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick); - expect(recordingFrames).toHaveLength(0); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + pending.resolve(image); + const exit = yield* Fiber.join(fiber); - setBackgroundThrottling.mockImplementationOnce(() => { - throw new Error("picture-in-picture throttling restore failed"); - }); - yield* manager.closePictureInPicture("tab_pip"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [true]]); - expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); - expect(states.at(-1)?.pictureInPicture).toBe(false); - const capturesAfterClose = capturePage.mock.calls.length; - yield* TestClock.adjust(200); - expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); }), ), ); - effectIt.effect("keeps picture-in-picture capture separate from recording warmup", () => + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { - const jpeg = Buffer.from("shared-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), + const firstJpeg = Buffer.from("first-recording-frame"); + const secondJpeg = Buffer.from("second-recording-frame"); + const firstCapturePage = vi.fn(async () => ({ + toJPEG: () => firstJpeg, + getSize: () => ({ width: 800, height: 600 }), })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), + const secondCapturePage = vi.fn(async () => ({ + toJPEG: () => secondJpeg, + getSize: () => ({ width: 390, height: 844 }), + })); + const firstSendCommand = vi.fn(async () => undefined); + const secondSendCommand = vi.fn(async () => undefined); + const host = makeTestHostWebContents(); + const makeWebContents = ( + id: number, + capturePage: typeof firstCapturePage, + sendCommand: typeof firstSendCommand, + ) => + ({ + id, + mainFrame: { routingId: id }, + hostWebContents: host, + executeJavaScript: vi.fn(async () => + id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, + ), + isDestroyed: () => false, + getType: () => "webview", + getURL: () => `https://example.com/${id}`, + getTitle: () => `Example ${id}`, + isLoadingMainFrame: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + }) as never; + const webContentsById = new Map([ + [41, makeWebContents(41, firstCapturePage, firstSendCommand)], + [42, makeWebContents(42, secondCapturePage, secondSendCommand)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), ); - yield* manager.createTab("tab_recording_then_pip"); - yield* manager.registerWebview("tab_recording_then_pip", 42); - yield* manager.startRecording("tab_recording_then_pip"); + yield* manager.createTab("tab_1"); + yield* manager.createTab("tab_2"); + yield* manager.registerWebview("tab_1", 41); + yield* manager.registerWebview("tab_2", 42); - expect(recordingFrames).toHaveLength(0); - expect(capturePage).toHaveBeenCalledOnce(); - yield* manager.openPictureInPicture("tab_recording_then_pip"); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledOnce(); + const grants: Array<{ video?: unknown }> = []; + const takeGrant = () => + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { + grants.push(value); + }); - yield* TestClock.adjust(100); + yield* manager.startRecording("tab_1"); + takeGrant(); + yield* manager.startRecording("tab_2"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(recordingFrames).toHaveLength(0); - expect(send).toHaveBeenCalledOnce(); - yield* manager.closePictureInPicture("tab_recording_then_pip"); - yield* manager.stopRecording("tab_recording_then_pip"); + expect(firstCapturePage).toHaveBeenCalledOnce(); + expect(secondCapturePage).toHaveBeenCalledOnce(); + expect(firstSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + expect(secondSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + + yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], { + concurrency: 2, + discard: true, + }); }), ), ); - effectIt.effect("stops frame capture when the native picture-in-picture window closes", () => + effectIt.effect("only lets the host frame that armed a recording claim its stream", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("native-close-preview-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - - yield* manager.createTab("tab_native_pip_close"); - yield* manager.registerWebview("tab_native_pip_close", 42); - yield* manager.openPictureInPicture("tab_native_pip_close"); + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - pictureInPictureWindow.close(); - yield* settle(() => states.at(-1)?.pictureInPicture === false); + yield* manager.startRecording("tab_race_a"); + takeGrant({ frameTreeNodeId: 999 }); + takeGrant(); - expect(states.at(-1)?.pictureInPicture).toBe(false); - const capturesAfterClose = capturePage.mock.calls.length; - yield* TestClock.adjust(200); - expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + expect(grants).toEqual([{}, { video: { routingId: 41 } }]); + yield* manager.stopRecording("tab_race_a"); }), ), ); - effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () => + effectIt.effect("reclaims the arm slot from a destroyed webContents", () => withManager((manager) => Effect.gen(function* () { - const jpeg = Buffer.from("retry-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - send.mockImplementationOnce(() => { - throw new Error("picture-in-picture delivery failed"); - }); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - - yield* manager.createTab("tab_pip_delivery_retry"); - yield* manager.registerWebview("tab_pip_delivery_retry", 42); - yield* manager.openPictureInPicture("tab_pip_delivery_retry"); - expect(send).toHaveBeenCalledOnce(); + const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager); - yield* TestClock.adjust(100); + yield* manager.startRecording("tab_race_a"); + destroy(41); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledTimes(2); - yield* manager.closePictureInPicture("tab_pip_delivery_retry"); + yield* manager.stopRecording("tab_race_b"); }), ), ); - effectIt.effect("replays an unchanged picture-in-picture frame after its renderer reloads", () => + effectIt.effect("gates recording decorations and isolates failed subscribers", () => withManager((manager) => Effect.gen(function* () { - const jpeg = Buffer.from("reloaded-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send, webContentsListeners } = - makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const callbacks = new Map< + string, + (event: unknown, input: unknown) => Fiber.Fiber | undefined + >(); + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + ipc: { + on: ( + channel: string, + callback: (event: unknown, input: unknown) => Fiber.Fiber | undefined, + ) => callbacks.set(channel, callback), + off: vi.fn(), + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_recording_input"); + yield* manager.registerWebview("tab_recording_input", 42); + const received: DesktopPreviewRecordingInputEvent[] = []; + yield* manager.subscribeRecordingInputs(() => Effect.die("renderer unavailable")); + yield* manager.subscribeRecordingInputs((event) => + Effect.sync(() => { + received.push(event); + }), + ); + const send = (input: unknown) => + Effect.gen(function* () { + const fiber = callbacks.get("preview:recording-input")?.(null, input); + if (fiber) yield* Fiber.join(fiber); + }); + const key = { type: "key", label: "⌘C", held: true, width: 800 }; + const pointer = { + type: "pointer", + phase: "down", + x: 120, + y: 80, + width: 800, + height: 600, + }; + yield* send(key); + expect(received).toEqual([]); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: true, + showMousePresses: false, }); - - yield* manager.createTab("tab_pip_reload"); - yield* manager.registerWebview("tab_pip_reload", 42); - yield* manager.openPictureInPicture("tab_pip_reload"); - expect(send).toHaveBeenCalledOnce(); - - webContentsListeners.get("did-finish-load")?.(); - yield* TestClock.adjust(100); - - expect(send).toHaveBeenCalledTimes(2); - yield* manager.closePictureInPicture("tab_pip_reload"); + yield* send(key); + yield* send(pointer); + yield* send({ ...key, width: 0 }); + expect(received).toEqual([{ tabId: "tab_recording_input", input: key }]); + yield* manager.stopRecording("tab_recording_input"); + yield* send(key); + expect(received).toHaveLength(1); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: false, + showMousePresses: true, + }); + yield* send(key); + yield* send(pointer); + expect(received.at(-1)).toEqual({ tabId: "tab_recording_input", input: pointer }); + expect(received).toHaveLength(2); }), ), ); - effectIt.effect("encodes a picture-in-picture frame only when the page changes", () => + effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { - let page = "static-page"; - let pixelsReadable = true; - const toJPEG = vi.fn((pixels: string) => Buffer.from(`jpeg:${pixels}`)); const capturePage = vi.fn(async () => { - const pixels = page; - return { - toJPEG: () => toJPEG(pixels), - toBitmap: () => (pixelsReadable ? Buffer.from(pixels) : Buffer.alloc(0)), - getSize: () => ({ width: 1280, height: 720 }), - }; + throw new Error("source is not ready"); }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const host = makeTestHostWebContents(); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), }); + fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_pip_unchanged"); - yield* manager.registerWebview("tab_pip_unchanged", 42); - yield* manager.openPictureInPicture("tab_pip_unchanged"); - yield* TestClock.adjust(100); - - expect(capturePage).toHaveBeenCalledTimes(2); - expect(toJPEG).toHaveBeenCalledOnce(); - expect(send).toHaveBeenCalledOnce(); - - page = "changed-page"; - yield* TestClock.adjust(100); + yield* manager.createTab("tab_recording_warmup_failure"); + yield* manager.registerWebview("tab_recording_warmup_failure", 42); - expect(toJPEG).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenLastCalledWith( - "desktop:preview-pip-frame", - expect.objectContaining({ data: Buffer.from("jpeg:changed-page").toString("base64") }), - ); + yield* manager.startRecording("tab_recording_warmup_failure"); + expect(capturePage).toHaveBeenCalledTimes(2); - // A frame whose pixels cannot be read has no fingerprint and is still delivered. - pixelsReadable = false; - yield* TestClock.adjust(100); + const handler = host.displayMediaHandler(); + const streams: Array<{ video?: unknown }> = []; + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); - expect(toJPEG).toHaveBeenCalledTimes(3); - expect(send).toHaveBeenCalledTimes(3); - yield* manager.closePictureInPicture("tab_pip_unchanged"); + yield* manager.stopRecording("tab_recording_warmup_failure"); }), ), ); - effectIt.effect("drops empty frames before picture-in-picture delivery", () => + effectIt.effect("serializes recording source acquisition with webview replacement", () => withManager((manager) => Effect.gen(function* () { - const validImage: TestCapturedPreviewImage = { - toJPEG: () => Buffer.from("valid-preview-frame"), - toBitmap: () => Buffer.from("valid-preview-frame"), + const capturedImage = { + toJPEG: () => Buffer.from("unused-recording-frame"), getSize: () => ({ width: 1280, height: 720 }), }; - const capturePage = vi.fn(async () => validImage); - capturePage.mockResolvedValueOnce({ - toJPEG: () => Buffer.from("empty-preview-frame"), - getSize: () => ({ width: 0, height: 0 }), + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ) + .mockResolvedValue(capturedImage); + const initialWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementOn = vi.fn(); + const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { + on: replacementOn, + }); + fromId.mockImplementation((id) => { + if (id === 42) return initialWebContents; + if (id === 43) return replacementWebContents; + return null; }); - yield* manager.createTab("tab_empty_frame"); - yield* manager.registerWebview("tab_empty_frame", 42); - yield* manager.openPictureInPicture("tab_empty_frame"); - - expect(capturePage).toHaveBeenCalledOnce(); - expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled(); - expect(send).not.toHaveBeenCalled(); - - yield* TestClock.adjust(100); + yield* manager.createTab("tab_recording_replacement_race"); + yield* manager.registerWebview("tab_recording_replacement_race", 42); + const start = yield* manager + .startRecording("tab_recording_replacement_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted); + const replacement = yield* manager + .registerWebview("tab_recording_replacement_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementOn).not.toHaveBeenCalled(); - expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); - expect(send).toHaveBeenCalledOnce(); - yield* manager.closePictureInPicture("tab_empty_frame"); + finishWarmup(capturedImage); + yield* Fiber.join(start); + yield* Fiber.join(replacement); + expect(replacementOn).toHaveBeenCalled(); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + yield* manager.stopRecording("tab_recording_replacement_race"); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + ]); }), ), ); @@ -4356,82 +2376,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("settles the pick when the annotation screenshot never arrives", () => - withManager((manager) => - Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - isFocused: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - once: vi.fn(), - off: vi.fn(), - capturePage: vi.fn(() => new Promise(() => {})), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; - }), - off: vi.fn(), - removeListener: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - - yield* manager.setAnnotationSendEnabled("tab_1", true); - onPicked?.( - {}, - { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }, - null, - "send", - ); - yield* Effect.yieldNow; - expect(pick.pollUnsafe()).toBeUndefined(); - - yield* TestClock.adjust("6 seconds"); - const result = yield* Fiber.join(pick); - expect(result?.annotation.screenshot).toBeNull(); - expect(result?.screenshotFailed).toBe(true); - expect(result?.submission).toBe("send"); - expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured"); - }), - ), - ); - describe("annotation screenshots", () => { const captureAnnotation = ( cropRect: PreviewAnnotationRect | null, @@ -4503,23 +2447,6 @@ describe("PreviewManager", () => { }), ); - effectIt.effect("crops an on-screen region at device scale", () => - Effect.gen(function* () { - const rect = { x: 5, y: 6, width: 20, height: 30 }; - const { result, crop } = yield* captureAnnotation(rect, { - regions: [{ id: "region_1", rect }], - }); - expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 12, width: 40, height: 60 }); - expect(result?.screenshotFailed).toBeUndefined(); - expect(result?.annotation.screenshot).toEqual({ - dataUrl: "data:image/png;base64,cropped", - width: 40, - height: 60, - cropRect: rect, - }); - }), - ); - effectIt.effect("clips a partially visible selection to the captured viewport", () => Effect.gen(function* () { const { result, crop } = yield* captureAnnotation( @@ -4569,6 +2496,7 @@ describe("PreviewManager", () => { }), ); + // regression: #18441 zero-area strokes on the viewport edge must still get a screenshot crop effectIt.effect.each([ { name: "zero-width SVG line on the left edge", @@ -4613,171 +2541,8 @@ describe("PreviewManager", () => { }); }), ); - - effectIt.effect.each(["viewport", "crop"] as const)( - "reports an empty native %s image as a failed screenshot", - (emptyImage) => - Effect.gen(function* () { - const rect = { x: 5, y: 6, width: 20, height: 30 }; - const { result, crop } = yield* captureAnnotation( - rect, - { - regions: [{ id: "region_1", rect }], - }, - emptyImage, - ); - if (emptyImage === "viewport") expect(crop).not.toHaveBeenCalled(); - else - expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 12, width: 40, height: 60 }); - expect(result?.annotation.screenshot).toBeNull(); - expect(result?.screenshotFailed).toBe(true); - }), - ); }); - effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => - withManager((manager) => - Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - isFocused: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - once: vi.fn(), - off: vi.fn(), - capturePage: vi.fn(() => new Promise(() => {})), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; - }), - off: vi.fn(), - removeListener: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const annotation = { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }; - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - onPicked?.({}, annotation, null, "send"); - yield* Effect.yieldNow; - - const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - expect(yield* Fiber.join(firstPick)).toBeNull(); - webviewSend.mockClear(); - - yield* TestClock.adjust("6 seconds"); - yield* Effect.yieldNow; - expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); - expect(secondPick.pollUnsafe()).toBeUndefined(); - - onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach"); - yield* TestClock.adjust("6 seconds"); - const result = yield* Fiber.join(secondPick); - expect(result?.annotation.id).toBe("annotation_2"); - expect(result?.submission).toBe("attach"); - }), - ), - ); - - effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => - withManager((manager) => - Effect.gen(function* () { - let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; - const goBack = vi.fn(); - const goForward = vi.fn(); - let canGoBack = true; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof mouseNavigate) => { - if (channel === "preview:mouse-navigate") mouseNavigate = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { - canGoBack: () => canGoBack, - canGoForward: () => true, - goBack, - goForward, - }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.createTab("tab_nav"); - yield* manager.registerWebview("tab_nav", 42); - expect(mouseNavigate).toBeDefined(); - - mouseNavigate?.({}, { direction: "back" }); - yield* Effect.yieldNow; - expect(goBack).toHaveBeenCalledOnce(); - - mouseNavigate?.({}, { direction: "forward" }); - yield* Effect.yieldNow; - expect(goForward).toHaveBeenCalledOnce(); - - mouseNavigate?.({}, { direction: "sideways" }); - canGoBack = false; - mouseNavigate?.({}, { direction: "back" }); - yield* Effect.yieldNow; - expect(goBack).toHaveBeenCalledOnce(); - }), - ), - ); - effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { @@ -4799,71 +2564,4 @@ describe("PreviewManager", () => { }), ), ); - - effectIt.effect("copies screenshot artifacts to the system clipboard", () => - withManager((manager) => - Effect.gen(function* () { - const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; - - yield* manager.copyArtifactToClipboard(artifactPath); - - expect(createFromPath).toHaveBeenCalledWith(artifactPath); - expect(clipboardItemConstructor).toHaveBeenCalledWith({ - "image/png": expect.any(Blob), - }); - expect(writeClipboard).toHaveBeenCalledOnce(); - const exit = yield* Effect.exit( - manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) return; - const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error).toMatchObject({ - _tag: "PreviewArtifactPathOutsideDirectoryError", - artifactPath: "/tmp/t3/dev/settings.json", - artifactDirectory: "/tmp/t3/dev/browser-artifacts", - }); - expect("cause" in error).toBe(false); - - createFromPath.mockReturnValueOnce({ - isEmpty: () => true, - toPNG: () => Buffer.from("invalid"), - }); - const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); - expect(Exit.isFailure(invalidImageExit)).toBe(true); - if (Exit.isSuccess(invalidImageExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({ - _tag: "PreviewArtifactImageLoadError", - artifactPath, - }); - - const writeCause = new Error("clipboard write failed"); - writeClipboard.mockRejectedValueOnce(writeCause); - const writeExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); - expect(Exit.isFailure(writeExit)).toBe(true); - if (Exit.isSuccess(writeExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(writeExit.cause))).toMatchObject({ - _tag: "PreviewOperationError", - operation: "copyArtifactToClipboard.write", - artifactPath, - cause: writeCause, - }); - }), - ), - ); -}); - -describe("PreviewOperationError", () => { - it("keeps timeline detail separate from its structured message", () => { - const cause = new Error("CDP command failed with an invalid node id"); - const error = new PreviewManager.PreviewOperationError({ - operation: "click.DOM.resolveNode", - tabId: "tab_1", - webContentsId: 42, - cause, - }); - - expect(error.message).not.toContain(cause.message); - expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message); - }); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index b036a67ac8ba0..e9578c711ae40 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -206,7 +206,7 @@ export const fitPictureInPictureContentSize = ( return [Math.round(width), Math.round(height)]; }; -export const recordingFileExtension = (mimeType: string): string => { +const recordingFileExtension = (mimeType: string): string => { const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? ""; const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, ""); return extension || "video"; @@ -495,7 +495,7 @@ export const previewWindowOpenAction = (details: { : "navigate"; }; -export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => +const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && (input.meta || input.control) && diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 8cbaad7f38dce..3b72ab5890757 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 c6b8720314a3d..f848aff47b8f9 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 d7a967324771f..68f313783a275 100644 --- a/apps/desktop/src/preview/PickedElementPayload.test.ts +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -31,76 +31,6 @@ function validPayload(overrides?: Record): Record { - it("accepts a complete, well-typed payload", () => { - expect(isPickedElementPayload(validPayload())).toBe(true); - }); - - it("accepts nullable string fields when null", () => { - expect( - isPickedElementPayload( - validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), - ), - ).toBe(true); - }); - - it("accepts an empty stack array", () => { - expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); - }); - - it("accepts stack frames with null fields", () => { - expect( - isPickedElementPayload( - validPayload({ - stack: [ - { - functionName: null, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - ], - }), - ), - ).toBe(true); - }); - - it("rejects null and primitive inputs", () => { - expect(isPickedElementPayload(null)).toBe(false); - expect(isPickedElementPayload(undefined)).toBe(false); - expect(isPickedElementPayload("string")).toBe(false); - expect(isPickedElementPayload(42)).toBe(false); - expect(isPickedElementPayload([])).toBe(false); - }); - - it.each<[string, Record]>([ - ["missing pageUrl", validPayload({ pageUrl: undefined })], - ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], - ["missing tagName", validPayload({ tagName: undefined })], - ["missing htmlPreview", validPayload({ htmlPreview: undefined })], - ["missing styles", validPayload({ styles: undefined })], - ["missing pickedAt", validPayload({ pickedAt: undefined })], - ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], - ["wrong-type selector", validPayload({ selector: 99 })], - ["wrong-type componentName", validPayload({ componentName: 99 })], - ])("rejects payloads with %s", (_label, value) => { - expect(isPickedElementPayload(value)).toBe(false); - }); - - it("rejects malformed source frames", () => { - expect( - isPickedElementPayload( - validPayload({ - source: { - functionName: 0, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - }), - ), - ).toBe(false); - }); - it("rejects non-finite numeric line/column numbers", () => { expect( isPickedElementPayload( @@ -127,11 +57,6 @@ describe("isPickedElementPayload", () => { ), ).toBe(false); }); - - it("rejects malformed stack arrays", () => { - expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); - expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); - }); }); function validAnnotation(overrides?: Record): Record { @@ -176,26 +101,9 @@ function validAnnotation(overrides?: Record): Record { - it("accepts a structured annotation draft before screenshot capture", () => { - expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); - }); - it("rejects screenshots supplied by the guest preload", () => { expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( false, ); }); - - it("rejects malformed geometry and nested element payloads", () => { - expect( - isPreviewAnnotationPayload( - validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), - ), - ).toBe(false); - expect( - isPreviewAnnotationPayload( - validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), - ), - ).toBe(false); - }); }); diff --git a/apps/desktop/src/preview/RecordingInput.test.ts b/apps/desktop/src/preview/RecordingInput.test.ts index 062685ec29342..0f4f93fb36bf3 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 94681b3e046fe..182b6a505d166 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 a98aa96079e67..832a774577149 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -109,124 +108,6 @@ describe("DesktopSettings", () => { }), ), ); - it.effect("loads defaults when no settings file exists", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); - assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); - }), - ), - ); - - it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual( - DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), - { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings, - ); - }); - - it.effect("loads persisted settings and applies semantic updates", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - linuxPasswordStore: "gnome-libsecret", - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "gnome-libsecret", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - - const exposure = yield* settings.setServerExposureMode("local-only"); - assert.isTrue(exposure.changed); - assert.equal(exposure.settings.serverExposureMode, "local-only"); - - const tailscale = yield* settings.setTailscaleServe({ - enabled: true, - port: Option.some(9443), - }); - assert.isTrue(tailscale.changed); - assert.equal(tailscale.settings.tailscaleServePort, 9443); - - const updateChannel = yield* settings.setUpdateChannel("nightly"); - assert.isTrue(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannel, "nightly"); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); - }), - ), - ); - - it.effect("reports the failed desktop settings write operation and path", () => - withSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true }); - - const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip); - assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError); - assert.equal(error.operation, "replace-settings-file"); - assert.equal(error.path, environment.desktopSettingsPath); - assert.exists(error.cause); - assert.equal( - error.message, - `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`, - ); - }), - ), - ); - - it.effect("does not persist no-op semantic updates", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - - const exposure = yield* settings.setServerExposureMode("local-only"); - assert.isFalse(exposure.changed); - - const tailscale = yield* settings.setTailscaleServe({ - enabled: false, - port: Option.none(), - }); - assert.isFalse(tailscale.changed); - - const updateChannel = yield* settings.setUpdateChannel("latest"); - assert.isFalse(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); - }), - ), - ); it.effect("falls back to defaults when the settings file is malformed", () => withSettings( @@ -296,45 +177,6 @@ describe("DesktopSettings", () => { ), ); - it.effect( - "normalizes unsupported linux password-store values without dropping other settings", - () => - withSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString( - environment.desktopSettingsPath, - `{ - "linuxPasswordStore": "unsupported-store", - "serverExposureMode": "network-accessible", - "tailscaleServeEnabled": true, - "tailscaleServePort": 8443, - "updateChannel": "nightly", - "updateChannelConfiguredByUser": true - }\n`, - ); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "nightly", - updateChannelConfiguredByUser: true, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -442,33 +284,6 @@ describe("DesktopSettings", () => { ), ); - it.effect("normalizes invalid persisted Tailscale Serve ports", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - tailscaleServeEnabled: true, - tailscaleServePort: 0, - }); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists wsl backend toggle and normalizes invalid distro names", () => withSettings( Effect.gen(function* () { @@ -543,19 +358,4 @@ describe("DesktopSettings", () => { }), ), ); - - it.effect("drops invalid persisted wsl distro values on load", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - wslBackendEnabled: true, - wslDistro: "bad/name", - }); - const loaded = yield* settings.load; - assert.equal(loaded.wslBackendEnabled, true); - assert.equal(loaded.wslDistro, null); - }), - ), - ); }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts deleted file mode 100644 index 41ba53cf1d43b..0000000000000 --- 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 75ff38f32260f..51c3d31daae85 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 27ec38f46c59c..0001548f4534f 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -4,10 +4,8 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -155,17 +153,6 @@ const withSavedEnvironments = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopSavedEnvironments", () => { - it.effect("reads saved environment metadata", () => - withSavedEnvironments( - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry(); - - assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); - }), - ), - ); - it.effect("loads lenient saved environment registry documents", () => withSavedEnvironments( Effect.gen(function* () { @@ -216,30 +203,6 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("reports invalid saved secret encoding without exposing the secret", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry("%%%"); - - const error = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); - assert.equal(error.environmentId, savedRegistryRecord.environmentId); - assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); - assert.equal(error.field, "encryptedBearerToken"); - assert.exists(error.cause); - assert.equal( - error.message, - `Failed to decode encryptedBearerToken for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, - ); - assert.notInclude(error.message, "%%%"); - }), - ), - ); - it.effect("returns no secret while encryption is unavailable", () => withSavedEnvironments( Effect.gen(function* () { @@ -254,114 +217,4 @@ describe("DesktopSavedEnvironments", () => { { availableSecretStorage: false }, ), ); - - it.effect("adds saved-environment context to safe storage availability failures", () => { - const cause = new Error("safe storage unavailable"); - return withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry(Base64.encode(textEncoder.encode("enc:bearer-token"))); - - const error = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - - assert.instanceOf( - error, - DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError, - ); - assert.equal(error.operation, "check-encryption-availability"); - assert.equal(error.environmentId, savedRegistryRecord.environmentId); - assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); - assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); - const availabilityError = - error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError; - assert.strictEqual(availabilityError.cause, cause); - assert.equal( - error.message, - `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, - ); - assert.notEqual(error.message, availabilityError.message); - }), - { availabilityError: cause }, - ); - }); - - it.effect("treats empty saved environment documents as empty", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n"); - - assert.deepEqual(yield* savedEnvironments.getRegistry, []); - assert.isTrue( - Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), - ); - }), - ), - ); - - it.effect("surfaces malformed saved environment documents", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); - - const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); - assert.instanceOf( - registryError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); - assert.exists(registryError.cause); - const secretError = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - assert.instanceOf( - secretError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - }), - ), - ); - - it.effect("reports saved environment filesystem reads separately from document decoding", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const baseFileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-saved-environments-test-", - }); - const registryPath = path.join(baseDir, "userdata", "saved-environments.json"); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: registryPath, - }); - const layerFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(permissionError), - }), - ); - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( - Effect.provide(layer(baseDir, undefined, layerFileSystem)), - ); - - const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); - assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); - assert.equal(error.registryPath, registryPath); - assert.strictEqual(error.cause, permissionError); - assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 3adf74bf76f8d..9d12ed5a07fe6 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 ddc760cbb9d09..0000000000000 --- 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 ea6e176b82047..e2a3b913e6487 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 152de5f9478b8..39a7dddf5de72 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 569bf732df65d..164c97b4f22fc 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 af2052c6a03aa..fd0bfdfd15dd5 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 cc4ada0d57ab0..5979bf3b000c7 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 fe55319ecea4b..525e57b34938b 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 2093be868e67e..199af10e8ff96 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 5a90aa818fac8..bfd6b2984147d 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 1cb75134ad07c..0000000000000 --- 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 0ea9c08f935c0..49aa4dd721774 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 8603232e7e9fc..3d05c5eecceb8 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 81889f9f07eb5..0000000000000 --- 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 43ff082204ae1..0000000000000 --- 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 f56c35cb56e09..e987a8fe202ed 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 c0ca5dc877711..ee017fac4086e 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 313d335d38860..6ff7db8e8cc1d 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 acd9a29b32209..0000000000000 --- 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 eb20a4a041eef..35c007e35dc0e 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 0d6381bb3f28e..24b233b465d53 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 6edfa017584a2..e4e80c6c4db64 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 31eae474f87cd..a8ca237009bbe 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 4a319d7ac36a7..aa56de0908970 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 6d57d35ee8b66..9db407be961d2 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 8f57ad55563cc..26ae8fbf9ebe9 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 6da59dcdc5ac4..958e017299e0e 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -168,7 +168,7 @@ export function boundedSnapShotString( return candidate.slice(0, end).trimEnd(); } -export function capturedImageBounds( +function capturedImageBounds( bounds: WindowBounds | null | undefined, sourceBounds: WindowBounds, imageSize: CapturedImageSize, diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts deleted file mode 100644 index adb0a12e8f068..0000000000000 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as NetService from "@t3tools/shared/Net"; -import { SshPasswordPromptError } from "@t3tools/ssh/errors"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; - -function makeTempHomeDir() { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" }); - }); -} - -describe("sshEnvironment", () => { - it("keeps prompt presentation diagnostics distinct from the legacy wrapper message", () => { - const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ - requestId: "prompt-1", - destination: "devbox", - operation: "send-prompt-request", - cause: new Error("renderer send failed"), - }); - - assert.equal(cause.message, "Failed to present SSH password prompt for devbox."); - assert.equal( - DesktopSshEnvironment.toSshPasswordPromptError(cause).message, - "T3 Code window is not available for SSH authentication.", - ); - }); - - it("treats password prompt timeouts as cancellable authentication prompts", () => { - assert.equal( - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( - new SshPasswordPromptError({ - message: "SSH authentication timed out for devbox.", - cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ - requestId: "prompt-1", - destination: "devbox", - }), - }), - ), - true, - ); - }); - - it.effect("wires desktop host discovery through the ssh package runtime", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDir = yield* makeTempHomeDir(); - const sshDir = path.join(homeDir, ".ssh"); - yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); - yield* fs.writeFileString( - path.join(sshDir, "config"), - ["Include config.d/*.conf", "Host devbox", " HostName devbox.example.com", ""].join("\n"), - ); - yield* fs.writeFileString( - path.join(sshDir, "config.d", "team.conf"), - [ - "Host staging", - " HostName staging.example.com", - "Host *", - " ServerAliveInterval 30", - "", - ].join("\n"), - ); - yield* fs.writeFileString( - path.join(sshDir, "known_hosts"), - [ - "known.example.com ssh-ed25519 AAAA", - "|1|hashed|entry ssh-ed25519 AAAA", - "[bastion.example.com]:2222 ssh-ed25519 AAAA", - "", - ].join("\n"), - ); - - const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - const hosts = yield* sshEnvironment.discoverHosts({ homeDir }); - assert.deepEqual(hosts, [ - { - alias: "bastion.example.com", - hostname: "bastion.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "devbox", - hostname: "devbox.example.com", - username: null, - port: null, - source: "ssh-config", - }, - { - alias: "known.example.com", - hostname: "known.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "staging", - hostname: "staging.example.com", - username: null, - port: null, - source: "ssh-config", - }, - ]); - }).pipe( - Effect.provide( - DesktopSshEnvironment.layer().pipe( - Layer.provideMerge( - Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { - request: () => Effect.die("unexpected password prompt request"), - resolve: () => Effect.die("unexpected password prompt resolution"), - }), - ), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - ), - ), - Effect.scoped, - ), - ); -}); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 08dd48b718f93..61b104b4ac62b 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -87,7 +87,7 @@ function unexpectedPasswordPromptError(error: never): never { throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`); } -export function toSshPasswordPromptError( +function toSshPasswordPromptError( cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, ): SshPasswordPromptError { let message: string; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index dc46278a11504..b86cc275df230 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 5e59973901c1a..f3f0163401ab2 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 1fc0a9e39be5d..49acc3f86a483 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.fork.test.ts b/apps/desktop/src/updates/DesktopUpdates.fork.test.ts new file mode 100644 index 0000000000000..7c058105373ec --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdates.fork.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; + +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; + +import * as Layer from "effect/Layer"; + +import * as Option from "effect/Option"; + +import * as TestClock from "effect/testing/TestClock"; + +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import { makeHarness } from "./updatesTestHarness.ts"; + +describe("DesktopUpdates", () => { + it.effect("keeps fork builds off the upstream update feed", () => { + const harness = makeHarness({ + appVersion: "0.0.45-v2.1", + env: { T3CODE_DISABLE_AUTO_UPDATE: "false" }, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const result = yield* updates.check("manual"); + assert.equal(result.checked, false); + yield* TestClock.adjust(Duration.hours(2)); + + const state = yield* updates.getState; + assert.equal(state.enabled, false); + assert.equal(state.status, "disabled"); + assert.deepEqual( + yield* updates.disabledReason, + Option.some("Automatic updates are disabled for fork builds."), + ); + assert.equal(harness.checkCount(), 0); + assert.equal(harness.listenerCount(), 0); + assert.deepEqual(harness.feedUrls(), []); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); +}); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ce728210b1539..d2f24143fc209 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,100 +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("keeps fork builds off the upstream update feed", () => { - const harness = makeHarness({ - appVersion: "0.0.45-v2.1", - env: { T3CODE_DISABLE_AUTO_UPDATE: "false" }, - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - const result = yield* updates.check("manual"); - assert.equal(result.checked, false); - yield* TestClock.adjust(Duration.hours(2)); - - const state = yield* updates.getState; - assert.equal(state.enabled, false); - assert.equal(state.status, "disabled"); - assert.deepEqual( - yield* updates.disabledReason, - Option.some("Automatic updates are disabled for fork builds."), - ); - assert.equal(harness.checkCount(), 0); - assert.equal(harness.listenerCount(), 0); - assert.deepEqual(harness.feedUrls(), []); - }), - ).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) => @@ -158,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(); @@ -334,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", () => @@ -436,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(); @@ -535,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({ @@ -749,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" }), @@ -949,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(); @@ -1027,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(); @@ -1092,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 57d185c0975e9..008a83eab03db 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 d7afeafab879e..5247931221f0b 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 91fb45fee3a42..f7c65b35820cc 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 acf4155fab8e3..0000000000000 --- 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 de57e0a8e2c70..41af75f61e962 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 c5e3194f5bb84..dbc7a47ee910d 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 beed625dc0f05..45cdd65f848a9 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -132,95 +132,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(); @@ -291,37 +202,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.fork.test.ts b/apps/desktop/src/window/DesktopWindow.fork.test.ts new file mode 100644 index 0000000000000..50d3725a60b2e --- /dev/null +++ b/apps/desktop/src/window/DesktopWindow.fork.test.ts @@ -0,0 +1,590 @@ +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 Ref from "effect/Ref"; + +import * as Schema from "effect/Schema"; + +import * as Electron from "electron"; +import { zoomFactorToLevel, zoomLevelToFactor } from "@t3tools/shared/interfaceZoom"; + +import { vi } from "vite-plus/test"; + +vi.mock("electron", async (importOriginal) => ({ + ...(await importOriginal()), + session: { + fromPartition: vi.fn(() => ({ + getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), + setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), + })), + }, + screen: { + getAllDisplays: vi.fn(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }, + ]), + }, +})); + +import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; +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 * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; +import * as PreviewManager from "../preview/Manager.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; + +const environmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +function makeFakeBrowserWindow() { + const windowListeners = new Map void>(); + const webContentsListeners = new Map void>(); + let zoomLevel = 0; + const webContents = { + copyImageAt: vi.fn(), + focus: vi.fn(), + isDestroyed: vi.fn(() => false), + getURL: vi.fn(() => "t3code-dev://app/"), + getZoomLevel: vi.fn(() => zoomLevel), + getZoomFactor: vi.fn(() => 1.2 ** zoomLevel), + setZoomLevel: vi.fn((level: number) => { + zoomLevel = level; + }), + isLoadingMainFrame: vi.fn(() => false), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + webContentsListeners.set(eventName, listener); + }), + once: vi.fn<(eventName: string, listener: (...args: readonly unknown[]) => void) => void>(), + openDevTools: vi.fn(), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), + replaceMisspelling: vi.fn(), + send: vi.fn(), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn(), + toggleDevTools: vi.fn(), + }; + + const window = { + close: vi.fn(), + focus: vi.fn(), + getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isMinimized: vi.fn(() => false), + isVisible: vi.fn(() => true), + loadURL: vi.fn(() => Promise.resolve()), + maximize: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), + once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), + restore: vi.fn(), + setBackgroundColor: vi.fn(), + setAutoHideCursor: vi.fn(), + setFullScreen: vi.fn(), + setOpacity: vi.fn(), + setTitle: vi.fn(), + setTitleBarOverlay: vi.fn(), + setWindowButtonPosition: vi.fn(), + show: vi.fn(), + webContents, + }; + + return { + window: window as unknown as Electron.BrowserWindow, + close: window.close, + getBounds: window.getBounds, + getNormalBounds: window.getNormalBounds, + isDestroyed: window.isDestroyed, + isFullScreen: window.isFullScreen, + isMaximized: window.isMaximized, + isMinimized: window.isMinimized, + loadURL: window.loadURL, + maximize: window.maximize, + openDevTools: webContents.openDevTools, + reload: webContents.reload, + reloadIgnoringCache: webContents.reloadIgnoringCache, + toggleDevTools: webContents.toggleDevTools, + send: webContents.send, + setZoomLevel: webContents.setZoomLevel, + setWindowButtonPosition: window.setWindowButtonPosition, + setBackgroundThrottling: webContents.setBackgroundThrottling, + setAutoHideCursor: window.setAutoHideCursor, + setFullScreen: window.setFullScreen, + setOpacity: window.setOpacity, + webContentsListeners, + webContentsOnce: webContents.once, + windowListeners, + }; +} + +const layerDesktopClientSettings = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ + get: Effect.succeedNone, +}); + +const layerElectronApp = Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.void, +}); + +const layerDesktopAssets = Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.none(), + icns: Option.none(), + png: Option.none(), + }), + resolveResourcePath: () => Effect.succeed(Option.none()), +} satisfies DesktopAssets.DesktopAssets["Service"]); + +const layerDesktopServerExposure = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 3773, + bindHost: "127.0.0.1", + httpBaseUrl: new URL("http://127.0.0.1:3773"), + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), +} satisfies DesktopServerExposure.DesktopServerExposure["Service"]); + +const layerElectronMenu = Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: () => Effect.void, + popupTemplate: () => Effect.void, + showContextMenu: () => Effect.succeedNone, +} satisfies ElectronMenu.ElectronMenu["Service"]); + +const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, +} satisfies ElectronTheme.ElectronTheme["Service"]); + +const layerDesktopEnvironment = DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), + ), +); + +const desktopWindowBoundsEquivalence = Schema.toEquivalence( + DesktopAppSettings.DesktopWindowBoundsSchema, +); + +function layerTest(input: { + readonly window: Electron.BrowserWindow; + readonly createCount: Ref.Ref; + readonly mainWindow: Ref.Ref>; + readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly desktopSettings?: DesktopAppSettings.DesktopSettings; + readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowMaximizedUpdates?: boolean[]; + readonly beforeMainWindowBoundsUpdate?: ( + bounds: DesktopAppSettings.DesktopWindowBounds, + ) => Effect.Effect; + readonly setPreviewMainWindow?: (window: Electron.BrowserWindow) => Effect.Effect; + readonly destroyAll?: Effect.Effect; + readonly onWindowCreate?: Effect.Effect; + readonly openedExternalUrls?: unknown[]; + readonly copiedTexts?: string[]; + readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; + readonly previewZoomReapplies?: number[]; + readonly onReveal?: (window: Electron.BrowserWindow) => void; + readonly focusedWindow?: Electron.BrowserWindow; +}) { + let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const layerDesktopAppSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => desktopSettings), + load: Effect.sync(() => desktopSettings), + setMainWindowBounds: (bounds, isMaximized) => + Effect.gen(function* () { + if (input.beforeMainWindowBoundsUpdate) { + yield* input.beforeMainWindowBoundsUpdate(bounds); + } + const changed = + desktopSettings.mainWindowBounds === null || + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowMaximized !== isMaximized; + if (changed) { + desktopSettings = { + ...desktopSettings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; + input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowMaximizedUpdates?.push(isMaximized); + } + return { settings: desktopSettings, changed }; + }), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + + const layerElectronWindow = Layer.succeed(ElectronWindow.ElectronWindow, { + create: (options) => + Effect.sync(() => { + input.createdWindowOptions?.push(options); + }).pipe( + Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.andThen(input.onWindowCreate ?? Effect.void), + Effect.as(input.window), + ), + main: Ref.get(input.mainWindow), + currentMainOrFirst: Ref.get(input.mainWindow), + focusedMainOrFirst: + input.focusedWindow === undefined + ? Ref.get(input.mainWindow) + : Effect.succeedSome(input.focusedWindow), + setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), + clearMain: () => Ref.set(input.mainWindow, Option.none()), + prepareReveal: () => Effect.succeed(false), + reveal: (window) => Effect.sync(() => input.onReveal?.(window)), + sendAll: () => Effect.void, + destroyAll: input.destroyAll ?? Effect.void, + syncAllAppearance: (sync) => sync(input.window), + } satisfies ElectronWindow.ElectronWindow["Service"]); + + return DesktopWindow.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerDesktopAssets, + Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + recordMetrics: () => Effect.void, + shutdown: Effect.void, + }), + layerDesktopEnvironment, + layerDesktopAppSettings, + layerDesktopClientSettings, + layerDesktopServerExposure, + DesktopState.layer, + layerElectronApp, + Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: () => Effect.void, + showContextMenu: () => Effect.succeedNone, + popupTemplate: input.onPopupTemplate ?? (() => Effect.void), + }), + Layer.succeed(ElectronShell.ElectronShell, { + openExternal: (url) => + Effect.sync(() => { + input.openedExternalUrls?.push(url); + return true; + }), + openSystemSettings: () => Effect.succeed(true), + copyText: (text) => + Effect.sync(() => { + input.copiedTexts?.push(text); + }), + } satisfies ElectronShell.ElectronShell["Service"]), + layerElectronTheme, + layerElectronWindow, + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), + Layer.mock(PreviewManager.PreviewManager)({ + getBrowserSession: () => Effect.succeed({} as Electron.Session), + setMainWindow: input.setPreviewMainWindow ?? (() => Effect.void), + prepareWebview: () => Effect.void, + isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + reapplyZoom: () => + Effect.sync(() => { + input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel()); + }), + }), + ), + ), + Layer.merge(DesktopState.layer), + ); +} + +const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => + Effect.gen(function* () { + const createdWindows = yield* Ref.make([]); + const createCalls = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const revealedWindows = yield* Ref.make([]); + const fallbackWindow = createOutcomes.find( + (window): window is Electron.BrowserWindow => window !== null, + ); + + const currentMainOrFirst = Effect.gen(function* () { + const registered = yield* Ref.get(mainWindow); + if (Option.isSome(registered)) { + return registered; + } + const created = yield* Ref.get(createdWindows); + return Option.fromNullishOr(created[0] ?? null); + }); + + const electronWindowShape = { + create: () => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(createCalls, (count) => count + 1); + const outcome = createOutcomes[index] ?? null; + if (outcome === null) { + return yield* new ElectronWindow.ElectronWindowCreateError({ + options: { + title: null, + width: null, + height: null, + minWidth: null, + minHeight: null, + show: null, + modal: null, + frame: null, + transparent: null, + backgroundColor: null, + webPreferences: { + preload: null, + partition: null, + backgroundThrottling: null, + sandbox: null, + contextIsolation: null, + nodeIntegration: null, + webviewTag: null, + }, + }, + cause: new Error("simulated window-open failure"), + }); + } + yield* Ref.update(createdWindows, (windows) => [...windows, outcome]); + return outcome; + }), + main: Ref.get(mainWindow), + currentMainOrFirst, + focusedMainOrFirst: currentMainOrFirst, + setMain: (window) => Ref.set(mainWindow, Option.some(window)), + clearMain: () => Ref.set(mainWindow, Option.none()), + prepareReveal: () => Effect.succeed(false), + reveal: (window) => Ref.update(revealedWindows, (windows) => [...windows, window]), + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: (sync) => (fallbackWindow ? sync(fallbackWindow) : Effect.void), + } satisfies ElectronWindow.ElectronWindow["Service"]; + + const layer = DesktopWindow.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerDesktopAssets, + Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + recordMetrics: () => Effect.void, + shutdown: Effect.void, + }), + layerDesktopEnvironment, + DesktopAppSettings.layerTest(), + layerDesktopClientSettings, + layerDesktopServerExposure, + DesktopState.layer, + layerElectronApp, + layerElectronMenu, + Layer.succeed(ElectronShell.ElectronShell, { + openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(true), + copyText: () => Effect.void, + } satisfies ElectronShell.ElectronShell["Service"]), + layerElectronTheme, + Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), + Layer.mock(PreviewManager.PreviewManager)({ + getBrowserSession: () => Effect.succeed({} as Electron.Session), + setMainWindow: () => Effect.void, + isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + }), + ), + ), + ); + + return { layer, createCalls, mainWindow, revealedWindows } as const; + }); + +describe("DesktopWindow", () => { + // 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. + it.effect("zooms main with a focused popup and reapplies preview zoom", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const popup = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + focusedWindow: popup.window, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("in"); + yield* desktopWindow.zoomMain("reset"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [0.9, 0.8, 0.9, 1].map(zoomFactorToLevel), + ); + assert.deepEqual(previewZoomReapplies, [0.9, 0.8, 0.9, 1].map(zoomFactorToLevel)); + assert.equal(popup.setZoomLevel.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + // The Settings dropdown and the shortcuts share the window's zoom level, so + // the dropdown must read back what a shortcut set and keep to the listed steps. + it.effect("sets the interface zoom to a listed step and reads it back", () => + 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")); + + yield* desktopWindow.setMainZoomFactor(1.27); + assert.closeTo(yield* desktopWindow.mainZoomFactor, 1.25, 1e-9); + yield* desktopWindow.zoomMain("in"); + assert.closeTo(zoomLevelToFactor(fakeWindow.setZoomLevel.mock.lastCall![0]), 1.5, 1e-9); + }).pipe(Effect.provide(layer)); + }), + ); + + // Ctrl+wheel bypasses menu accelerators, so it uses the same listed steps as + // the Settings dropdown and restores the embedded preview's own zoom. + it.effect("zooms the app from Ctrl+wheel using interface steps", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const zoomChanged = fakeWindow.webContentsListeners.get("zoom-changed"); + if (!zoomChanged) return yield* Effect.die("zoom-changed listener was not registered"); + zoomChanged({}, "in"); + zoomChanged({}, "out"); + zoomChanged({}, "out"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [zoomFactorToLevel(1.1), 0, zoomFactorToLevel(0.9)], + ); + assert.deepEqual(previewZoomReapplies, [1.1, 1, 0.9].map(zoomFactorToLevel)); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect( + "keeps the local splash through a backend crash so a restart can still open the app", + () => + Effect.gen(function* () { + const splash = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([splash.window]); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.showConnectingSplash("Starting T3 Code…"); + yield* desktopWindow.setConnectingSplashLabel("Upgrading the database…"); + yield* desktopWindow.setConnectingSplashLabel("Upgrading the database…"); + assert.equal(splash.loadURL.mock.calls.length, 2); + yield* desktopWindow.handleBackendNotReady; + assert.equal(splash.close.mock.calls.length, 0); + yield* desktopWindow.setConnectingSplashLabel("Starting T3 Code…"); + assert.equal(splash.loadURL.mock.calls.length, 3); + assert.equal(yield* Ref.get(scenario.createCalls), 1); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + + it.effect("keeps the local splash until the main window is revealed", () => + 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("Starting T3 Code…"); + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(splash.close.mock.calls.length, 0); + main.windowListeners.get("ready-to-show")?.(); + yield* Effect.promise(() => Promise.resolve()); + assert.equal(splash.close.mock.calls.length, 1); + yield* desktopWindow.setConnectingSplashLabel("Upgrading the database…"); + assert.equal(splash.loadURL.mock.calls.length, 1); + yield* desktopWindow.showConnectingSplash("Starting T3 Code…"); + assert.equal(yield* Ref.get(scenario.createCalls), 2); + }).pipe(Effect.provide(scenario.layer)); + }), + ); +}); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index bad0874bc73e0..b4fee0575cd99 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,3 +1,4 @@ +import { zoomFactorToLevel } from "@t3tools/shared/interfaceZoom"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -8,15 +9,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 { zoomFactorToLevel, zoomLevelToFactor } from "@t3tools/shared/interfaceZoom"; -import * as NodeEvents from "node:events"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -49,12 +46,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"; @@ -133,7 +125,6 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, - close: window.close, getBounds: window.getBounds, getNormalBounds: window.getNormalBounds, isDestroyed: window.isDestroyed, @@ -465,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 = [ @@ -619,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", () => @@ -749,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. @@ -810,30 +586,9 @@ describe("DesktopWindow", () => { }), ); - // The Settings dropdown and the shortcuts share the window's zoom level, so - // the dropdown must read back what a shortcut set and keep to the listed steps. - it.effect("sets the interface zoom to a listed step and reads it back", () => - 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")); - - yield* desktopWindow.setMainZoomFactor(1.27); - assert.closeTo(yield* desktopWindow.mainZoomFactor, 1.25, 1e-9); - yield* desktopWindow.zoomMain("in"); - assert.closeTo(zoomLevelToFactor(fakeWindow.setZoomLevel.mock.lastCall![0]), 1.5, 1e-9); - }).pipe(Effect.provide(layer)); - }), - ); - - // Ctrl+wheel bypasses menu accelerators, so it uses the same listed steps as - // the Settings dropdown and restores the embedded preview's own zoom. - it.effect("zooms the app from Ctrl+wheel using interface steps", () => + // Ctrl+wheel bypasses the menu accelerators, so it has to take the same + // zoom step, preview restore included, from the window's zoom-changed event. + it.effect("zooms the app from Ctrl+wheel like the menu does", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); @@ -858,7 +613,7 @@ describe("DesktopWindow", () => { assert.deepEqual( fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), - [zoomFactorToLevel(1.1), 0, zoomFactorToLevel(0.9)], + [1.1, 1, 0.9].map(zoomFactorToLevel), ); assert.deepEqual(previewZoomReapplies, [1.1, 1, 0.9].map(zoomFactorToLevel)); }).pipe(Effect.provide(layer)); @@ -896,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(); @@ -1091,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(); @@ -1224,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(); @@ -1332,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(); @@ -1404,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(); @@ -1509,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(); @@ -1686,48 +993,6 @@ describe("DesktopWindow", () => { }), ); - it.effect( - "keeps the local splash through a backend crash so a restart can still open the app", - () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window]); - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.showConnectingSplash("Starting T3 Code…"); - yield* desktopWindow.setConnectingSplashLabel("Upgrading the database…"); - yield* desktopWindow.setConnectingSplashLabel("Upgrading the database…"); - assert.equal(splash.loadURL.mock.calls.length, 2); - yield* desktopWindow.handleBackendNotReady; - assert.equal(splash.close.mock.calls.length, 0); - yield* desktopWindow.setConnectingSplashLabel("Starting T3 Code…"); - assert.equal(splash.loadURL.mock.calls.length, 3); - assert.equal(yield* Ref.get(scenario.createCalls), 1); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - - it.effect("keeps the local splash until the main window is revealed", () => - 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("Starting T3 Code…"); - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - assert.equal(splash.close.mock.calls.length, 0); - main.windowListeners.get("ready-to-show")?.(); - yield* Effect.promise(() => Promise.resolve()); - assert.equal(splash.close.mock.calls.length, 1); - yield* desktopWindow.setConnectingSplashLabel("Upgrading the database…"); - assert.equal(splash.loadURL.mock.calls.length, 1); - yield* desktopWindow.showConnectingSplash("Starting T3 Code…"); - assert.equal(yield* Ref.get(scenario.createCalls), 2); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - it.effect( "retries opening the real main on activate when a failed post-readiness open left only the splash", () => @@ -1758,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 = []; @@ -1860,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(); @@ -1926,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 25d6c540e5bd7..a3d85650d8cb0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -203,7 +203,7 @@ function buildConnectingSplashDataUrl( return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -export function isSameOriginRendererNavigation(input: { +function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; }): boolean { @@ -214,7 +214,7 @@ export function isSameOriginRendererNavigation(input: { } } -export function isRetryableDevelopmentRendererLoadFailure(input: { +function isRetryableDevelopmentRendererLoadFailure(input: { readonly applicationUrl: string; readonly errorCode: number; readonly isMainFrame: boolean; @@ -230,7 +230,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 591fad185a5da..bebcd66e61c6e 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 eb0b3141c47a8..b87ae9ceab29a 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 e6dcb7bd7133e..8468a9538630c 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 670dcb8984050..711733cb36b54 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -2,40 +2,16 @@ import { describe, it } from "@effect/vitest"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; - -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, buildWslRuntimeProbeScript, - DesktopWslDistroListError, - DesktopWslEnvironment, - formatMissingToolsReason, - layer as wslEnvironmentLayer, - parseNodePath, - parseNodeVersion, parseResolvedPath, - parseToolchainReport, parseWslRuntimeRoot, - probeWslDistros, } from "./DesktopWslEnvironment.ts"; -const encoder = new TextEncoder(); - // The install script only fails the way this file cares about when a real shell // runs it, so find one that has the tools it needs: bash directly on Linux, and // the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere @@ -102,166 +78,6 @@ const readField = (stdout: string, field: string) => { const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; -const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => - ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: - result.exitCode === undefined - ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), - isRunning: Effect.succeed(result.exitCode === undefined), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout ?? "")), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ); - -describe("probeWslDistros", () => { - it.effect("preserves a successful empty distro list", () => - Effect.gen(function* () { - const distros = yield* probeWslDistros; - expect(distros).toEqual([]); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeDistroListSpawner({ stdout: "", exitCode: 0 }), - ), - ), - ); - - it.effect("fails when the distro-list command exits unsuccessfully", () => - Effect.gen(function* () { - const error = yield* probeWslDistros.pipe(Effect.flip); - expect(error).toBeInstanceOf(DesktopWslDistroListError); - expect(error.message).toContain("exited with code 1"); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeDistroListSpawner({ exitCode: 1 }), - ), - ), - ); - - it.effect("fails when the distro-list command times out", () => { - const layer = Layer.merge( - TestClock.layer(), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), - ); - return Effect.gen(function* () { - const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.seconds(8)); - const error = yield* Fiber.join(fiber); - expect(error).toBeInstanceOf(DesktopWslDistroListError); - expect(error.message).toContain("timed out"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("WSL command deadlines", () => { - const makeControlledEnvironment = Effect.gen(function* () { - const started = yield* Deferred.make(); - const exited = yield* Deferred.make(); - const released = yield* Deferred.make(); - const spawner = ChildProcessSpawner.make(() => - Effect.acquireRelease( - Deferred.succeed(started, undefined).pipe( - Effect.as( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Deferred.await(exited), - isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ), - () => Deferred.succeed(released, undefined), - ), - ); - const environment = yield* DesktopWslEnvironment.pipe( - Effect.provide( - wslEnvironmentLayer.pipe( - Layer.provide([ - DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop", - homeDirectory: "/home/test", - platform: "win32", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/repo/resources", - runningUnderArm64Translation: false, - }).pipe(Layer.provide([Path.layer, DesktopConfig.layerTest({})])), - FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - ]), - ), - ), - ); - return { environment, started, exited, released }; - }); - - it.effect("waits for a cold distro to finish starting after 15 seconds", () => - Effect.gen(function* () { - const { environment, started, exited, released } = yield* makeControlledEnvironment; - const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.seconds(15)); - expect(yield* Deferred.isDone(released)).toBe(false); - - yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); - yield* Fiber.join(fiber); - expect(yield* Deferred.isDone(released)).toBe(true); - }), - ); - - it.effect("releases a stalled warmup process at the 30-second deadline", () => - Effect.gen(function* () { - const { environment, started, released } = yield* makeControlledEnvironment; - const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.seconds(29)); - expect(yield* Deferred.isDone(released)).toBe(false); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* Fiber.join(fiber); - expect(yield* Deferred.isDone(released)).toBe(true); - }), - ); - - it.effect("still gives path conversion only 10 seconds and releases its process", () => - Effect.gen(function* () { - const { environment, started, released } = yield* makeControlledEnvironment; - const fiber = yield* environment - .windowsToWslPath("Ubuntu", "C:\\project") - .pipe(Effect.forkScoped); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.seconds(9)); - expect(yield* Deferred.isDone(released)).toBe(false); - - yield* TestClock.adjust(Duration.seconds(1)); - expect(yield* Fiber.join(fiber)).toEqual(Option.none()); - expect(yield* Deferred.isDone(released)).toBe(true); - }), - ); -}); - describe("WSL runtime cache", () => { it.each([ [ @@ -276,181 +92,6 @@ describe("WSL runtime cache", () => { expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); expect(script).not.toContain(runtimeId); }); - - it("installs through a temporary directory and only reuses valid completed caches", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); - expect(script).toContain(' [ -f "$ready_marker" ] &&'); - expect(script).toContain(' runtime_entry_runs "$runtime_root" 2>/dev/null &&'); - expect(script).toContain("if runtime_is_ready; then"); - expect(script).not.toContain("bin.mjs"); - expect(script).not.toContain("node-pty"); - expect(script).toContain("trap 'exit 1' HUP INT TERM"); - expect(script).toContain('exec 9> "$runtime_lock"'); - expect(script).toContain("flock -x 9"); - expect(script).not.toContain('rm -rf "$runtime_lock"'); - expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); - expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); - // The release archive wraps everything in one `t3--linux-x64/` - // directory; stripping it puts the executable at `$runtime_root/t3`. - expect(script).toContain( - "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", - ); - expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); - expect(script).not.toContain('rm -rf "$runtime_root"'); - - const lockAcquired = script.indexOf("flock -x 9"); - const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1); - const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - expect(lockAcquired).toBeGreaterThan(-1); - expect(readinessAfterLock).toBeGreaterThan(lockAcquired); - expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock); - }); - - it("verifies the archive digest before extracting, and only on a cache miss", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - const expected = "b".repeat(64); - expect(script).toContain( - "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", - ); - expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); - - const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - const digestChecked = script.indexOf("archive_sha=$(sha256sum"); - const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - const extracted = script.indexOf("tar -xzf"); - expect(digestChecked).toBeGreaterThan(readyShortCircuit); - expect(existingRuntimeMoved).toBeGreaterThan(digestChecked); - expect(extracted).toBeGreaterThan(digestChecked); - }); - - it("moves an in-use runtime aside instead of deleting it under a live backend", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "sha256-" + "c".repeat(64), - "b".repeat(64), - ); - - expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - expect(script).toContain(" [ -d /proc/1 ] || return 0"); - expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); - - // A process's cmdline keeps the pre-rename path, so the question is only - // answerable before the move. - const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then'); - const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - expect(inUseChecked).toBeGreaterThan(-1); - expect(inUseChecked).toBeLessThan(moved); - - const kept = script.indexOf('touch "$runtime_stale"'); - const deleted = script.indexOf('rm -rf "$runtime_stale"'); - expect(kept).toBeGreaterThan(moved); - expect(deleted).toBeGreaterThan(kept); - }); - - it("re-hashes the executable against the digest the install recorded", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); - expect(script).toContain( - ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', - ); - expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); - expect(script).toContain( - `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, - ); - - const extracted = script.indexOf("tar -xzf"); - const digestRecorded = script.indexOf( - 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', - ); - const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); - const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); - expect(digestRecorded).toBeGreaterThan(extracted); - expect(markerWritten).toBeGreaterThan(digestRecorded); - expect(promoted).toBeGreaterThan(markerWritten); - }); - - it("refuses to mark an archive whose executable does not run as ready", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - - const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); - const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); - const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); - expect(payloadValidated).toBeGreaterThan(-1); - expect(markerWritten).toBeGreaterThan(payloadValidated); - expect(promoted).toBeGreaterThan(payloadValidated); - }); - - it("parses only absolute Linux runtime paths", () => { - expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe( - "/home/josh/.t3/wsl-runtime/1.2.3-x64", - ); - expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull(); - expect(parseWslRuntimeRoot("noise\n")).toBeNull(); - }); - - it("prunes completed runtimes except the current and newest previous cache", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"'); - expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]'); - expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue'); - expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue'); - expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue'); - expect(script).toContain('rm -rf -- "$candidate"'); - }); - - it("never deletes a runtime another backend is running from", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); - - expect(script).toContain("[ -d /proc/1 ] || exit 0"); - - const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); - const removed = script.indexOf('rm -rf -- "$candidate"'); - expect(inUseChecked).toBeGreaterThan(-1); - expect(removed).toBeGreaterThan(inUseChecked); - }); - - it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain( - 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', - ); - expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); - }); - - it("invalidates a cache by dropping its ready marker, not the tree", () => { - const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); - - expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); - expect(script).not.toContain("rm -rf"); - }); }); describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { @@ -546,16 +187,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(provider.stdout).toBe("linux-node-provider\n"); }); - it("keeps standalone runtime readiness independent of Node availability", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - - const probe = probeFixture(fixture); - - expect(probe.status, probe.stderr).toBe(0); - expect(parseResolvedPath(probe.stdout)).not.toBeNull(); - }); - it("keeps the inherited PATH when bash is unavailable", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); @@ -747,53 +378,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); }); - it("prunes a selected cache after its prepare-to-spawn grace period expires", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - const result = runShell( - [ - "set -eu", - `runtime_parent=${sh(fixture.runtimeParent)}`, - 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', - 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', - 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', - `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, - `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`, - 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', - `HOME=${sh(`${fixture.work}/home`)}`, - "export HOME", - buildWslRuntimePruneScript("sha256-current"), - `test ! -e ${sh(fixture.runtimeRoot)}`, - ].join("\n"), - ); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - }); - - it("removes an aged stale tree after replacing an active unready cache", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - const result = runShell( - [ - "set -eu", - `runtime_root=${sh(fixture.runtimeRoot)}`, - `runtime_parent=${sh(fixture.runtimeParent)}`, - 'rm "$runtime_root/.t3code-wsl-runtime-ready"', - holdRuntimeBusy('"$runtime_root/t3"'), - fixture.installScript(), - 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)', - 'test -n "$stale"', - 'touch -d "180 minutes ago" "$stale"', - `HOME=${sh(`${fixture.work}/home`)}`, - "export HOME", - buildWslRuntimePruneScript(fixture.runtimeId), - 'test ! -e "$stale"', - ].join("\n"), - ); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - }); - it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { const result = runShell( [ @@ -833,78 +417,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed }); }); -describe("parseToolchainReport", () => { - it("returns no missing tools and no node version on empty output", () => { - expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); - }); - - it("collects all missing: lines", () => { - const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); - expect(parseToolchainReport(stdout)).toEqual({ - missingTools: ["make", "g++"], - nodeVersion: "24.10.0", - }); - }); - - it("ignores blank lines and trims whitespace", () => { - const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); - expect(parseToolchainReport(stdout)).toEqual({ - missingTools: ["python3"], - nodeVersion: "v22.16.0", - }); - }); - - it("returns null node version when value after prefix is empty", () => { - expect(parseToolchainReport("nodeVersion:")).toEqual({ - missingTools: [], - nodeVersion: null, - }); - }); -}); - -describe("parseNodePath", () => { - it("extracts the absolute node path from a nodePath: line", () => { - const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; - expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); - }); - - it("returns null when node was not found (empty value after prefix)", () => { - expect(parseNodePath("nodePath:")).toBeNull(); - }); - - it("returns null when there is no nodePath line at all", () => { - expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); - }); - - it("ignores surrounding noise and trims whitespace", () => { - const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); - expect(parseNodePath(stdout)).toBe("/usr/bin/node"); - }); -}); - -describe("parseNodeVersion", () => { - it("extracts the node version from a nodeVersion: line", () => { - expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0"); - }); - - it("returns null when the version value is empty", () => { - expect(parseNodeVersion("nodeVersion:")).toBeNull(); - }); - - it("returns null when there is no nodeVersion line at all", () => { - expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull(); - }); - - it("ignores surrounding noise and trims whitespace", () => { - const stdout = [ - "some preamble noise", - " nodeVersion:22.16.0 ", - "nodePath:/usr/bin/node", - ].join("\n"); - expect(parseNodeVersion(stdout)).toBe("22.16.0"); - }); -}); - describe("parseResolvedPath", () => { it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; @@ -918,52 +430,4 @@ describe("parseResolvedPath", () => { "/usr/local/bin:/usr/bin", ); }); - - it("returns null when the resolved PATH is absent or empty", () => { - expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull(); - expect(parseResolvedPath("resolvedPath:\n")).toBeNull(); - }); -}); - -describe("formatMissingToolsReason", () => { - it("returns null when everything is present and node is in range", () => { - expect( - formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), - ).toBeNull(); - }); - - it("returns null when range is not specified and tools are present", () => { - expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); - }); - - it("flags missing node first", () => { - const reason = formatMissingToolsReason( - { missingTools: ["node", "make"], nodeVersion: null }, - "^24.10", - ); - expect(reason).toContain("node"); - expect(reason).toContain("^24.10"); - expect(reason).toContain("make"); - expect(reason).toContain("nvm"); - }); - - it("flags an out-of-range node version with the actual version surfaced", () => { - const reason = formatMissingToolsReason( - { missingTools: [], nodeVersion: "20.0.0" }, - "^24.10 || ^22.16", - ); - expect(reason).toContain("node 20.0.0"); - expect(reason).toContain("requires ^24.10 || ^22.16"); - }); - - it("flags missing build tools without node when node is fine", () => { - const reason = formatMissingToolsReason( - { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, - "^24.10", - ); - expect(reason).toContain("g++"); - expect(reason).toContain("python3"); - expect(reason).toContain("build-essential"); - expect(reason).not.toContain("nvm"); - }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 25877552671ad..ca5b89c9d49fa 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -513,7 +513,7 @@ export interface ToolchainReport { readonly nodeVersion: string | null; } -export const parseToolchainReport = (stdout: string): ToolchainReport => { +const parseToolchainReport = (stdout: string): ToolchainReport => { const lines = stdout .split("\n") .map((line) => line.trim()) @@ -528,7 +528,7 @@ export const parseToolchainReport = (stdout: string): ToolchainReport => { return { missingTools, nodeVersion }; }; -export const parseNodePath = (stdout: string): string | null => { +const parseNodePath = (stdout: string): string | null => { const path = stdout .split("\n") .map((line) => line.trim()) @@ -538,7 +538,7 @@ export const parseNodePath = (stdout: string): string | null => { return path ?? null; }; -export const parseNodeVersion = (stdout: string): string | null => { +const parseNodeVersion = (stdout: string): string | null => { const version = stdout .split("\n") .map((line) => line.trim()) @@ -558,7 +558,7 @@ export const parseResolvedPath = (stdout: string): string | null => { return resolvedPath.length > 0 ? resolvedPath : null; }; -export const formatMissingToolsReason = ( +const formatMissingToolsReason = ( report: ToolchainReport, requiredRange: string | null, ): string | null => { @@ -888,7 +888,7 @@ const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")( }); }); -export const probeWslDistros: Effect.Effect< +const probeWslDistros: Effect.Effect< readonly WslDistro[], DesktopWslDistroListError, ChildProcessSpawner.ChildProcessSpawner diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 0faa1b29472a2..f267b7899c480 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 dd750164b381e..0a814229665dd 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 893d6f8e076f1..4404dddbaa3f5 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 (tsc-rs) rewrites the tsbuildinfo it reads, which keeps a task out of the cache. + typecheck: { + command: "tsc --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 5ba3da4fba109..15a8a8f916333 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 ca0582be6009c..36dc282e89b47 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 0a393b4758a04..0000000000000 --- 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 0000000000000..f927357c438df --- /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/app.config.ts b/apps/mobile/app.config.ts index d1d0398e0942a..55451537ef5a5 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -226,7 +226,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.0.0", + version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.1.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay 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 33a1dcca0721d..0000000000000 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.36.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz new file mode 100644 index 0000000000000..071a4022a7b3f Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.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 0000000000000..5b466062932ae --- /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 1d57632eee5fc..0b44599016b13 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 d4f8312b72b3b..d2b7b9cb9e460 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 7a659cc510aee..919902b051ca8 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -7,13 +7,13 @@ 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 } -private struct ComposerSelectionPayload: Decodable { +private struct ComposerSelectionPayload: Decodable, Equatable { 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 @@ -470,6 +449,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var tokensNeedRebuild = false private var chipsNeedMeasuredWidth = false private var hasDeferredForcedRebuild = false + private var pendingContentOffset: CGPoint? + private var needsSelectionReveal = false + private var selectionBeforeFocusingTap: (selection: ComposerSelectionPayload, value: String)? let onComposerChange = EventDispatcher() let onComposerSelectionChange = EventDispatcher() @@ -485,7 +467,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - clipsToBounds = false + clipsToBounds = true + textView.clipsToBounds = true textView.delegate = self textView.textDropDelegate = self textView.backgroundColor = .clear @@ -541,6 +524,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro @objc private func openContext(_ recognizer: UITapGestureRecognizer) { guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else { textView.becomeFirstResponder() + if selectionBeforeFocusingTap != nil { + // UIKit places the caret from its own tap recognizer during this same touch. + DispatchQueue.main.async { [weak self] in + self?.restoreSelectionBeforeFocusingTap() + } + } return } openContext(index: index, attachment: attachment) @@ -579,7 +568,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - true + // The collapsed editor only shows the draft's first line, so a tap that expands an + // overflowing draft returns to the caret it was left at instead of moving it there. + let collapsedOverflow = !textView.isFirstResponder && !textView.isScrollEnabled && draftOverflowsBounds() + selectionBeforeFocusingTap = collapsedOverflow ? (sourceSelection(), value) : nil + return true } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { @@ -604,11 +597,27 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public override func layoutSubviews() { super.layoutSubviews() + let previousSize = textView.bounds.size textView.frame = bounds if chipsNeedMeasuredWidth, bounds.width > 0 { chipsNeedMeasuredWidth = false applyControlledDocument(force: true) } + if textView.isScrollEnabled, bounds.width > 0, bounds.height > 0 { + textView.layoutIfNeeded() + if let offset = pendingContentOffset { + let minY = -textView.adjustedContentInset.top + let maxY = max(minY, textView.contentSize.height - bounds.height + textView.adjustedContentInset.bottom) + textView.setContentOffset(CGPoint(x: offset.x, y: min(maxY, max(minY, offset.y))), animated: false) + pendingContentOffset = nil + } + if needsSelectionReveal || (previousSize != bounds.size && textView.isFirstResponder) { + needsSelectionReveal = false + if let selection = textView.selectedTextRange { + textView.scrollRectToVisible(textView.caretRect(for: selection.end), animated: false) + } + } + } let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding let placeholderY = textView.textContainerInset.top let placeholderWidth = max( @@ -653,10 +662,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? [] tokensNeedRebuild = true } + let previousSelection = sourceSelection() value = document.value requestedSelection = document.selection applyControlledDocument(force: tokensNeedRebuild) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) if tokensMatchCurrentValue() { tokensNeedRebuild = false } @@ -715,7 +726,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setScrollEnabled(_ scrollEnabled: Bool) { + guard textView.isScrollEnabled != scrollEnabled else { return } + if !scrollEnabled { + // Disabling scrolling discards UITextView's offset. Restore it after + // expansion has laid out the text, regardless of prop update order. + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } textView.isScrollEnabled = scrollEnabled + if scrollEnabled { + revealSelectionAfterLayout() + } } func setAutoFocus(_ autoFocus: Bool) { @@ -759,8 +779,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setSelection(start: Int, end: Int) { + let previousSelection = sourceSelection() requestedSelection = ComposerSelectionPayload(start: start, end: end) applyRequestedSelection() + cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection) } public func textViewDidChange(_ textView: UITextView) { @@ -832,6 +854,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } public func textViewDidBeginEditing(_ textView: UITextView) { + revealSelectionAfterLayout() onComposerFocus() } @@ -857,6 +880,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro hasDeferredForcedRebuild = false let previousSelection = sourceSelection() + if textView.isScrollEnabled { + pendingContentOffset = pendingContentOffset ?? textView.contentOffset + } isApplyingControlledValue = true textView.attributedText = makeAttributedDocument() let targetSelection = requestedSelection ?? previousSelection @@ -864,6 +890,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.selectedRange = displayRange(for: targetSelection) restoreBaseTypingAttributes() isApplyingControlledValue = false + if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end { + revealSelectionAfterLayout() + } else { + setNeedsLayout() + } updatePlaceholderVisibility() emitContentSizeIfNeeded() } @@ -906,40 +937,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 +966,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 +1004,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? { @@ -1154,6 +1105,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return } value = textView.serializedText() + revealSelectionAfterLayout() let selection = sourceSelection() nativeEventCount += 1 onComposerChange([ @@ -1223,6 +1175,40 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false + revealSelectionAfterLayout() + } + + private func restoreSelectionBeforeFocusingTap() { + guard let snapshot = selectionBeforeFocusingTap else { + return + } + selectionBeforeFocusingTap = nil + // A draft that changed during the touch, such as a transcript landing, owns the caret now. + guard textView.isFirstResponder, textView.markedTextRange == nil, value == snapshot.value else { + return + } + let range = displayRange(for: snapshot.selection) + guard !NSEqualRanges(range, textView.selectedRange) else { + return + } + textView.selectedRange = range + revealSelectionAfterLayout() + } + + private func cancelFocusingTapRestoreIfSelectionMoved(from previousSelection: ComposerSelectionPayload) { + if sourceSelection() != previousSelection { + selectionBeforeFocusingTap = nil + } + } + + private func draftOverflowsBounds() -> Bool { + let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)) + return size.height > textView.bounds.height + 1 + } + + private func revealSelectionAfterLayout() { + needsSelectionReveal = true + setNeedsLayout() } private func updatePlaceholderVisibility() { @@ -1230,7 +1216,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitContentSizeIfNeeded() { - let nextSize = textView.contentSize + guard bounds.width > 0 else { return } + // Fit independently of the animated viewport: when scrolling is disabled, + // its current height can clip TextKit's laid-out line fragments. + let fittedHeight = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)).height + - textView.textContainerInset.top - textView.textContainerInset.bottom + let measuredHeight = min(lineHeight * 4, fittedHeight) + let width = fittedHeight <= lineHeight + 0.5 + ? textView.layoutManager.usedRect(for: textView.textContainer).width + : 0 + let nextSize = CGSize(width: ceil(width), height: ceil(measuredHeight)) guard abs(nextSize.width - lastContentSize.width) > 0.5 || abs(nextSize.height - lastContentSize.height) > 0.5 else { return diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png new file mode 100644 index 0000000000000..7443f8e0096dc Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png new file mode 100644 index 0000000000000..efc4cb3a093d5 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@2x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png new file mode 100644 index 0000000000000..ee63420dba56d Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/pull-request@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 936efe7676e95..b970936b1fe8f 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -69,6 +69,7 @@ final class T3LayoutMetricsView: ExpoView { } var verticalBarEdge = "none" var regions: [[String: Any]] = [] + var bottomBarInset: CGFloat? #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft @@ -77,6 +78,8 @@ final class T3LayoutMetricsView: ExpoView { case .trailing: verticalBarEdge = rtl ? "left" : "right" default: break } + // Where UIKit floats a bar as tall as the app's bottom controls, level with its tab bar. + bottomBarInset = edgeInsets(for: .bar(onEdge: UIRectEdge.bottom, extent: 48)).bottom for kind in [UIView.ReservedRegion.Kind.division, .occlusion] { for region in reservedRegions(kind: kind) where region.isActive { let frame = region.frame.intersection(bounds) @@ -90,7 +93,7 @@ final class T3LayoutMetricsView: ExpoView { } } #endif - let metrics: [String: Any] = [ + var metrics: [String: Any] = [ "width": bounds.width, "height": bounds.height, "hasHinge": hasHinge, "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact", @@ -101,6 +104,7 @@ final class T3LayoutMetricsView: ExpoView { ], "reservedRegions": regions, ] + metrics["bottomBarInset"] = bottomBarInset let next = metrics as NSDictionary guard lastMetrics?.isEqual(next) != true else { return } lastMetrics = next diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec index 29735c301cc0e..c32f88b0dacb3 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec @@ -15,5 +15,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', } + s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png'] } s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' end diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 479b5fed0ce51..81a892c8becf8 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -32,6 +32,14 @@ public final class T3NativeControlsModule: Module { Prop("appearance") { (view: T3SegmentedControlView, value: String) in view.appearance = value } } + View(T3SidebarTabBarView.self) { + ViewName("SidebarTabBar") + Events("onSelectionChange") + Prop("selectedIndex") { (view: T3SidebarTabBarView, value: Int) in view.selectedIndex = value } + Prop("selectedColor") { (view: T3SidebarTabBarView, value: UIColor) in view.selectedColor = value } + Prop("appearance") { (view: T3SidebarTabBarView, value: String) in view.appearance = value } + } + View(T3LayoutMetricsView.self) { ViewName("LayoutMetrics") Events("onMetricsChange") diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift new file mode 100644 index 0000000000000..3bfd730facd17 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -0,0 +1,60 @@ +import ExpoModulesCore +import UIKit + +final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { + let onSelectionChange = EventDispatcher() + private let tabBar = UITabBar() + var selectedIndex = 0 { didSet { updateSelection() } } + var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } } + var appearance = "light" { + didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light } + } + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) + let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) + tabBar.items = [ + UITabBarItem(title: "Threads", image: tabIcon(UIImage(systemName: "text.bubble")), tag: 0), + UITabBarItem(title: "Pulls", image: tabIcon(pullRequestIcon), tag: 1), + ] + tabBar.items?.last?.accessibilityLabel = "Pull requests" + tabBar.items?.first?.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) + tabBar.itemPositioning = .fill + tabBar.delegate = self + addSubview(tabBar) + updateSelection() + } + + override func layoutSubviews() { + super.layoutSubviews() + tabBar.frame = bounds + updateSelection() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateSelection() + } + + // Give symbols and bitmap assets the same image box; UIKit otherwise scales symbols independently. + private func tabIcon(_ image: UIImage?) -> UIImage? { + guard let image else { return nil } + let canvas = CGSize(width: 28, height: 28) + let scale = min(24 / image.size.width, 24 / image.size.height) + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + return UIGraphicsImageRenderer(size: canvas).image { _ in + image.draw(in: CGRect(x: (canvas.width - size.width) / 2, y: (canvas.height - size.height) / 2, width: size.width, height: size.height)) + }.withRenderingMode(.alwaysTemplate) + } + + private func updateSelection() { + guard let items = tabBar.items, items.indices.contains(selectedIndex) else { return } + guard tabBar.selectedItem !== items[selectedIndex] else { return } + tabBar.selectedItem = items[selectedIndex] + } + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + onSelectionChange(["index": item.tag]) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c7e8406bc1746..9f1a944485088 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -45,7 +45,7 @@ "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "test": "vp test run --project unit --project react", - "typecheck": "tsc-rs --noEmit" + "typecheck": "tsc --noEmit" }, "dependencies": { "@clerk/expo": "catalog:", @@ -134,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.36.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 388afbf3f67d7..c221c14ca4470 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 a225b7087c868..25ed7dd41d270 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 a304226c7f07b..0000000000000 --- 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 e7a059496af65..0000000000000 --- 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 f1851723550ac..0000000000000 --- 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/src/App.tsx b/apps/mobile/src/App.tsx index bfe5d1774a6bf..819f780ed7d3a 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -6,7 +6,7 @@ import { StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { createStaticNavigation } from "@react-navigation/native"; +import { createStaticNavigation, getStateFromPath } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; @@ -24,7 +24,7 @@ import { subscribeComposerDraftModelReconciliation } from "./state/use-composer- import { environmentThreadShells } from "./state/threads"; import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; -import { shouldHandleAppLink } from "./lib/appLinking"; +import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; @@ -44,6 +44,8 @@ const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], config: { initialRouteName: "Home" }, filter: shouldHandleAppLink, + getStateFromPath: (path: string, options: Parameters[1]) => + restoreDraftLinkState(getStateFromPath(path, options), path), }; const Navigation = createStaticNavigation(RootStack); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 5589ad3823504..d5a8a038fe435 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,11 +1,9 @@ +import { isDraftThreadRoute } from "./features/threads/thread-route-state"; import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen"; import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; -import { - createV5StackNavigator as createNativeStackNavigator, - createV5SheetStackNavigator, -} from "./native/createV5StackNavigator"; +import { createV5SheetStackNavigator } from "./native/createV5StackNavigator"; import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; import { createPathConfigForStaticNavigation, @@ -19,14 +17,7 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef, type ReactNode } from "react"; -import { - Platform, - Pressable, - ScrollView, - StyleSheet, - View, - useWindowDimensions, -} from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/ui/AppText"; @@ -71,6 +62,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"; @@ -81,7 +73,6 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; -import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; import { NewTaskBranchPickerRouteScreen, NewTaskEnvironmentPickerRouteScreen, @@ -148,7 +139,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; -import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; +import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; @@ -209,6 +200,15 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false }; +// Thread settings keep the Duo's vertical bar so cancel, search, filter and confirm sit in the +// rail. The picker stack spans the sheet so its header reaches the rail, and insets its content. +const THREAD_SETTINGS_SHEET_OPTIONS = { + presentation: "formSheet" as const, + sheetAllowedDetents: [1], + sheetGrabberVisible: true, + nativeContentInsetHorizontally: false, +}; + const SettingsContentStack = createV5SheetStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -367,9 +367,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), @@ -453,97 +451,6 @@ const SettingsSheetStack = createV5SheetStackNavigator({ // across two bars the whole screen slides). const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -const NewTaskSheetStack = createNativeStackNavigator({ - initialRouteName: "NewTask", - screenOptions: { - ...SHEET_GLASS_HEADER_OPTIONS, - contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, - // UIKit's default push adds a dimming shadow and independently transitions - // the navigation bar. Both read as mismatched sheet backgrounds here. - animation: Platform.OS === "ios" ? "simple_push" : undefined, - animationDuration: Platform.OS === "ios" ? 350 : undefined, - }, - screens: { - NewTask: createNativeStackScreen({ - screen: NewTaskRouteScreen, - linking: "", - options: { - title: "Choose project", - }, - }), - NewTaskDraft: createNativeStackScreen({ - screen: NewTaskDraftRouteScreen, - linking: "draft", - options: { - headerBackVisible: false, - title: "", - }, - }), - NewTaskEnvironment: createNativeStackScreen({ - screen: NewTaskEnvironmentPickerRouteScreen, - linking: "draft/environment", - options: { - title: "Environment", - }, - }), - NewTaskBranch: createNativeStackScreen({ - screen: NewTaskBranchPickerRouteScreen, - linking: "draft/branch", - options: { - title: "Branch", - }, - }), - NewTaskFile: createNativeStackScreen({ - screen: ThreadFileScreen, - linking: "draft/files/:path*", - options: SOLID_HEADER_OPTIONS, - }), - NewTaskAttachment: createNativeStackScreen({ - screen: AttachmentFileScreen, - linking: "draft/attachments/:attachmentId", - options: SOLID_HEADER_OPTIONS, - }), - ThreadSettings: createNativeStackScreen({ - screen: NewTaskThreadSettingsRouteScreen, - linking: "draft/settings", - options: { - gestureEnabled: true, - headerShown: false, - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, - }), - }, - }), - AddProject: createNativeStackScreen({ - screen: AddProjectSourceRoute, - linking: "add-project", - options: { - title: "Add Project", - }, - }), - AddProjectRepository: createNativeStackScreen({ - screen: AddProjectRepositoryRoute, - linking: "add-project/repository", - }), - AddProjectDestination: createNativeStackScreen({ - screen: AddProjectDestinationRoute, - linking: "add-project/destination", - }), - AddProjectLocal: createNativeStackScreen({ - screen: AddProjectLocalRoute, - linking: "add-project/local", - }), - AddProjectNew: createNativeStackScreen({ - screen: AddProjectNewRoute, - linking: "add-project/new", - }), - }, -}); - const WORKSPACE_OVERLAY_ROUTES = new Set([ "PullRequestReview", "PullRequestInlineComment", @@ -554,10 +461,22 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "GitCommit", "GitConfirm", "GitOverview", - "NewTaskSheet", + "NewTaskProject", + "NewTaskFiles", + "NewTaskFile", + "NewTaskAttachment", + "NewTaskThreadSettings", + "AddProject", + "AddProjectRepository", + "AddProjectDestination", + "AddProjectLocal", + "AddProjectNew", + "NewTaskEnvironment", + "NewTaskBranch", "SettingsLegal", "SettingsSheet", "ThreadAgents", + "ThreadBackgroundWork", "ThreadPullRequests", "ThreadQueue", "ThreadReviewComment", @@ -604,44 +523,58 @@ function RootStackLayout(props: { const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; const workspaceLocation = workspaceLocationFromState(props.state); + const draftRoute = props.state.routes.findLast((route) => route.name === "Thread"); + const draftParams = ( + draftRoute?.params as + | { draft?: { draftId?: string | string[]; pendingTaskId?: string | string[] } } + | undefined + )?.draft; + const routeDraftId = Array.isArray(draftParams?.draftId) + ? draftParams.draftId[0] + : draftParams?.draftId; + const routePendingTaskId = Array.isArray(draftParams?.pendingTaskId) + ? draftParams.pendingTaskId[0] + : draftParams?.pendingTaskId; useAgentNotificationNavigation(workspaceLocation.pathname); useConnectOnboardingNavigation(); useAppShortcuts(props.state); useEffect(() => { - const topRouteName = props.state.routes[props.state.index]?.name; const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { - isShareSheetPresented: topRouteName === "NewTaskSheet", + isShareSheetPresented: isDraftThreadRoute(props.state.routes[props.state.index]), pendingShareId: pendingShare?.id ?? null, }); sharePresentationRef.current = transition.state; if (!transition.shareIdToPresent) { return; } - navigation.navigate("NewTaskSheet", { - screen: "NewTask", - params: { incomingShareId: transition.shareIdToPresent }, - }); + navigation.navigate("Thread", { draft: { incomingShareId: transition.shareIdToPresent } }); }, [navigation, pendingShare, props.state]); return ( - - - - - - - - {props.children} - - - - - - + + + + + + + + + {props.children} + + + + + + + ); } @@ -732,8 +665,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestFilesSheet, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6), sheetGrabberVisible: true, sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, @@ -742,8 +675,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestReviewersScreen, options: { ...DETAIL_HEADER_OPTIONS, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, headerBackVisible: false, }, @@ -757,8 +690,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: { ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : { ...SHEET_GLASS_HEADER_OPTIONS, ...FORM_SHEET_PRESENTATION_OPTIONS }), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.75, 0.92], + : { ...SHEET_GLASS_HEADER_OPTIONS, ...HORIZONTAL_FORM_SHEET_OPTIONS }), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.75), sheetGrabberVisible: Platform.OS !== "android", }, }), @@ -766,16 +699,86 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestReviewSheet, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.75, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, }, }), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, - linking: THREAD_LINKING_PREFIX, + linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] }, options: DETAIL_HEADER_OPTIONS, }), + NewTaskProject: createNativeStackScreen({ + screen: NewTaskRouteScreen, + linking: { path: "new", alias: ["new/project"] }, + options: { ...GLASS_HEADER_OPTIONS, title: "Choose project" }, + }), + NewTaskEnvironment: createNativeStackScreen({ + linking: "new/draft/environment", + screen: NewTaskEnvironmentPickerRouteScreen, + options: { ...GLASS_HEADER_OPTIONS, title: "Environment" }, + }), + NewTaskBranch: createNativeStackScreen({ + linking: "new/draft/branch", + screen: NewTaskBranchPickerRouteScreen, + options: { ...GLASS_HEADER_OPTIONS, title: "Branch" }, + }), + AddProject: createNativeStackScreen({ + linking: "new/add-project", + screen: AddProjectSourceRoute, + + options: { + ...GLASS_HEADER_OPTIONS, + title: "Add project", + }, + }), + AddProjectRepository: createNativeStackScreen({ + linking: "new/add-project/repository", + screen: AddProjectRepositoryRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectDestination: createNativeStackScreen({ + linking: "new/add-project/destination", + screen: AddProjectDestinationRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectLocal: createNativeStackScreen({ + linking: "new/add-project/local", + screen: AddProjectLocalRoute, + options: GLASS_HEADER_OPTIONS, + }), + AddProjectNew: createNativeStackScreen({ + linking: "new/add-project/new", + screen: AddProjectNewRoute, + options: GLASS_HEADER_OPTIONS, + }), + NewTaskFiles: createNativeStackScreen({ + linking: "new/draft/files", + screen: ThreadFilesTreeScreen, + options: SOLID_HEADER_OPTIONS, + }), + NewTaskFile: createNativeStackScreen({ + linking: "new/draft/files/:path*", + screen: ThreadFileScreen, + options: SOLID_HEADER_OPTIONS, + }), + NewTaskAttachment: createNativeStackScreen({ + screen: AttachmentFileScreen, + linking: "new/draft/attachments/:attachmentId", + options: SOLID_HEADER_OPTIONS, + }), + NewTaskThreadSettings: createNativeStackScreen({ + screen: NewTaskThreadSettingsRouteScreen, + linking: "new/draft/settings", + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : THREAD_SETTINGS_SHEET_OPTIONS), + }, + }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, linking: `${THREAD_LINKING_PREFIX}/terminal`, @@ -814,8 +817,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ // formSheet; use a full-screen modal there instead. ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), - sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], + : HORIZONTAL_FORM_SHEET_OPTIONS), + sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.55), sheetGrabberVisible: Platform.OS !== "android", }, }), @@ -854,27 +857,34 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, + ...THREAD_SETTINGS_SHEET_OPTIONS, }), }, }), ThreadQueue: createNativeStackScreen({ screen: ThreadQueueSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.65, 0.95], + sheetAllowedDetents: formSheetDetents(0.65, 0.95), + sheetGrabberVisible: true, + }, + }), + ThreadBackgroundWork: createNativeStackScreen({ + screen: ThreadBackgroundWorkSheet, + options: { + ...HORIZONTAL_FORM_SHEET_OPTIONS, + headerShown: false, + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), ThreadAgents: createNativeStackScreen({ screen: ThreadAgentsSheet, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, headerShown: false, - sheetAllowedDetents: [0.5, 0.9], + sheetAllowedDetents: formSheetDetents(0.5, 0.9), sheetGrabberVisible: true, }, }), @@ -882,8 +892,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitOverviewSheet, linking: `${THREAD_LINKING_PREFIX}/git`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -891,8 +902,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: ThreadPullRequestsSheet, linking: `${THREAD_LINKING_PREFIX}/pull-requests`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -901,8 +913,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/git/commit`, options: { ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.8, 0.92], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.8), sheetGrabberVisible: true, }, }), @@ -910,8 +922,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitBranchesSheet, linking: `${THREAD_LINKING_PREFIX}/git/branches`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.55, 0.92], + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.55), sheetGrabberVisible: true, }, }), @@ -919,8 +932,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, options: { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.45, 0.7], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, }), @@ -953,8 +966,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.6, 0.95], + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.6, 0.95), sheetGrabberVisible: true, }, }), @@ -966,7 +979,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...(Platform.OS === "android" ? { presentation: "card" as const, headerShown: false } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }), @@ -980,28 +993,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ // native header stack mounts without remounting the pairing form. ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), title: "Add Environment", - ...FORM_SHEET_PRESENTATION_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }, }), - NewTaskSheet: createNativeStackScreen({ - screen: NewTaskSheetStack, - linking: "new", - // A screen's layout replaces the navigator's screenLayout. - layout: ({ children, route }) => ( - - - {children} - - - ), - options: { - ...NESTED_NAVIGATOR_OPTIONS, - gestureEnabled: true, - headerShown: false, - }, - }), NotFound: createNativeStackScreen({ screen: NotFoundScreen, linking: "*", @@ -1041,22 +1037,38 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig const nativeMetrics = useNativeLayoutMetrics(); const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView; + const flowPresentations = useRef(new Map()); return ( } + screenListeners={{ + state: ({ data }) => { + const keys = new Set(data.state.routes.map((route) => route.key)); + for (const key of flowPresentations.current.keys()) { + if (!keys.has(key)) flowPresentations.current.delete(key); + } + }, + }} screenOptions={({ route }) => { - if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { + if (route.name !== "SettingsSheet") { return Platform.OS === "android" ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } : {}; } - return usesWorkspaceFlowScreens + // Moving an open flow between a sheet and a split column remounts its nested + // navigator. Keep its presentation until dismissal so folding preserves the settings flow. + let usesCard = flowPresentations.current.get(route.key); + if (usesCard === undefined) { + usesCard = usesWorkspaceFlowScreens; + flowPresentations.current.set(route.key, usesCard); + } + return usesCard ? { presentation: "card" } : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.92], + presentation: "formSheet", + sheetAllowedDetents: [1], sheetGrabberVisible: true, }; }} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 9cd2d85cef570..ebbfbe9881477 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -347,6 +347,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { onPressDocument={props.onPressDocument} /> 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={ + 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 d6bbf9d992943..74708eff8d8c0 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,7 +1,6 @@ import type { ComponentProps, ReactNode } from "react"; import { useCallback, useMemo, useState } from "react"; import { - Platform, Pressable, ScrollView, View, @@ -13,6 +12,8 @@ import { type ViewStyle, } from "react-native"; +import Animated, { FadeIn } from "react-native-reanimated"; + import { cn } from "../lib/cn"; import { AppText as Text } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; @@ -21,8 +22,9 @@ 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); export function ComposerInlineControl(props: { + readonly size?: "regular" | "large"; readonly accessibilityHint?: string; readonly accessibilityLabel?: string; readonly disabled?: boolean; @@ -30,6 +32,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 +43,76 @@ export function ComposerInlineControl(props: { readonly showChevron?: boolean; }) { const { scale, smallIconSize } = useAndroidControlSizing(); + // Keep the picker anchored to its parent while the editor changes height. + const animatesLabel = props.iconOnly !== undefined; + const iconSize = props.size === "large" ? 20 : smallIconSize; return ( - - {props.renderIcon ? ( - - {props.renderIcon(smallIconSize)} - - ) : props.icon ? ( - - ) : null} - - {props.label} - - {props.showChevron === false ? null : ( - - )} - + {props.renderIcon ? ( + + {props.renderIcon(iconSize)} + + ) : props.icon ? ( + + ) : null} + {props.iconOnly ? null : ( + + + {props.label} + + + )} + {!props.iconOnly && props.suffix ? ( + {props.suffix} + ) : null} + {props.showChevron === false ? null : ( + + )} + + ); } @@ -219,6 +244,7 @@ export function ComposerToolbarScroller(props: { } export function ComposerActionButton(props: { + readonly size?: "regular" | "large"; readonly accessibilityLabel: string; readonly disabled?: boolean; readonly icon: ComponentProps["name"]; @@ -230,13 +256,14 @@ export function ComposerActionButton(props: { readonly onTouchStart?: PressableProps["onTouchStart"]; }) { const { scale, smallIconSize } = useAndroidControlSizing(); - const circleSize = Math.round(30 * scale); + const circleSize = Math.round((props.size === "large" ? 44 : 30) * scale); return ( 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 621006d949c09..e3db494219d71 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/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 7e5e95da3beb5..16a0f55d0a7fc 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -9,6 +9,7 @@ import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; +import IconCalendarClock from "@tabler/icons-react-native/IconCalendarClock"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp"; @@ -178,6 +179,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "circle.dashed": IconCircleDashed, "circle.inset.filled": IconCircleDot, "circle.dashed.inset.filled": IconCircleDot, + "calendar.badge.clock": IconCalendarClock, clock: IconClock, timer: IconClock, ticket: IconTicket, diff --git a/apps/mobile/src/components/ui/NativeAppBar.android.tsx b/apps/mobile/src/components/ui/NativeAppBar.android.tsx index 2c028fded8038..22f8ca65cc425 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/Sheet.tsx b/apps/mobile/src/components/ui/Sheet.tsx index 21ee41a5c5569..56994803b4ab8 100644 --- a/apps/mobile/src/components/ui/Sheet.tsx +++ b/apps/mobile/src/components/ui/Sheet.tsx @@ -4,6 +4,7 @@ import { FormSheet, ScreenStack } from "react-native-screens"; import { useResolveClassNames } from "uniwind"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { formSheetDetents } from "../../native/sheet-surface"; import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -21,8 +22,10 @@ export function Sheet(props: SheetProps) { return ( = [ - { - 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 faa02c1982898..736ac27ed4eb7 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 4e8bdf1edf4a7..0000000000000 --- 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 2107d9305e936..4c88a03ccf2a0 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 6b0329aa27274..755c4f639d78b 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 59bb850a2016a..0000000000000 --- 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 a763917322391..0000000000000 --- 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 dd17b610bfad1..a546aa76beaac 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 2dd3ca03de296..92512bd2c3e87 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 def7443d543e7..0000000000000 --- 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 74c842be540f2..8886f0bf2720b 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 77e45963b8313..0000000000000 --- 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 51fa7fe91bffb..bb286a401fc8b 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 8277463dae3a3..0000000000000 --- 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 1e78f250ef4e4..13860d4bea178 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 9642e5f63ae2d..5a4befd993192 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 05bf1a8fbccdc..8c09819aa72f3 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 1293e78932359..eed95771b1a99 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 e573138beef36..0000000000000 --- 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 1939276847949..466fbfa7a282d 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/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 368c3286e3591..3e5899f870a33 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -27,6 +27,8 @@ import { findHeaderMenuAction, } from "../../components/ui/headerMenu.android"; import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; +import { deriveBottomControlInsets } from "../../lib/reserved-regions"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -75,6 +77,8 @@ function DevicePreviewScreen({ readonly onClose: () => void; }) { const insets = useSafeAreaInsets(); + // NativeColumnContent already insets the sides, so only a book fold moves the reveal handle. + const handleBounds = deriveBottomControlInsets(useNativeColumnLayoutMetrics()); const { themeVariables } = useAppearancePreferences(); const focused = useIsFocused(); const [foreground, setForeground] = useState(AppState.currentState !== "background"); @@ -318,7 +322,10 @@ function DevicePreviewScreen({ accessibilityHint="Shaking the phone also shows them" hitSlop={12} className="absolute h-6 w-16 items-center justify-center self-center" - style={{ top: Math.max(insets.top - 18, 4) }} + style={{ + top: Math.max(insets.top - 18, 4), + transform: [{ translateX: (handleBounds.left - handleBounds.right) / 2 }], + }} onPress={() => setControlsVisible(true)} > 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 cd833a75511cc..06e63d841561e 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(/", - '', - "", - ].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/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index 4ad26a4aa6838..d9339b9c46946 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -229,6 +229,13 @@ const launchBrowser = Effect.fnUntraced(function* (input: { (cause) => new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }), ), + // Node throws some spawn errors synchronously, such as `spawn UNKNOWN` + // when policy blocks the executable; `mapError` cannot catch those. + Effect.catchDefect((cause) => + Effect.fail( + new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }), + ), + ), ); let stderrTail = ""; diff --git a/apps/server/src/htmlRender/publicProxy.test.ts b/apps/server/src/htmlRender/publicProxy.test.ts index 15c1e7f369ad3..12d0dd65ad342 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 ddb4b25d707d4..48c75a1240351 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 6848ec492010f..0016175111e7a 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -280,7 +280,7 @@ export const layerBrowserApiCors = Layer.unwrap( }), ); -export function isLoopbackHostname(hostname: string): boolean { +function isLoopbackHostname(hostname: string): boolean { const normalizedHostname = hostname .trim() .toLowerCase() @@ -288,7 +288,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; @@ -586,6 +586,14 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( const staticDir = config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined); + if (!staticDir && config.devUrl) { + // `vp run dev --share --built` prints the pairing URL before its first + // web build lands in apps/web/dist. Reload until it does. + return HttpServerResponse.text( + '

Waiting for the first web build. This page reloads by itself.

', + { status: 503, contentType: "text/html", headers: { "Retry-After": "2" } }, + ); + } if (!staticDir) { return HttpServerResponse.text("No static directory configured and no dev URL set.", { status: 503, diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index 89f509a9f0338..ae23fc37a08e8 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 f9c83400edbcb..5d427b957265a 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/issues/IssueInboxMcpTools.ts b/apps/server/src/issues/IssueInboxMcpTools.ts index 5670a9ac177db..735e8e6d11cc6 100644 --- a/apps/server/src/issues/IssueInboxMcpTools.ts +++ b/apps/server/src/issues/IssueInboxMcpTools.ts @@ -1,3 +1,4 @@ +import * as Option from "effect/Option"; import { IssueInboxToolInput, IssueInboxToolResult, @@ -15,7 +16,6 @@ import * as IssueInbox from "./IssueInbox.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, - IssueInbox.IssueInbox, ThreadManagementService.ThreadManagementService, ]; @@ -65,13 +65,17 @@ const caller = Effect.gen(function* () { export const issueInboxHandlers = { t3_issue_post: McpToolAccess.actsAsCaller((input: IssuePostInput) => Effect.gen(function* () { - const inbox = yield* IssueInbox.IssueInbox; + const available = yield* Effect.serviceOption(IssueInbox.IssueInbox); + if (Option.isNone(available)) return yield* new OrchestratorMcpFailure({ code: "orchestration_error", message: "Issue tools are unavailable in this runtime." }); + const inbox = available.value; return yield* inbox.post(yield* caller, input).pipe(Effect.mapError(failure)); }), ), t3_issue_inbox: McpToolAccess.actsAsCaller((input: IssueInboxToolInput) => Effect.gen(function* () { - const inbox = yield* IssueInbox.IssueInbox; + const available = yield* Effect.serviceOption(IssueInbox.IssueInbox); + if (Option.isNone(available)) return yield* new OrchestratorMcpFailure({ code: "orchestration_error", message: "Issue tools are unavailable in this runtime." }); + const inbox = available.value; return yield* inbox.forCaller(yield* caller, input).pipe(Effect.mapError(failure)); }), ), diff --git a/apps/server/src/issues/IssueMcpTools.ts b/apps/server/src/issues/IssueMcpTools.ts index 105a264d0c2a5..039b1c82d5ccb 100644 --- a/apps/server/src/issues/IssueMcpTools.ts +++ b/apps/server/src/issues/IssueMcpTools.ts @@ -1,3 +1,4 @@ +import * as Option from "effect/Option"; import { IssueLinkInput, IssueLinkResult, @@ -14,7 +15,6 @@ import * as McpToolAccess from "../mcp/McpToolAccess.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, - IssueLinks.IssueLinks, ThreadManagementService.ThreadManagementService, ]; export const LinkIssueTool = Tool.make("link_issue", { @@ -48,7 +48,9 @@ export const issueLinkHandlers = { (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; - const links = yield* IssueLinks.IssueLinks; + const available = yield* Effect.serviceOption(IssueLinks.IssueLinks); + if (Option.isNone(available)) return yield* new OrchestratorMcpFailure({ code: "orchestration_error", message: "Issue tools are unavailable in this runtime." }); + const links = available.value; return yield* links.link(input, scope.thread?.threadId); }), ), @@ -57,7 +59,9 @@ export const issueLinkHandlers = { (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; - const links = yield* IssueLinks.IssueLinks; + const available = yield* Effect.serviceOption(IssueLinks.IssueLinks); + if (Option.isNone(available)) return yield* new OrchestratorMcpFailure({ code: "orchestration_error", message: "Issue tools are unavailable in this runtime." }); + const links = available.value; return yield* links.link(input, scope.thread?.threadId, true); }), ), diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index e2823551d9955..80d4a52625ed7 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 43c3e381f6fb4..1482804c61e0e 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 5734d613bf524..0368c89714208 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 f61d20b2cfd29..2db36ce5abc28 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -6,6 +6,7 @@ import * as NodeReadline from "node:readline"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; @@ -53,96 +54,125 @@ function protocolVersionOf(entry: unknown): string | null { return typeof version === "string" && version.length > 0 ? version : null; } -export interface AcpMcpToolCallOptions { +export interface McpToolSessionOptions { readonly endpoint: string; readonly authorization: string; - readonly tool: string; - readonly arguments: Readonly>; + readonly clientName: string; readonly fetchImplementation?: (url: string, init?: RequestInit) => Promise; } +export interface McpToolSession { + /** Resolves to the JSON-RPC `result` of `tools/call`, a `CallToolResult`. */ + readonly call: ( + tool: string, + args: Readonly>, + ) => Effect.Effect; +} + /** - * This is the terminal fallback for ACP agents that accept `mcpServers` in - * `session/new` but fail to expose those tools to their model. Compliant ACP - * agents continue to use the stdio bridge above. + * One initialized MCP session over T3 Code's HTTP endpoint, terminated when + * the scope closes. Used by the ACP terminal fallback and `t3 thread`. */ -export function callAcpMcpTool( - options: AcpMcpToolCallOptions, -): Effect.Effect { +export const mcpToolSession = ( + options: McpToolSessionOptions, +): Effect.Effect => { const fetchImplementation = options.fetchImplementation ?? fetch; - return Effect.gen(function* () { - let sessionId: string | null = null; - let protocolVersion: string | null = null; + let sessionId: string | null = null; + let protocolVersion: string | null = null; + let nextId = 0; - const send = Effect.fn("AcpMcpStdioBridge.send")(function* ( - message: unknown, - ): Effect.fn.Return, AcpMcpBridgeError> { - const response = yield* Effect.tryPromise({ - try: () => - fetchImplementation(options.endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - authorization: options.authorization, - ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), - ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), - }, - body: JSON.stringify(message), - }), - catch: bridgeError, - }); - sessionId = response.headers.get("mcp-session-id") ?? sessionId; - if (!response.ok) { - yield* discardResponseBody(response); - return yield* Effect.fail( - new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), - ); - } - const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); - for (const payload of payloads) { - protocolVersion = protocolVersionOf(payload) ?? protocolVersion; - } - return payloads; - }); + const headers = () => ({ + accept: "application/json, text/event-stream", + authorization: options.authorization, + ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), + ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), + }); - const initializeId = "t3-acp-cli-initialize"; - const initialized = yield* send({ - jsonrpc: "2.0", - id: initializeId, - method: "initialize", - params: { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "t3-code-acp-cli", version: "0.0.0" }, - }, + const send = Effect.fn("AcpMcpStdioBridge.send")(function* ( + message: unknown, + ): Effect.fn.Return, AcpMcpBridgeError> { + const response = yield* Effect.tryPromise({ + try: () => + fetchImplementation(options.endpoint, { + method: "POST", + headers: { "content-type": "application/json", ...headers() }, + body: JSON.stringify(message), + }), + catch: bridgeError, }); - const initializeResponse = initialized.find((entry) => asEnvelope(entry)?.id === initializeId); - if (initializeResponse === undefined || asEnvelope(initializeResponse)?.error !== undefined) { + sessionId = response.headers.get("mcp-session-id") ?? sessionId; + if (!response.ok) { + yield* discardResponseBody(response); return yield* Effect.fail( - new AcpMcpBridgeError("T3 Code MCP endpoint rejected initialization."), + new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), ); } - yield* send({ jsonrpc: "2.0", method: "notifications/initialized" }); + const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); + for (const payload of payloads) { + protocolVersion = protocolVersionOf(payload) ?? protocolVersion; + } + return payloads; + }); - const callId = "t3-acp-cli-tool-call"; - const responses = yield* send({ - jsonrpc: "2.0", - id: callId, - method: "tools/call", - params: { name: options.tool, arguments: options.arguments }, - }); - const response = responses.find((entry) => asEnvelope(entry)?.id === callId); - const envelope = asEnvelope(response); + const request = Effect.fn("AcpMcpStdioBridge.request")(function* ( + method: string, + params: unknown, + ): Effect.fn.Return { + const id = `t3-cli-${nextId++}`; + const responses = yield* send({ jsonrpc: "2.0", id, method, params }); + const envelope = asEnvelope(responses.find((entry) => asEnvelope(entry)?.id === id)); if (envelope === null || envelope.error !== undefined) { return yield* Effect.fail( new AcpMcpBridgeError( - `T3 Code MCP tool call failed${envelope?.error === undefined ? "." : `: ${JSON.stringify(envelope.error)}`}`, + `T3 Code MCP ${method} failed${envelope?.error === undefined ? "." : `: ${JSON.stringify(envelope.error)}`}`, ), ); } - return envelope.result; + return envelope; + }); + + const terminate = Effect.suspend(() => + sessionId === null + ? Effect.void + : Effect.tryPromise(() => + fetchImplementation(options.endpoint, { method: "DELETE", headers: headers() }), + ).pipe(Effect.flatMap(discardResponseBody), Effect.ignore), + ); + + // Registered before the handshake so a session the server opened is ended + // even when the handshake fails, and the handshake stays interruptible. + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => terminate); + yield* request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: options.clientName, version: "0.0.0" }, + }); + yield* send({ jsonrpc: "2.0", method: "notifications/initialized" }); + return { + call: (tool, args) => + request("tools/call", { name: tool, arguments: args }).pipe( + Effect.map((envelope) => envelope.result), + ), + } satisfies McpToolSession; }); +}; + +/** + * This is the terminal fallback for ACP agents that accept `mcpServers` in + * `session/new` but fail to expose those tools to their model. Compliant ACP + * agents continue to use the stdio bridge above. + */ +function callAcpMcpTool(options: { + readonly endpoint: string; + readonly authorization: string; + readonly tool: string; + readonly arguments: Readonly>; +}): Effect.Effect { + return mcpToolSession({ ...options, clientName: "t3-code-acp-cli" }).pipe( + Effect.flatMap((session) => session.call(options.tool, options.arguments)), + Effect.scoped, + ); } export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect.Effect { diff --git a/apps/server/src/mcp/IssueReviewDelegation.fork.test.ts b/apps/server/src/mcp/IssueReviewDelegation.fork.test.ts index 8cdedc71dc6af..47922803c38f4 100644 --- a/apps/server/src/mcp/IssueReviewDelegation.fork.test.ts +++ b/apps/server/src/mcp/IssueReviewDelegation.fork.test.ts @@ -1,13 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, vi } from "@effect/vitest"; -import { - CommandId, - EnvironmentId, - EventId, - ProviderSessionId, - resolveThreadRole, - type ThreadId, -} from "@t3tools/contracts"; +import { CommandId, EnvironmentId, EventId, ProviderSessionId, resolveThreadRole, type ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; @@ -25,14 +18,7 @@ import { ServerSettingsService } from "../serverSettings.ts"; import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; -import { - createReviewRoot, - delegateReviewIssue, - modelSelection, - reviewHarness, - reviewIssue, - reviewRead, -} from "../orchestration-v2/ThreadLaunchService.testHarness.ts"; +import { createReviewRoot, delegateReviewIssue, modelSelection, reviewHarness, reviewIssue, reviewRead } from "../orchestration-v2/ThreadLaunchService.testHarness.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -436,41 +422,9 @@ it.effect( clientRequestId: CommandId.make("resume-finished-owner"), }); assert.isEmpty(result.orchestration!.suspendedIssues); - const cancelled = (yield* threads.getThreadRecords(owner, ["subagents"])).subagents.find( - (task) => task.id === child.taskId, - )!; - yield* sink.write({ - events: [ - { - id: EventId.make("cancelled-child-pending-delivery"), - type: "subagent.updated", - threadId: owner, - nodeId: child.taskId, - occurredAt: now, - payload: { - ...cancelled, - completionDelivery: { state: "pending", observedByRunId: null }, - }, - }, - ], - }); - yield* work.reconcile(root); - const suspended = (yield* store.configuration(root))!; - assert.lengthOf(suspended.suspendedIssues, 1); - yield* work.closeOut({ - host: "github.com", - repository: "tannerpolley/t3code", - issueId: reviewIssue(1).id, - expectedRevision: suspended.revision, - clientRequestId: CommandId.make("finish-cancelled-child-owner"), - }); - const finished = yield* threads.getThreadRecords(owner, ["subagents"]); - assert.equal( - finished.subagents.find((task) => task.id === child.taskId)!.completionDelivery?.state, - "disposed", - ); const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; yield* worker.drain(); + assert.isTrue(yield* store.resultAvailable(owner)); yield* work.setPaused({ host: "github.com", repository: "tannerpolley/t3code", diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 87801ea736887..967f9e138c1b2 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 4f51f8e79b69a..dba3a4d1f75d1 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 ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; @@ -45,7 +37,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 = { @@ -189,18 +180,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) => @@ -253,56 +232,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 }, @@ -440,80 +369,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* () { @@ -532,45 +387,51 @@ 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 }); +it.effect( + "clamps t3_thread_read page sizes above their maximum instead of rejecting the call", + () => { + const calls: Array = []; + return Effect.gen(function* () { + const thread = { threadId: "thread-clamped-arguments" }; + // Codex never shows the model these bounds, so an over-large page must still read. + yield* callTool("t3_thread_read", { + ...thread, + limit: 150, + runLimit: 80, + maxCharsPerItem: 60_000, + }); + yield* callTool("t3_thread_read", { + ...thread, + limit: 100, + runLimit: 50, + maxCharsPerItem: 50_000, + }); + yield* callTool("t3_thread_read", { ...thread, limit: 7, runLimit: 1, maxCharsPerItem: 1 }); - expect(snapshot.isError).toBe(true); - expect(snapshot.content).toEqual([ - { type: "text", text: "Preview snapshot failed: PreviewScreenshotSaveError." }, + expect(calls).toEqual([ + { ...thread, limit: 100, runLimit: 50, maxCharsPerItem: 50_000 }, + { ...thread, limit: 100, runLimit: 50, maxCharsPerItem: 50_000 }, + { ...thread, limit: 7, runLimit: 1, maxCharsPerItem: 1 }, ]); - expect(snapshot.structuredContent).toEqual({ - error: { _tag: "PreviewScreenshotSaveError", operation: "snapshot", failureCount: 1 }, - }); - }), - ).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provide(layerRecordingTools(calls))); + }, +); + +it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( + "still rejects a t3_thread_read %s below its minimum", + (field) => { + const calls: Array = []; + return Effect.gen(function* () { + // Zero is a different request than one item, so it fails rather than answering another question. + const rejected = yield* callTool("t3_thread_read", { + threadId: "thread-below-minimum", + [field]: 0, + }).pipe(Effect.flip); + + expect(String(rejected)).toContain("greater than or equal to 1"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layerRecordingTools(calls))); + }, ); it.effect( @@ -604,24 +465,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* () { @@ -711,6 +554,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* () { @@ -743,6 +587,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* () { @@ -781,71 +626,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* () { @@ -921,155 +701,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 5f67b1f53dd4f..602539bf6587d 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -141,7 +141,7 @@ type McpAuthMiddleware = ( HttpServerRequest.HttpServerRequest >; -export const normalizeMcpHttpResponse = ( +const normalizeMcpHttpResponse = ( response: HttpServerResponse.HttpServerResponse, ): HttpServerResponse.HttpServerResponse => { const bodyIsEmpty = @@ -1028,7 +1028,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 e3bc3face4110..94059d4fd8c53 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 94f6aa6777994..282c3dffd6d64 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 ae05529cd250d..04c33a1509e94 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 52b8523ad56d9..b02ef26374848 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 46371724119a8..7bd0d66fa0406 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.fork.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.fork.test.ts new file mode 100644 index 0000000000000..f5b1669d8e8f1 --- /dev/null +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.fork.test.ts @@ -0,0 +1,414 @@ +import { + EnvironmentId, + NodeId, + type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +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 NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "vite-plus/test"; + +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +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 * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadToolkitHandlers from "./toolkits/thread/handlers.ts"; +import { ThreadToolkit } from "./toolkits/thread/tools.ts"; +import * as AttachmentToolkitHandlers from "./toolkits/attachment/handlers.ts"; +import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; +import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; + +const layerOrchestratorService = OrchestratorMcpService.layer.pipe( + Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), +); + +const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail"); +const projectId = ProjectId.make("project-mcp-orchestrator-detail"); +const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent"); +const childThreadId = ThreadId.make("thread-mcp-orchestrator-child"); +const activeRunId = RunId.make("run-mcp-active"); +const childRunId = RunId.make("run-mcp-child"); +const taskId = NodeId.make("node-mcp-task-1"); +const now = DateTime.makeUnsafe("2026-08-04T12:00:00.000Z"); +const codexDriver = ProviderDriverKind.make("codex"); + +const customCodexInstanceId = ProviderInstanceId.make("codex-custom-workspace"); +const parentInstanceId = ProviderInstanceId.make("codex"); + +const makeScope = (): McpInvocationContext.McpInvocationScope => ({ + environmentId, + requestNamespace: "provider-session-mcp-orchestrator-detail", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session-mcp-orchestrator-detail", + providerInstanceId: parentInstanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, +}); + +function baseThread(input: { + readonly threadId: ThreadId; + readonly title: string; + readonly instanceId: ProviderInstanceId; + readonly model: string; +}) { + return { + id: input.threadId, + projectId, + title: input.title, + createdBy: "user" as const, + creationSource: "mcp" as const, + modelSelection: { + instanceId: input.instanceId, + model: input.model, + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: input.threadId, + }, + archivedAt: null, + deletedAt: null, + providerInstanceId: input.instanceId, + createdAt: now, + updatedAt: now, + }; +} + +function makeRun(input: { + readonly id: RunId; + readonly ordinal: number; + readonly status: "running" | "waiting" | "cancelled" | "queued" | "completed"; + readonly instanceId?: ProviderInstanceId; +}) { + return { + id: input.id, + ordinal: input.ordinal, + status: input.status, + modelSelection: { + instanceId: input.instanceId ?? parentInstanceId, + model: "gpt-5.4", + }, + providerInstanceId: input.instanceId ?? parentInstanceId, + requestedAt: now, + startedAt: input.status === "cancelled" || input.status === "queued" ? null : now, + completedAt: input.status === "cancelled" || input.status === "completed" ? now : null, + }; +} + +it("taskStatus reports a child waiting on the orchestrator, but not an auth refresh", async () => { + const parentProjection = { + thread: baseThread({ + threadId: parentThreadId, + title: "Parent", + instanceId: parentInstanceId, + model: "gpt-5.4", + }), + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })], + visibleTurnItems: [], + runtimeRequests: [], + messages: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + runId: activeRunId, + parentNodeId: NodeId.make("node-parent"), + origin: "app_owned", + createdBy: "agent", + driver: codexDriver, + providerInstanceId: customCodexInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Inspect the custom instance.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + ], + updatedAt: now, + } as unknown as OrchestrationV2ThreadProjection; + + const childProjection = { + thread: { + ...baseThread({ + threadId: childThreadId, + title: "Child", + instanceId: customCodexInstanceId, + model: "gpt-5.4", + }), + lineage: { + parentThreadId, + relationshipToParent: "subagent", + rootThreadId: parentThreadId, + }, + createdBy: "agent", + }, + runs: [ + makeRun({ + id: childRunId, + ordinal: 1, + status: "running", + instanceId: customCodexInstanceId, + }), + ], + visibleTurnItems: [], + runtimeRequests: [ + { + id: "request-mcp-question", + responseCapability: { type: "live" }, + kind: "user_input", + status: "pending", + routedTo: parentThreadId, + }, + { + id: "request-mcp-auth", + responseCapability: { type: "live" }, + kind: "auth_refresh", + status: "pending", + }, + ], + turnItems: [ + { + type: "user_input_request", + requestId: "request-mcp-question", + questions: [{ id: "q1", header: "Scope", question: "Fix the lexer too?", options: [] }], + }, + ], + messages: [], + contextTransfers: [ + { + type: "subagent_spawn", + sourceThreadId: parentThreadId, + targetThreadId: childThreadId, + targetRunId: childRunId, + }, + ], + subagents: [], + providerThreads: [], + updatedAt: now, + } as unknown as OrchestrationV2ThreadProjection; + + const layer = layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), + getThreadRecords: (threadId) => { + if (threadId === parentThreadId) return Effect.succeed(parentProjection); + if (threadId === childThreadId) return Effect.succeed(childProjection); + return Effect.die(`unexpected thread ${threadId}`); + }, + } satisfies Partial), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + } satisfies Partial), + 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.taskStatus(makeScope(), taskId); + expect(result.status).toBe("running"); + expect(result.waitingOnUser).toEqual({ + kind: "input", + requestIds: ["request-mcp-question"], + waitingOn: "orchestrator", + preview: expect.stringContaining("Fix the lexer too?"), + }); + }).pipe(Effect.provide(layer), Effect.runPromise); +}); + +it("refuses delegated child messages, attachments, and queue sends to ancestors", async () => { + const rootId = ThreadId.make("thread-mcp-lineage-root"); + const childId = ThreadId.make("thread-mcp-lineage-child"); + const grandchildId = ThreadId.make("thread-mcp-lineage-grandchild"); + const unrelatedId = ThreadId.make("thread-mcp-lineage-unrelated"); + const subagentOf = (parentId: ThreadId) => ({ + parentThreadId: parentId, + relationshipToParent: "subagent" as const, + rootThreadId: rootId, + }); + const projections = new Map( + ( + [ + [rootId, null], + [childId, subagentOf(rootId)], + [grandchildId, subagentOf(childId)], + [unrelatedId, null], + ] as const + ).map(([threadId, lineage]) => { + const thread = baseThread({ + threadId, + title: threadId, + instanceId: parentInstanceId, + model: "gpt-5.4", + }); + return [ + threadId, + { + thread: { ...thread, activeRunId, ...(lineage === null ? {} : { lineage }) }, + runs: [makeRun({ id: RunId.make(`run-${threadId}`), ordinal: 1, status: "running" })], + runtimeRequests: [], + messages: [], + contextTransfers: [], + subagents: [], + } as unknown as OrchestrationV2ThreadProjection, + ] as const; + }), + ); + const scopeFor = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({ + ...makeScope(), + thread: { ...makeScope().thread!, threadId }, + }); + + let dispatches = 0; + const layer = layerOrchestratorService.pipe( + Layer.provideMerge( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: () => { + dispatches++; + return Effect.succeed({ sequence: 1, storedEvents: [] }); + }, + getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), + getThreadShell: (threadId) => + Effect.succeed( + (projections.get(threadId)?.thread ?? null) as OrchestrationV2ThreadShell | null, + ), + getProjectThreadRecords: (input) => Effect.succeed(projections.get(input.threadId)!), + sendToThread: (input) => + Effect.succeed({ + run: { id: `run-sent-${input.threadId}`, status: "queued" }, + delivery: "started", + } as unknown as ThreadManagementService.ThreadManagementSendResult), + } satisfies Partial), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ThreadSearch.ThreadSearch)({}), + Layer.mock(Orchestrator.OrchestratorV2)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-ancestor-" }).pipe( + Layer.provide(NodeServices.layer), + ), + Layer.mock(ServerSecretStore.ServerSecretStore)({}), + NodeServices.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, scopeFor(childId)), + NodeCrypto.layer, + ), + ), + ); + + await Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const send = (from: ThreadId, to: ThreadId) => + service.sendToThread(scopeFor(from), { threadId: to, message: "progress update" }); + + const toParent = yield* send(childId, rootId).pipe(Effect.flip); + expect(toParent.code).toBe("ancestor_send_denied"); + expect(toParent.message).toContain("final message"); + expect(toParent.message).toContain("t3_request_user_input"); + const toGrandparent = yield* send(grandchildId, rootId).pipe(Effect.flip); + expect(toGrandparent.code).toBe("ancestor_send_denied"); + + expect((yield* send(childId, unrelatedId)).threadId).toBe(unrelatedId); + expect((yield* send(rootId, childId)).threadId).toBe(childId); + + const toolkit = yield* ThreadToolkit.pipe( + Effect.provide(McpToolAccess.HandlersLayer.layer(ThreadToolkitHandlers.layer)), + ); + const queueTarget = { threadId: rootId, queuedRunId: RunId.make("run-parent-queued") }; + const edited = yield* toolkit + .handle("t3_queue_edit", { ...queueTarget, text: "child-injected text" }) + .pipe(Stream.unwrap, Stream.runCollect); + expect(edited.at(-1)?.result).toMatchObject({ code: "ancestor_send_denied" }); + const promoted = yield* toolkit + .handle("t3_queue_promote_to_steer", { ...queueTarget, targetRunId: activeRunId }) + .pipe(Stream.unwrap, Stream.runCollect); + expect(promoted.at(-1)?.result).toMatchObject({ code: "ancestor_send_denied" }); + expect(dispatches).toBe(0); + + const attachments = yield* AttachmentToolkit.pipe( + Effect.provide(McpToolAccess.HandlersLayer.layer(AttachmentToolkitHandlers.layer)), + ); + const attachmentSend = yield* attachments + .handle("t3_thread_send_attachments", { + threadId: rootId, + message: "child-injected attachment", + attachments: [ + { + type: "file", + id: "attachment-1", + name: "report.txt", + mimeType: "text/plain", + sizeBytes: 1, + }, + ], + }) + .pipe(Stream.unwrap, Stream.runCollect); + expect(attachmentSend.at(-1)?.result).toMatchObject({ code: "ancestor_send_denied" }); + expect(dispatches).toBe(0); + + const unrelatedEdit = yield* toolkit + .handle("t3_queue_edit", { ...queueTarget, threadId: unrelatedId, text: "allowed message" }) + .pipe(Stream.unwrap, Stream.runCollect); + expect(unrelatedEdit.at(-1)?.result).toEqual({ sequence: 1 }); + expect(dispatches).toBe(1); + + // Corrupt lineage must terminate even when the target is outside the cycle. + const root = projections.get(rootId)!; + projections.set(rootId, { ...root, thread: { ...root.thread, lineage: subagentOf(childId) } }); + expect((yield* send(childId, unrelatedId)).threadId).toBe(unrelatedId); + expect((yield* send(grandchildId, rootId).pipe(Effect.flip)).code).toBe("ancestor_send_denied"); + }).pipe(Effect.provide(layer), Effect.runPromise); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 13972537b1b48..42716cd356f64 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -13,9 +13,7 @@ import { 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 NodeCrypto from "@effect/platform-node/NodeCrypto"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "vite-plus/test"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; @@ -27,18 +25,9 @@ import * as SecretRequests from "../secrets/SecretRequests.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 ServerConfig from "../config.ts"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; -import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import * as ThreadToolkitHandlers from "./toolkits/thread/handlers.ts"; -import { ThreadToolkit } from "./toolkits/thread/tools.ts"; -import * as AttachmentToolkitHandlers from "./toolkits/attachment/handlers.ts"; -import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; -import * as McpInvocationContext from "./McpInvocationContext.ts"; -import * as McpToolAccess from "./McpToolAccess.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)({})), @@ -192,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({ @@ -339,7 +263,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(null), +getThreadShell: () => Effect.succeed(null), getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => { if (threadId === parentThreadId) return Effect.succeed(parentProjection); @@ -615,433 +539,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 Orchestrator.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("taskStatus reports a child waiting on the orchestrator, but not an auth refresh", async () => { - const parentProjection = { - thread: baseThread({ - threadId: parentThreadId, - title: "Parent", - instanceId: parentInstanceId, - model: "gpt-5.4", - }), - runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })], - visibleTurnItems: [], - runtimeRequests: [], - messages: [], - contextTransfers: [], - subagents: [ - { - id: taskId, - threadId: parentThreadId, - runId: activeRunId, - parentNodeId: NodeId.make("node-parent"), - origin: "app_owned", - createdBy: "agent", - driver: codexDriver, - providerInstanceId: customCodexInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Inspect the custom instance.", - title: null, - model: "gpt-5.4", - status: "running", - result: null, - startedAt: now, - completedAt: null, - updatedAt: now, - }, - ], - updatedAt: now, - } as unknown as OrchestrationV2ThreadProjection; - - const childProjection = { - thread: { - ...baseThread({ - threadId: childThreadId, - title: "Child", - instanceId: customCodexInstanceId, - model: "gpt-5.4", - }), - lineage: { - parentThreadId, - relationshipToParent: "subagent", - rootThreadId: parentThreadId, - }, - createdBy: "agent", - }, - runs: [ - makeRun({ - id: childRunId, - ordinal: 1, - status: "running", - instanceId: customCodexInstanceId, - }), - ], - visibleTurnItems: [], - runtimeRequests: [ - { - id: "request-mcp-question", - responseCapability: { type: "live" }, - kind: "user_input", - status: "pending", - routedTo: parentThreadId, - }, - { - id: "request-mcp-auth", - responseCapability: { type: "live" }, - kind: "auth_refresh", - status: "pending", - }, - ], - turnItems: [ - { - type: "user_input_request", - requestId: "request-mcp-question", - questions: [{ id: "q1", header: "Scope", question: "Fix the lexer too?", options: [] }], - }, - ], - messages: [], - contextTransfers: [ - { - type: "subagent_spawn", - sourceThreadId: parentThreadId, - targetThreadId: childThreadId, - targetRunId: childRunId, - }, - ], - subagents: [], - providerThreads: [], - updatedAt: now, - } as unknown as OrchestrationV2ThreadProjection; - - const layer = layerOrchestratorService.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(null), - getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), - getThreadRecords: (threadId) => { - if (threadId === parentThreadId) return Effect.succeed(parentProjection); - if (threadId === childThreadId) return Effect.succeed(childProjection); - return Effect.die(`unexpected thread ${threadId}`); - }, - } satisfies Partial), - ServerSettings.layerTest(), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - } satisfies Partial), - 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.taskStatus(makeScope(), taskId); - expect(result.status).toBe("running"); - expect(result.waitingOnUser).toEqual({ - kind: "input", - requestIds: ["request-mcp-question"], - waitingOn: "orchestrator", - preview: expect.stringContaining("Fix the lexer too?"), - }); - }).pipe(Effect.provide(layer), Effect.runPromise); -}); - -it("refuses delegated child messages, attachments, and queue sends to ancestors", async () => { - const rootId = ThreadId.make("thread-mcp-lineage-root"); - const childId = ThreadId.make("thread-mcp-lineage-child"); - const grandchildId = ThreadId.make("thread-mcp-lineage-grandchild"); - const unrelatedId = ThreadId.make("thread-mcp-lineage-unrelated"); - const subagentOf = (parentId: ThreadId) => ({ - parentThreadId: parentId, - relationshipToParent: "subagent" as const, - rootThreadId: rootId, - }); - const projections = new Map( - ( - [ - [rootId, null], - [childId, subagentOf(rootId)], - [grandchildId, subagentOf(childId)], - [unrelatedId, null], - ] as const - ).map(([threadId, lineage]) => { - const thread = baseThread({ - threadId, - title: threadId, - instanceId: parentInstanceId, - model: "gpt-5.4", - }); - return [ - threadId, - { - thread: { ...thread, activeRunId, ...(lineage === null ? {} : { lineage }) }, - runs: [makeRun({ id: RunId.make(`run-${threadId}`), ordinal: 1, status: "running" })], - runtimeRequests: [], - messages: [], - contextTransfers: [], - subagents: [], - } as unknown as OrchestrationV2ThreadProjection, - ] as const; - }), - ); - const scopeFor = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({ - ...makeScope(), - thread: { ...makeScope().thread!, threadId }, - }); - - let dispatches = 0; - const layer = layerOrchestratorService.pipe( - Layer.provideMerge( - Layer.mergeAll( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: () => { - dispatches++; - return Effect.succeed({ sequence: 1, storedEvents: [] }); - }, - getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), - getThreadShell: (threadId) => - Effect.succeed( - (projections.get(threadId)?.thread ?? null) as OrchestrationV2ThreadShell | null, - ), - getProjectThreadRecords: (input) => Effect.succeed(projections.get(input.threadId)!), - sendToThread: (input) => - Effect.succeed({ - run: { id: `run-sent-${input.threadId}`, status: "queued" }, - delivery: "started", - } as unknown as ThreadManagementService.ThreadManagementSendResult), - } satisfies Partial), - ServerSettings.layerTest(), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ - list: () => Effect.succeed([]), - }), - Layer.mock(ThreadSearch.ThreadSearch)({}), - Layer.mock(Orchestrator.OrchestratorV2)({}), - ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-ancestor-" }).pipe( - Layer.provide(NodeServices.layer), - ), - Layer.mock(ServerSecretStore.ServerSecretStore)({}), - NodeServices.layer, - Layer.succeed(McpInvocationContext.McpInvocationContext, scopeFor(childId)), - NodeCrypto.layer, - ), - ), - ); - - await Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const send = (from: ThreadId, to: ThreadId) => - service.sendToThread(scopeFor(from), { threadId: to, message: "progress update" }); - - const toParent = yield* send(childId, rootId).pipe(Effect.flip); - expect(toParent.code).toBe("ancestor_send_denied"); - expect(toParent.message).toContain("final message"); - expect(toParent.message).toContain("t3_request_user_input"); - const toGrandparent = yield* send(grandchildId, rootId).pipe(Effect.flip); - expect(toGrandparent.code).toBe("ancestor_send_denied"); - - expect((yield* send(childId, unrelatedId)).threadId).toBe(unrelatedId); - expect((yield* send(rootId, childId)).threadId).toBe(childId); - - const toolkit = yield* ThreadToolkit.pipe( - Effect.provide(McpToolAccess.HandlersLayer.layer(ThreadToolkitHandlers.layer)), - ); - const queueTarget = { threadId: rootId, queuedRunId: RunId.make("run-parent-queued") }; - const edited = yield* toolkit - .handle("t3_queue_edit", { ...queueTarget, text: "child-injected text" }) - .pipe(Stream.unwrap, Stream.runCollect); - expect(edited.at(-1)?.result).toMatchObject({ code: "ancestor_send_denied" }); - const promoted = yield* toolkit - .handle("t3_queue_promote_to_steer", { ...queueTarget, targetRunId: activeRunId }) - .pipe(Stream.unwrap, Stream.runCollect); - expect(promoted.at(-1)?.result).toMatchObject({ code: "ancestor_send_denied" }); - expect(dispatches).toBe(0); - - const attachments = yield* AttachmentToolkit.pipe( - Effect.provide(McpToolAccess.HandlersLayer.layer(AttachmentToolkitHandlers.layer)), - ); - const attachmentSend = yield* attachments - .handle("t3_thread_send_attachments", { - threadId: rootId, - message: "child-injected attachment", - attachments: [ - { - type: "file", - id: "attachment-1", - name: "report.txt", - mimeType: "text/plain", - sizeBytes: 1, - }, - ], - }) - .pipe(Stream.unwrap, Stream.runCollect); - expect(attachmentSend.at(-1)?.result).toMatchObject({ code: "ancestor_send_denied" }); - expect(dispatches).toBe(0); - - const unrelatedEdit = yield* toolkit - .handle("t3_queue_edit", { ...queueTarget, threadId: unrelatedId, text: "allowed message" }) - .pipe(Stream.unwrap, Stream.runCollect); - expect(unrelatedEdit.at(-1)?.result).toEqual({ sequence: 1 }); - expect(dispatches).toBe(1); - - // Corrupt lineage must terminate even when the target is outside the cycle. - const root = projections.get(rootId)!; - projections.set(rootId, { ...root, thread: { ...root.thread, lineage: subagentOf(childId) } }); - expect((yield* send(childId, unrelatedId)).threadId).toBe(unrelatedId); - expect((yield* send(grandchildId, rootId).pipe(Effect.flip)).code).toBe("ancestor_send_denied"); - }).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.fork.test.ts b/apps/server/src/mcp/OrchestratorMcpService.fork.test.ts new file mode 100644 index 0000000000000..ecad4f4a53751 --- /dev/null +++ b/apps/server/src/mcp/OrchestratorMcpService.fork.test.ts @@ -0,0 +1,1381 @@ +import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; +import type { DispatchModeRefusal } from "../orchestration-v2/DispatchModeLimit.ts"; +import { OrchestratorThreadAboveModeLimitError } from "../orchestration-v2/Orchestrator.ts"; +import { liveThreadShell } from "./McpToolAccess.testkit.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + CommandId, + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + NodeId, + OrchestratorMcpTarget, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2ThreadProjection, + type ModelRole, + type ServerProvider, +} from "@t3tools/contracts"; + +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Arr from "effect/Array"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.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 ProviderRegistry from "../provider/ProviderRegistry.ts"; + +import * as IssueWorkService from "../issues/IssueWorkService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import { modelRoleStatuses } from "./delegateTaskTarget.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; + +import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +const decodeTarget = Schema.decodeUnknownEffect(OrchestratorMcpTarget); + +const layerOrchestratorService = OrchestratorMcpService.layer.pipe( + Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), +); + +describe("OrchestratorMcpService", () => { + it.effect("retries terminal acknowledgement with a fresh command id", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-ack-parent"); + const childThreadId = ThreadId.make("thread:mcp-ack-child"); + const childRunId = RunId.make("run:mcp-ack-child"); + const taskId = NodeId.make("node:mcp-ack-task"); + const acknowledgementCommandIds = yield* Ref.make>([]); + const acknowledgementAttempts = yield* Ref.make(0); + const parentProjection = { + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [ + { + id: "transfer:delivered-original", + type: "subagent_result", + sourceThreadId: childThreadId, + targetThreadId: parentThreadId, + sourcePoint: { runId: childRunId }, + }, + ], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + result: "terminal result", + status: "completed", + completionDelivery: { state: "pending" }, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + thread: { + id: childThreadId, + forkedFrom: { type: "node", threadId: parentThreadId, nodeId: taskId }, + }, + runs: [ + { + id: childRunId, + ordinal: 1, + status: "completed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:00:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:10:00Z"), + }, + { + id: RunId.make("run:mcp-ack-continuation"), + delegatedTaskId: taskId, + ordinal: 2, + status: "failed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:02:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00Z"), + }, + ], + contextTransfers: [ + { + type: "subagent_spawn", + sourceThreadId: parentThreadId, + targetThreadId: childThreadId, + targetRunId: childRunId, + }, + ], + messages: [], + turnItems: [], + subagents: [], + providerThreads: [], + runtimeRequests: [], + } as unknown as OrchestrationV2ThreadProjection; + let hasNestedWork = true; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? hasNestedWork + ? { + ...parentProjection, + subagents: parentProjection.subagents.map((task) => ({ + ...task, + result: null, + status: "running" as const, + })), + } + : parentProjection + : hasNestedWork + ? { + ...childProjection, + runs: childProjection.runs.map((run) => ({ + ...run, + delegatedTaskId: undefined, + })), + subagents: [ + { ...parentProjection.subagents[0]!, status: "running" as const }, + ], + } + : childProjection, + ), + dispatch: (command) => + Ref.update(acknowledgementCommandIds, (commandIds) => [ + ...commandIds, + String(command.commandId), + ]).pipe( + Effect.andThen(Ref.updateAndGet(acknowledgementAttempts, (count) => count + 1)), + Effect.flatMap((attempt) => + attempt === 1 + ? Effect.fail(new Error("simulated acknowledgement failure") as never) + : Effect.succeed({} 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-ack"), + requestNamespace: "provider-session:mcp-ack", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-ack", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const pending = yield* service.taskStatus(scope, taskId); + assert.equal(pending.status, "running"); + assert.equal(pending.workState, "waiting_for_children"); + assert.isNull(pending.summary); + assert.equal(yield* Ref.get(acknowledgementAttempts), 0); + hasNestedWork = false; + const error = yield* service.taskStatus(scope, taskId).pipe(Effect.flip); + assert.equal(error.code, "orchestration_error"); + + const result = yield* service.taskStatus(scope, taskId); + assert.equal(result.status, "completed"); + assert.equal(result.summary, "terminal result"); + assert.equal(result.resultContextTransferId, "transfer:delivered-original"); + assert.equal(result.latestTerminalRunId, childRunId); + assert.equal(result.latestTerminalStatus, "completed"); + const commandIds = yield* Ref.get(acknowledgementCommandIds); + assert.equal(commandIds.length, 2); + assert.notEqual(commandIds[0], commandIds[1]); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + + const cancelOverRaisedGrandchild = (child: { + readonly deleted: boolean; + readonly raisedDuringStop?: boolean; + }) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-cancel-grandchild-parent"); + const childThreadId = ThreadId.make("thread:mcp-cancel-grandchild-child"); + const grandchildThreadId = ThreadId.make("thread:mcp-cancel-grandchild-grandchild"); + const taskId = NodeId.make("node:mcp-cancel-grandchild-task"); + const dispatched = yield* Ref.make>([]); + const stoppedBelow = yield* Ref.make(false); + const appOwnedTask = (id: string, threadId: ThreadId, childId: ThreadId) => ({ + id: NodeId.make(id), + threadId, + origin: "app_owned", + childThreadId: childId, + driver: "codex", + model: "gpt-5.6-terra", + result: null, + completionDelivery: { state: "pending" }, + }); + const projections = new Map([ + [ + parentThreadId, + { + thread: { + id: parentThreadId, + runtimeMode: "approval-required", + interactionMode: "default", + }, + runs: [], + contextTransfers: [], + subagents: [appOwnedTask(taskId, parentThreadId, childThreadId)], + }, + ], + [ + childThreadId, + { + thread: { id: childThreadId }, + runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], + contextTransfers: [], + messages: [], + subagents: [ + appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId), + ], + providerThreads: [], + runtimeRequests: [], + }, + ], + [ + grandchildThreadId, + { + thread: { id: grandchildThreadId }, + runs: [], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: [], + }, + ], + ]) as unknown as ReadonlyMap; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), + getThreadShell: (threadId) => + Effect.succeed( + threadId === grandchildThreadId && child.raisedDuringStop !== true + ? liveThreadShell(threadId) + : threadId === childThreadId && child.deleted + ? null + : liveThreadShell(threadId, { runtimeMode: "approval-required" }), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + stopDelegatedTasks: () => + Effect.gen(function* () { + const limit = yield* DispatchModeLimit; + if (child.raisedDuringStop === true && limit?.refused !== undefined) { + const refusal: DispatchModeRefusal = { + threadId: grandchildThreadId, + mode: "runtime", + runtimeMode: "full-access", + interactionMode: "default", + }; + yield* Ref.set(limit.refused, refusal); + return yield* new OrchestratorThreadAboveModeLimitError({ + commandId: CommandId.make("stop-grandchild"), + threadId: grandchildThreadId, + mode: "runtime", + runtimeMode: "full-access", + interactionMode: "default", + }); + } + yield* Ref.set(stoppedBelow, true); + }), + }), + 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-cancel-grandchild"), + requestNamespace: "provider-session:mcp-cancel-grandchild", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-grandchild", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + return yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .cancelTask(scope, { taskId, clientRequestId: "cancel-grandchild-above-modes" }) + .pipe(Effect.flip); + return { + code: error.code, + dispatched: yield* Ref.get(dispatched), + stoppedBelow: yield* Ref.get(stoppedBelow), + }; + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }); +}); + +describe("OrchestratorMcpService provider resolution", () => { + const parentThreadId = ThreadId.make("thread:mcp-providers-parent"); + const childThreadId = ThreadId.make("thread:mcp-providers-child"); + const parentRunId = RunId.make("run:mcp-providers-parent"); + const parentNodeId = NodeId.make("node:mcp-providers-root"); + const taskId = NodeId.make("node:mcp-providers-task"); + const projectId = ProjectId.make("project:mcp-providers"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const antigravityInstanceId = ProviderInstanceId.make("antigravity"); + const modelRoleStatusNow = DateTime.toEpochMillis( + DateTime.makeUnsafe("2026-10-12T12:00:00.000Z"), + ); + const modelRoleStatusWeekMs = 7 * 24 * 60 * 60 * 1000; + const modelRoleStatusResetAt = (timeLeft: number) => + DateTime.formatIso(DateTime.makeUnsafe(modelRoleStatusNow + modelRoleStatusWeekMs * timeLeft)); + const modelRoleStatusCheckedAt = DateTime.formatIso(DateTime.makeUnsafe(modelRoleStatusNow)); + + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-providers"), + requestNamespace: "provider-session:mcp-providers", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-providers", + providerInstanceId: codexInstanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + const providerSnapshot = (input: { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + readonly model?: string; + readonly enabled?: boolean; + }): ServerProvider => ({ + instanceId: input.instanceId, + driver: input.driver, + enabled: input.enabled ?? true, + installed: input.enabled !== false, + version: input.enabled === false ? null : "test", + status: input.enabled === false ? "disabled" : "ready", + auth: { status: input.enabled === false ? "unknown" : "authenticated" }, + ...(input.enabled === false ? { message: "Provider is disabled in T3 Code settings." } : {}), + checkedAt: "2026-09-13T00:00:00.000Z", + models: + input.model === undefined + ? [] + : [{ slug: input.model, name: input.model, isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + }); + + const providerRegistryLayer = (providers: ReadonlyArray) => + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(providers), + refreshInstance: () => Effect.succeed(providers), + }); + + const adapterRegistryLayer = (instanceIds: ReadonlyArray) => + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistryV2, + ProviderAdapterRegistry.ProviderAdapterRegistryV2.of({ + list: () => Effect.succeed(instanceIds), + get: (instanceId) => + instanceIds.includes(instanceId) + ? Effect.succeed({ + instanceId, + } as unknown as ProviderAdapter.ProviderAdapterV2["Service"]) + : Effect.fail( + new ProviderAdapterRegistry.ProviderAdapterRegistryLookupError({ instanceId }), + ), + }), + ); + + const parentProjection = ( + subagents: ReadonlyArray, + modelSelection: { + readonly instanceId: ProviderInstanceId; + readonly model: string; + readonly options?: ReadonlyArray<{ readonly id: string; readonly value: unknown }>; + } = { instanceId: codexInstanceId, model: "gpt-5.4" }, + ): OrchestrationV2ThreadProjection => + ({ + thread: { + id: parentThreadId, + projectId, + title: "MCP parent", + lineage: { rootThreadId: parentThreadId, parentThreadId: null }, + createdBy: "user", + creationSource: "web", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }, + runs: [ + { + id: parentRunId, + ordinal: 1, + status: "running", + rootNodeId: parentNodeId, + providerInstanceId: codexInstanceId, + modelSelection, + }, + ], + contextTransfers: [], + subagents, + }) as unknown as OrchestrationV2ThreadProjection; + + const childProjection = { + thread: { id: childThreadId }, + runs: [], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + + it("checks option selections independently for choices on the same model", () => { + const model = "gpt-6.1-sol"; + const role: ModelRole = { + id: "builder", + name: "Builder", + description: "", + targets: [ + { + providerInstanceId: codexInstanceId, + model, + options: [{ id: "reasoningEffort", value: "medium" }], + }, + { + providerInstanceId: codexInstanceId, + model, + options: [{ id: "reasoningEffort", value: "invalid" }], + }, + ], + }; + const provider: ServerProvider = { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model, + }), + models: [ + { + slug: model, + name: model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + }, + ], + }; + const statuses = modelRoleStatuses( + [role], + [provider], + new Set([codexInstanceId]), + modelRoleStatusNow, + ); + assert.deepEqual(statuses[0]!.targets[0], { ...role.targets[0], unavailableReason: null }); + assert.equal( + statuses[0]!.targets[1]!.unavailableReason, + `Model ${model} on provider codex rejected options: Option reasoningEffort must be one of: medium, high.`, + ); + }); + + it("orders role choices by weekly headroom, keeping unknown and unavailable choices last", () => { + const instance = (id: string) => ProviderInstanceId.make(id); + const configured = [ + "expired", + "balanced", + "unknown", + "roomy", + "missing-reset", + "blocked", + ] as const; + const target = (id: string) => ({ providerInstanceId: instance(id), model: id }); + const weekly = (usedPercent: number, timeLeft?: number) => ({ + id: "weekly", + kind: "weekly" as const, + label: "Weekly", + usedPercent, + windowDurationMins: 7 * 24 * 60, + ...(timeLeft === undefined ? {} : { resetsAt: modelRoleStatusResetAt(timeLeft) }), + }); + const provider = ( + id: string, + window?: ReturnType, + unavailable = false, + ): ServerProvider => ({ + ...providerSnapshot({ + instanceId: instance(id), + driver: ProviderDriverKind.make("codex"), + model: id, + }), + ...(unavailable ? { auth: { status: "unauthenticated" as const } } : {}), + ...(window === undefined + ? {} + : { + usageLimits: { + checkedAt: modelRoleStatusCheckedAt, + windows: [window], + }, + }), + }); + const roles: ReadonlyArray = [ + { + id: "builder", + name: "Builder", + description: "", + targets: [ + target("expired"), + target("balanced"), + target("unknown"), + target("roomy"), + target("missing-reset"), + target("blocked"), + ], + }, + ]; + const statuses = modelRoleStatuses( + roles, + [ + // A kept snapshot that reset yesterday says nothing about today's quota. + provider("expired", weekly(99, -1 / 7)), + provider("balanced", weekly(20, 0.8)), + provider("unknown"), + provider("roomy", weekly(40, 0.2)), + provider("missing-reset", weekly(10)), + provider("blocked", weekly(0, 0.01), true), + ], + new Set(configured.map(instance)), + modelRoleStatusNow, + ); + const choices = statuses[0]!.targets; + + assert.deepEqual( + choices.map((choice) => choice.model), + ["roomy", "balanced", "expired", "unknown", "missing-reset", "blocked"], + ); + assert.deepEqual(choices[0]!.weeklyUsage, { + remainingPercent: 60, + resetsAt: modelRoleStatusResetAt(0.2), + pace: "under", + }); + assert.deepEqual(choices[1]!.weeklyUsage, { + remainingPercent: 80, + resetsAt: modelRoleStatusResetAt(0.8), + pace: "on", + }); + assert.equal(choices[2]!.weeklyUsage, undefined); + assert.equal(choices[3]!.weeklyUsage, undefined); + assert.deepEqual(choices[4]!.weeklyUsage, { + remainingPercent: 90, + resetsAt: null, + pace: null, + }); + assert.include(choices[5]!.unavailableReason!, "not authenticated"); + }); + + it("caps Sonnet's weekly headroom at the all-models allowance", () => { + const claude = ProviderInstanceId.make("claude"); + const role: ModelRole = { + id: "builder", + name: "Builder", + description: "", + targets: [{ providerInstanceId: claude, model: "claude-sonnet-4-6" }], + }; + const provider: ServerProvider = { + ...providerSnapshot({ + instanceId: claude, + driver: ProviderDriverKind.make("claudeAgent"), + model: "claude-sonnet-4-6", + }), + usageLimits: { + checkedAt: modelRoleStatusCheckedAt, + windows: [ + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 95, + windowDurationMins: 7 * 24 * 60, + resetsAt: modelRoleStatusResetAt(0.5), + }, + { + id: "seven_day_sonnet", + kind: "weekly", + label: "Weekly · Sonnet", + usedPercent: 20, + windowDurationMins: 7 * 24 * 60, + resetsAt: modelRoleStatusResetAt(0.4), + }, + ], + }, + }; + const choice = modelRoleStatuses([role], [provider], new Set([claude]), modelRoleStatusNow)[0]! + .targets[0]!; + assert.equal(choice.weeklyUsage?.remainingPercent, 5); + }); + + it("ranks Claude choices by the tighter general or matching model-scoped weekly quota", () => { + const claude = ProviderInstanceId.make("claude"); + const role: ModelRole = { + id: "builder", + name: "Builder", + description: "", + targets: [ + { providerInstanceId: claude, model: "claude-sonnet-4-6" }, + { providerInstanceId: claude, model: "claude-opus-4-6" }, + { providerInstanceId: claude, model: "claude-3-5-haiku" }, + ], + }; + const provider: ServerProvider = { + ...providerSnapshot({ + instanceId: claude, + driver: ProviderDriverKind.make("claudeAgent"), + model: "claude-opus-4-6", + }), + models: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-3-5-haiku"].map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + })), + usageLimits: { + checkedAt: modelRoleStatusCheckedAt, + windows: [ + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 60, + windowDurationMins: 7 * 24 * 60, + resetsAt: modelRoleStatusResetAt(0.5), + }, + { + id: "seven_day_opus", + kind: "weekly", + label: "Weekly · Opus", + usedPercent: 95, + windowDurationMins: 7 * 24 * 60, + resetsAt: modelRoleStatusResetAt(0.1), + }, + { + id: "seven_day_sonnet", + kind: "weekly", + label: "Weekly · Sonnet", + usedPercent: 20, + windowDurationMins: 7 * 24 * 60, + resetsAt: modelRoleStatusResetAt(0.4), + }, + ], + }, + }; + const choices = modelRoleStatuses([role], [provider], new Set([claude]), modelRoleStatusNow)[0]! + .targets; + const byModel = new Map(choices.map((choice) => [choice.model, choice.weeklyUsage])); + + assert.deepEqual( + choices.map((choice) => choice.model), + ["claude-sonnet-4-6", "claude-3-5-haiku", "claude-opus-4-6"], + ); + assert.deepEqual(byModel.get("claude-opus-4-6"), { + remainingPercent: 5, + resetsAt: modelRoleStatusResetAt(0.1), + pace: "on", + }); + assert.deepEqual(byModel.get("claude-sonnet-4-6"), { + remainingPercent: 40, + resetsAt: modelRoleStatusResetAt(0.5), + pace: "ahead", + }); + assert.deepEqual(byModel.get("claude-3-5-haiku"), { + remainingPercent: 40, + resetsAt: modelRoleStatusResetAt(0.5), + pace: "ahead", + }); + }); + + it.effect("lists the user's model roles, marking targets delegate_task would reject", () => { + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const target = (providerInstanceId: ProviderInstanceId, model: string) => ({ + providerInstanceId, + model, + options: [{ id: "reasoningEffort", value: "high" }], + }); + const modelRoles: ReadonlyArray = [ + { + id: "builder", + name: "Builder", + description: "Pick a suitable choice", + targets: [ + target(codexInstanceId, "gpt-5.4"), + target(codexInstanceId, "gpt-0"), + target(ProviderInstanceId.make("ghost"), "gpt-5.4"), + target(claudeInstanceId, "claude-sonnet-4-6"), + ], + }, + ]; + const providers: ReadonlyArray = [ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + { + ...providerSnapshot({ + instanceId: claudeInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + model: "claude-sonnet-4-6", + }), + auth: { status: "unauthenticated" }, + }, + ]; + return Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const capabilities = yield* service.capabilities(scope); + assert.deepEqual( + capabilities.modelRoles, + modelRoles.map((entry) => ({ + ...entry, + targets: Arr.map(entry.targets, (choice, index) => ({ + ...choice, + unavailableReason: [ + null, + "Model gpt-0 is not advertised by provider codex.", + "Provider instance ghost is not registered.", + "Provider claudeAgent cannot run a child task: Provider is not authenticated.", + ][index]!, + })), + })), + ); + }).pipe( + Effect.provide( + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([])), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(providers), + }), + adapterRegistryLayer([codexInstanceId, claudeInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed({ ...DEFAULT_SERVER_SETTINGS, modelRoles }), + }), + ), + ), + ), + ), + ); + }); + + it.effect.each(["general", "checker", "test", "reviewer", "investigator"] as const)( + "driver-only delegation inherits the checkout for %s and skips disabled instances", + (role) => + Effect.gen(function* () { + const altInstanceId = ProviderInstanceId.make("codex-alt"); + const dispatched = yield* Ref.make>([]); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: altInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Summarize the diff.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + let delegated = false; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + hasReceipt: () => Effect.succeed(false), + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(delegated ? [task] : []) + : childProjection, + ), + + isProviderInstanceEnabledForProject: ({ instanceId }) => + Effect.succeed(instanceId !== codexInstanceId), + assertProviderInstanceEnabled: ({ projectId, instanceId }) => + instanceId === codexInstanceId + ? Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId, + instanceId, + }), + ) + : Effect.void, + 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), + ), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + providerSnapshot({ + instanceId: altInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId, altInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ServerSettings.layerTest(), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service.delegateTask(scope, { + task: "Summarize the diff.", + role, + target: { driverKind: ProviderDriverKind.make("codex") }, + mode: "async", + clientRequestId: "delegate-driver-skip-disabled-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 }; + role: string; + worktree?: unknown; + task: string; + }; + assert.equal(request.modelSelection.instanceId, altInstanceId); + assert.equal( + request.role, + role === "general" ? "investigator" : role === "test" ? "checker" : role, + ); + assert.isUndefined(request.worktree); + assert.equal( + request.task, + role === "general" + ? "Summarize the diff." + : `Act as the ${role === "test" ? "Checker" : role[0]!.toUpperCase() + role.slice(1)} sub-agent for this task.\n\nSummarize the diff.`, + ); + }).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", + () => + Effect.gen(function* () { + const codexAltInstanceId = ProviderInstanceId.make("codex-alt"); + const driver = ProviderDriverKind.make("codex"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const parentModelSelection = { + instanceId: codexInstanceId, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + } as const; + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: codexInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Summarize the diff.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + const cases = [ + { + name: "healthy-inherited", + inheritedEnabled: true, + peerEnabled: true, + explicit: false, + selectedInstanceId: codexInstanceId, + candidateDriver: driver, + }, + { + name: "unavailable-inherited-falls-back-to-healthy-peer", + inheritedEnabled: false, + peerEnabled: true, + explicit: false, + selectedInstanceId: codexAltInstanceId, + candidateDriver: driver, + }, + { + name: "no-available-peer", + inheritedEnabled: false, + peerEnabled: false, + explicit: false, + selectedInstanceId: null, + candidateDriver: driver, + }, + { + name: "cross-driver-no-available-candidate", + inheritedEnabled: false, + peerEnabled: false, + explicit: false, + selectedInstanceId: null, + candidateDriver: claudeDriver, + }, + { + name: "explicit-unavailable", + inheritedEnabled: false, + peerEnabled: true, + explicit: true, + selectedInstanceId: null, + candidateDriver: driver, + }, + { + name: "inherited-unavailable", + inheritedEnabled: false, + peerEnabled: true, + explicit: false, + selectedInstanceId: null, + candidateDriver: driver, + }, + { + name: "explicit-disabled-without-adapter", + inheritedEnabled: false, + peerEnabled: true, + explicit: true, + selectedInstanceId: null, + candidateDriver: driver, + }, + { + name: "explicit-healthy", + inheritedEnabled: true, + peerEnabled: true, + explicit: true, + selectedInstanceId: codexAltInstanceId, + candidateDriver: driver, + }, + ] as const; + + for (const testCase of cases) { + const dispatched = yield* Ref.make>([]); + let delegated = false; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + hasReceipt: () => Effect.succeed(false), + assertProviderInstanceEnabled: () => Effect.void, + isProviderInstanceEnabledForProject: () => Effect.succeed(true), + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(delegated ? [task] : [], parentModelSelection) + : 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(), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver, + model: "gpt-5.4", + enabled: testCase.inheritedEnabled, + }), + providerSnapshot({ + instanceId: codexAltInstanceId, + driver: testCase.candidateDriver, + model: "codex-alt-model", + enabled: testCase.peerEnabled, + }), + ]), + adapterRegistryLayer( + testCase.name === "explicit-disabled-without-adapter" + ? [codexAltInstanceId] + : [codexInstanceId, codexAltInstanceId], + ), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const target = + testCase.name === "inherited-unavailable" + ? undefined + : testCase.explicit + ? ({ + providerInstanceId: + testCase.selectedInstanceId === null + ? codexInstanceId + : testCase.selectedInstanceId, + } as const) + : ({ driverKind: testCase.candidateDriver } as const); + if (testCase.selectedInstanceId === null) { + const error = yield* service + .delegateTask(scope, { + task: "Summarize the diff.", + target, + mode: "async", + clientRequestId: `delegate-select-${testCase.name}`, + }) + .pipe(Effect.flip); + assert.equal(error.code, "provider_unavailable", testCase.name); + if ( + testCase.name === "explicit-unavailable" || + testCase.name === "inherited-unavailable" || + testCase.name === "explicit-disabled-without-adapter" + ) { + const expectedMessage = + `Provider ${codexInstanceId} cannot run a child task: ` + + (testCase.name === "explicit-disabled-without-adapter" + ? "No V2 provider adapter is registered. " + : "") + + "Provider instance is disabled."; + assert.include(error.message, expectedMessage); + const threadError = yield* service + .createThreads(scope, { + threads: [{ prompt: "Summarize the diff.", target }], + clientRequestId: `disabled-threads-${testCase.name}`, + }) + .pipe(Effect.flip); + assert.equal(threadError.code, "provider_unavailable"); + assert.include(threadError.message, expectedMessage); + } + if (testCase.name === "cross-driver-no-available-candidate") { + assert.isTrue(error.message.includes("driver claudeAgent"), testCase.name); + const threadError = yield* service + .createThreads(scope, { + threads: [{ prompt: "Summarize the diff.", target }], + clientRequestId: `delegate-threads-${testCase.name}`, + }) + .pipe(Effect.flip); + assert.equal( + threadError.code, + "provider_unavailable", + `${testCase.name}-createThreads`, + ); + assert.isTrue( + threadError.message.includes("driver claudeAgent"), + `${testCase.name}-createThreads`, + ); + } + assert.deepEqual(yield* Ref.get(dispatched), [], testCase.name); + return; + } + const result = yield* service.delegateTask(scope, { + task: "Summarize the diff.", + target, + mode: "async", + clientRequestId: `delegate-select-${testCase.name}`, + }); + assert.equal(result.status, "running", testCase.name); + const commands = yield* Ref.get(dispatched); + assert.equal(commands.length, 1, testCase.name); + const request = commands[0] as { + type: string; + modelSelection: { + instanceId: string; + model: string; + options?: ReadonlyArray<{ id: string; value: unknown }>; + }; + }; + assert.equal(request.type, "delegated_task.request", testCase.name); + assert.equal( + request.modelSelection.instanceId, + testCase.selectedInstanceId, + testCase.name, + ); + if (testCase.name === "healthy-inherited") { + assert.deepEqual(request.modelSelection, parentModelSelection, testCase.name); + } else { + assert.equal(request.modelSelection.model, "codex-alt-model", testCase.name); + } + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + } + }), + ); + + it.effect( + "inherits delegated options, merges overrides, and resets on explicit clearing or a new target", + () => + Effect.gen(function* () { + const peerId = ProviderInstanceId.make("codex-subscription-2"); + const driver = ProviderDriverKind.make("codex"); + const inherited = { + instanceId: codexInstanceId, + model: "gpt-5.4", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: "priority" }, + ], + } as const; + const override = [{ id: "reasoningEffort", value: "max" }] as const; + const cases = [ + { + name: "partial override", + target: { options: override }, + expected: { ...inherited, options: [...override, inherited.options[1]] }, + }, + { + name: "clear with array", + target: { options: [] }, + expected: { ...inherited, options: [] }, + }, + { + name: "clear with object", + target: { options: {} }, + expected: { ...inherited, options: [] }, + }, + { + name: "change model", + target: { model: "other-model" }, + expected: { instanceId: codexInstanceId, model: "other-model" }, + }, + { + name: "change provider with override", + target: { providerInstanceId: peerId, options: { reasoningEffort: "max" } }, + expected: { instanceId: peerId, model: inherited.model, options: override }, + }, + ] as const; + for (const testCase of cases) { + const commands: Array = []; + const task = { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + providerInstanceId: testCase.expected.instanceId, + model: testCase.expected.model, + status: "running", + result: null, + }; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + assertProviderInstanceEnabled: () => Effect.void, + getThreadShell: () => Effect.succeed(null), + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(commands.length > 0 ? [task] : [], inherited) + : childProjection, + ), + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { + sequence: 1, + storedEvents: [{ event: { type: "subagent.updated", payload: task } }], + } as never; + }), + }), + ServerSettings.layerTest(), + providerRegistryLayer([ + { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver, + model: inherited.model, + }), + models: [inherited.model, "other-model"].map((model) => ({ + slug: model, + name: model, + isCustom: false, + capabilities: null, + })), + }, + providerSnapshot({ instanceId: peerId, driver, model: inherited.model }), + ]), + adapterRegistryLayer([codexInstanceId, peerId]), + 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.inheritedOptions, inherited.options); + yield* service.delegateTask(scope, { + task: "Review the change.", + target: yield* decodeTarget(testCase.target), + mode: "async", + clientRequestId: testCase.name, + }); + assert.deepEqual( + (commands[0] as { modelSelection: unknown }).modelSelection, + testCase.expected, + testCase.name, + ); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies)))); + } + }), + ); + + it.effect("refuses stuck-root recovery to a Supervised caller", () => + Effect.gen(function* () { + const recover = () => Effect.die("A Supervised caller reached stuck-root recovery."); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + 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)({}), + Layer.mock(IssueWorkService.IssueWorkService)({ + cancelRequest: recover, + restoreRoot: recover, + }), + ), + ), + ), + ), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:recovery"), + requestNamespace: "client:recovery", + thread: undefined, + client: { sessionId: "recovery", label: "Claude Code", access: "approval-required" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const repository = { + host: "github.com", + repository: "owner/repo", + projectId: ProjectId.make("project:recovery"), + reason: "Stuck root", + clientRequestId: CommandId.make("command:recovery"), + }; + const cancelled = yield* mcp + .cancelIssueRequest(scope, { + ...repository, + expectedRootThreadId: ThreadId.make("thread:stuck-root"), + requestId: CommandId.make("command:stuck-start"), + }) + .pipe(Effect.flip); + const restored = yield* mcp + .restoreRepositoryRoot(scope, { + ...repository, + repositoryId: "repository:recovery", + expectedRootThreadId: ThreadId.make("thread:stuck-root"), + expectedRevision: 1, + targetThreadId: ThreadId.make("thread:replacement"), + }) + .pipe(Effect.flip); + assert.equal(cancelled.code, "runtime_mode_escalation_denied"); + assert.equal(restored.code, "runtime_mode_escalation_denied"); + }), + ); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 73eadbc862cc2..79c760f92c1dd 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2,10 +2,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { CommandId, - DEFAULT_SERVER_SETTINGS, EnvironmentId, MessageId, NodeId, + type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, type ScheduledTask, @@ -15,14 +15,13 @@ import { ProviderInstanceId, RunId, ThreadId, + type OrchestrationV2ServerCommand, type OrchestrationV2ThreadProjection, - type ModelRole, type ServerProvider, } from "@t3tools/contracts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Arr from "effect/Array"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -47,12 +46,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; -import * as IssueWorkService from "../issues/IssueWorkService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; -import { modelRoleStatuses } from "./delegateTaskTarget.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -77,15 +74,7 @@ describe("OrchestratorMcpService", () => { const parentProjection = { thread: { id: parentThreadId }, runs: [], - contextTransfers: [ - { - id: "transfer:delivered-original", - type: "subagent_result", - sourceThreadId: childThreadId, - targetThreadId: parentThreadId, - sourcePoint: { runId: childRunId }, - }, - ], + contextTransfers: [], subagents: [ { id: taskId, @@ -95,16 +84,12 @@ describe("OrchestratorMcpService", () => { driver: "codex", model: "gpt-5.6-terra", result: "terminal result", - status: "completed", completionDelivery: { state: "pending" }, }, ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { - id: childThreadId, - forkedFrom: { type: "node", threadId: parentThreadId, nodeId: taskId }, - }, + thread: { id: childThreadId }, runs: [ { id: childRunId, @@ -115,23 +100,14 @@ describe("OrchestratorMcpService", () => { }, { id: RunId.make("run:mcp-ack-continuation"), - delegatedTaskId: taskId, ordinal: 2, status: "failed", startedAt: DateTime.makeUnsafe("2026-10-03T10:02:00Z"), completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00Z"), }, ], - contextTransfers: [ - { - type: "subagent_spawn", - sourceThreadId: parentThreadId, - targetThreadId: childThreadId, - targetRunId: childRunId, - }, - ], + contextTransfers: [], messages: [], - turnItems: [], subagents: [], providerThreads: [], runtimeRequests: [], @@ -157,10 +133,6 @@ describe("OrchestratorMcpService", () => { : hasNestedWork ? { ...childProjection, - runs: childProjection.runs.map((run) => ({ - ...run, - delegatedTaskId: undefined, - })), subagents: [ { ...parentProjection.subagents[0]!, status: "running" as const }, ], @@ -216,7 +188,6 @@ describe("OrchestratorMcpService", () => { const result = yield* service.taskStatus(scope, taskId); assert.equal(result.status, "completed"); assert.equal(result.summary, "terminal result"); - assert.equal(result.resultContextTransferId, "transfer:delivered-original"); assert.equal(result.latestTerminalRunId, childRunId); assert.equal(result.latestTerminalStatus, "completed"); const commandIds = yield* Ref.get(acknowledgementCommandIds); @@ -399,6 +370,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"); @@ -820,13 +901,6 @@ describe("OrchestratorMcpService provider resolution", () => { const projectId = ProjectId.make("project:mcp-providers"); const codexInstanceId = ProviderInstanceId.make("codex"); const antigravityInstanceId = ProviderInstanceId.make("antigravity"); - const modelRoleStatusNow = DateTime.toEpochMillis( - DateTime.makeUnsafe("2026-10-12T12:00:00.000Z"), - ); - const modelRoleStatusWeekMs = 7 * 24 * 60 * 60 * 1000; - const modelRoleStatusResetAt = (timeLeft: number) => - DateTime.formatIso(DateTime.makeUnsafe(modelRoleStatusNow + modelRoleStatusWeekMs * timeLeft)); - const modelRoleStatusCheckedAt = DateTime.formatIso(DateTime.makeUnsafe(modelRoleStatusNow)); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-providers"), @@ -899,7 +973,7 @@ describe("OrchestratorMcpService provider resolution", () => { id: parentThreadId, projectId, title: "MCP parent", - lineage: { rootThreadId: parentThreadId, parentThreadId: null }, + lineage: { rootThreadId: parentThreadId, parentThreadId: null, relationshipToParent: null }, createdBy: "user", creationSource: "web", modelSelection, @@ -1087,678 +1161,176 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it("checks option selections independently for choices on the same model", () => { - const model = "gpt-6.1-sol"; - const role: ModelRole = { - id: "builder", - name: "Builder", - description: "", - targets: [ - { - providerInstanceId: codexInstanceId, - model, - options: [{ id: "reasoningEffort", value: "medium" }], - }, - { - providerInstanceId: codexInstanceId, - model, - options: [{ id: "reasoningEffort", value: "invalid" }], - }, - ], - }; - const provider: ServerProvider = { - ...providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model, - }), - models: [ - { - slug: model, - name: model, - isCustom: false, - capabilities: { - optionDescriptors: [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: [ - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - }, - ], - }, - }, - ], - }; - const statuses = modelRoleStatuses( - [role], - [provider], - new Set([codexInstanceId]), - modelRoleStatusNow, - ); - assert.deepEqual(statuses[0]!.targets[0], { ...role.targets[0], unavailableReason: null }); - assert.equal( - statuses[0]!.targets[1]!.unavailableReason, - `Model ${model} on provider codex rejected options: Option reasoningEffort must be one of: medium, high.`, - ); - }); - - it("orders role choices by weekly headroom, keeping unknown and unavailable choices last", () => { - const instance = (id: string) => ProviderInstanceId.make(id); - const configured = [ - "expired", - "balanced", - "unknown", - "roomy", - "missing-reset", - "blocked", - ] as const; - const target = (id: string) => ({ providerInstanceId: instance(id), model: id }); - const weekly = (usedPercent: number, timeLeft?: number) => ({ - id: "weekly", - kind: "weekly" as const, - label: "Weekly", - usedPercent, - windowDurationMins: 7 * 24 * 60, - ...(timeLeft === undefined ? {} : { resetsAt: modelRoleStatusResetAt(timeLeft) }), - }); - const provider = ( - id: string, - window?: ReturnType, - unavailable = false, - ): ServerProvider => ({ - ...providerSnapshot({ - instanceId: instance(id), - driver: ProviderDriverKind.make("codex"), - model: id, - }), - ...(unavailable ? { auth: { status: "unauthenticated" as const } } : {}), - ...(window === undefined - ? {} - : { - usageLimits: { - checkedAt: modelRoleStatusCheckedAt, - windows: [window], - }, - }), + describe("listed models", () => { + const opencodeInstanceId = ProviderInstanceId.make("opencode"); + const model = (slug: string, extra: Partial = {}) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + ...extra, }); - const roles: ReadonlyArray = [ - { - id: "builder", - name: "Builder", - description: "", - targets: [ - target("expired"), - target("balanced"), - target("unknown"), - target("roomy"), - target("missing-reset"), - target("blocked"), - ], - }, - ]; - const statuses = modelRoleStatuses( - roles, - [ - // A kept snapshot that reset yesterday says nothing about today's quota. - provider("expired", weekly(99, -1 / 7)), - provider("balanced", weekly(20, 0.8)), - provider("unknown"), - provider("roomy", weekly(40, 0.2)), - provider("missing-reset", weekly(10)), - provider("blocked", weekly(0, 0.01), true), - ], - new Set(configured.map(instance)), - modelRoleStatusNow, - ); - const choices = statuses[0]!.targets; - - assert.deepEqual( - choices.map((choice) => choice.model), - ["roomy", "balanced", "expired", "unknown", "missing-reset", "blocked"], + const opencodeSlugs = Array.from( + { length: 12 }, + (_, index) => `oc/m${String(index + 1).padStart(2, "0")}`, ); - assert.deepEqual(choices[0]!.weeklyUsage, { - remainingPercent: 60, - resetsAt: modelRoleStatusResetAt(0.2), - pace: "under", - }); - assert.deepEqual(choices[1]!.weeklyUsage, { - remainingPercent: 80, - resetsAt: modelRoleStatusResetAt(0.8), - pace: "on", - }); - assert.equal(choices[2]!.weeklyUsage, undefined); - assert.equal(choices[3]!.weeklyUsage, undefined); - assert.deepEqual(choices[4]!.weeklyUsage, { - remainingPercent: 90, - resetsAt: null, - pace: null, - }); - assert.include(choices[5]!.unavailableReason!, "not authenticated"); - }); - - it("caps Sonnet's weekly headroom at the all-models allowance", () => { - const claude = ProviderInstanceId.make("claude"); - const role: ModelRole = { - id: "builder", - name: "Builder", - description: "", - targets: [{ providerInstanceId: claude, model: "claude-sonnet-4-6" }], - }; - const provider: ServerProvider = { - ...providerSnapshot({ - instanceId: claude, - driver: ProviderDriverKind.make("claudeAgent"), - model: "claude-sonnet-4-6", - }), - usageLimits: { - checkedAt: modelRoleStatusCheckedAt, - windows: [ - { - id: "seven_day", - kind: "weekly", - label: "Weekly", - usedPercent: 95, - windowDurationMins: 7 * 24 * 60, - resetsAt: modelRoleStatusResetAt(0.5), - }, - { - id: "seven_day_sonnet", - kind: "weekly", - label: "Weekly · Sonnet", - usedPercent: 20, - windowDurationMins: 7 * 24 * 60, - resetsAt: modelRoleStatusResetAt(0.4), - }, - ], - }, - }; - const choice = modelRoleStatuses([role], [provider], new Set([claude]), modelRoleStatusNow)[0]! - .targets[0]!; - assert.equal(choice.weeklyUsage?.remainingPercent, 5); - }); - - it("ranks Claude choices by the tighter general or matching model-scoped weekly quota", () => { - const claude = ProviderInstanceId.make("claude"); - const role: ModelRole = { - id: "builder", - name: "Builder", - description: "", - targets: [ - { providerInstanceId: claude, model: "claude-sonnet-4-6" }, - { providerInstanceId: claude, model: "claude-opus-4-6" }, - { providerInstanceId: claude, model: "claude-3-5-haiku" }, - ], - }; - const provider: ServerProvider = { - ...providerSnapshot({ - instanceId: claude, - driver: ProviderDriverKind.make("claudeAgent"), - model: "claude-opus-4-6", - }), - models: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-3-5-haiku"].map((slug) => ({ - slug, - name: slug, - isCustom: false, - capabilities: null, - })), - usageLimits: { - checkedAt: modelRoleStatusCheckedAt, - windows: [ - { - id: "seven_day", - kind: "weekly", - label: "Weekly", - usedPercent: 60, - windowDurationMins: 7 * 24 * 60, - resetsAt: modelRoleStatusResetAt(0.5), - }, - { - id: "seven_day_opus", - kind: "weekly", - label: "Weekly · Opus", - usedPercent: 95, - windowDurationMins: 7 * 24 * 60, - resetsAt: modelRoleStatusResetAt(0.1), - }, - { - id: "seven_day_sonnet", - kind: "weekly", - label: "Weekly · Sonnet", - usedPercent: 20, - windowDurationMins: 7 * 24 * 60, - resetsAt: modelRoleStatusResetAt(0.4), - }, - ], - }, - }; - const choices = modelRoleStatuses([role], [provider], new Set([claude]), modelRoleStatusNow)[0]! - .targets; - const byModel = new Map(choices.map((choice) => [choice.model, choice.weeklyUsage])); - - assert.deepEqual( - choices.map((choice) => choice.model), - ["claude-sonnet-4-6", "claude-3-5-haiku", "claude-opus-4-6"], - ); - assert.deepEqual(byModel.get("claude-opus-4-6"), { - remainingPercent: 5, - resetsAt: modelRoleStatusResetAt(0.1), - pace: "on", - }); - assert.deepEqual(byModel.get("claude-sonnet-4-6"), { - remainingPercent: 40, - resetsAt: modelRoleStatusResetAt(0.5), - pace: "ahead", - }); - assert.deepEqual(byModel.get("claude-3-5-haiku"), { - remainingPercent: 40, - resetsAt: modelRoleStatusResetAt(0.5), - pace: "ahead", - }); - }); - - it.effect("lists the user's model roles, marking targets delegate_task would reject", () => { - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const target = (providerInstanceId: ProviderInstanceId, model: string) => ({ - providerInstanceId, - model, - options: [{ id: "reasoningEffort", value: "high" }], - }); - const modelRoles: ReadonlyArray = [ + const catalog: ReadonlyArray = [ { - id: "builder", - name: "Builder", - description: "Pick a suitable choice", - targets: [ - target(codexInstanceId, "gpt-5.4"), - target(codexInstanceId, "gpt-0"), - target(ProviderInstanceId.make("ghost"), "gpt-5.4"), - target(claudeInstanceId, "claude-sonnet-4-6"), + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }), + models: [ + model("gpt-6", { + name: "Sol", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + description: "How hard the model thinks.", + type: "select", + options: [ + { id: "low", label: "Low", description: "Fast." }, + { id: "high", label: "High", isDefault: true }, + ], + promptInjectedValues: ["high"], + }, + { id: "fastMode", label: "Fast", type: "boolean" }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build" }], + currentValue: "build", + }, + ], + }, + }), + model("gpt-5", { isLegacy: true }), + model("gpt-4", { isLegacy: true }), + model("gpt-3", { name: "GPT-3", isLegacy: true }), + model("my-alpha", { isCustom: true }), ], }, - ]; - const providers: ReadonlyArray = [ - providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), { ...providerSnapshot({ - instanceId: claudeInstanceId, - driver: ProviderDriverKind.make("claudeAgent"), - model: "claude-sonnet-4-6", + instanceId: opencodeInstanceId, + driver: ProviderDriverKind.make("opencode"), }), - auth: { status: "unauthenticated" }, + models: [ + ...opencodeSlugs.map((slug) => model(slug, { isLegacy: slug === "oc/m03" })), + model("oc-custom", { isCustom: true }), + ], }, ]; - return Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const capabilities = yield* service.capabilities(scope); - assert.deepEqual( - capabilities.modelRoles, - modelRoles.map((entry) => ({ - ...entry, - targets: Arr.map(entry.targets, (choice, index) => ({ - ...choice, - unavailableReason: [ - null, - "Model gpt-0 is not advertised by provider codex.", - "Provider instance ghost is not registered.", - "Provider claudeAgent cannot run a child task: Provider is not authenticated.", - ][index]!, - })), - })), - ); - }).pipe( - Effect.provide( - layerOrchestratorService.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(parentProjection([])), - }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed(providers), - }), - adapterRegistryLayer([codexInstanceId, claudeInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed({ ...DEFAULT_SERVER_SETTINGS, modelRoles }), - }), + + const capabilitiesWith = (search?: string) => + Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.capabilities(scope, search === undefined ? {} : { search }); + }).pipe( + Effect.provide( + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + getThreadRecords: () => + Effect.succeed( + parentProjection([], { instanceId: codexInstanceId, model: "gpt-3" }), + ), + }), + ServerSettings.layerTest({ + favorites: [ + { provider: codexInstanceId, model: "gpt-4" }, + { provider: opencodeInstanceId, model: "oc/m10" }, + ], + providerModelPreferences: { + [opencodeInstanceId]: { hiddenModels: ["oc/m02"], modelOrder: ["oc/m12"] }, + }, + }), + providerRegistryLayer(catalog), + adapterRegistryLayer([codexInstanceId, opencodeInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), ), ), ), - ), - ); - }); - - 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)))); - }), - ); + const listing = (capabilities: OrchestratorMcpCapabilitiesResult) => + capabilities.providers.map((provider) => ({ + id: provider.providerInstanceId, + models: provider.models.map((entry) => entry.id), + omitted: provider.omittedModels ?? 0, + })); - 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([])), + it.effect( + "skips legacy models, lists the first 8 of large catalogs, and keeps pinned models", + () => + Effect.gen(function* () { + assert.deepEqual(listing(yield* capabilitiesWith()), [ + // gpt-4 is a favorite and gpt-3 is the caller's model; legacy gpt-5 is left out. + { id: codexInstanceId, models: ["gpt-6", "gpt-4", "gpt-3", "my-alpha"], omitted: 1 }, + { + id: opencodeInstanceId, + models: [ + "oc/m12", + "oc/m01", + "oc/m04", + "oc/m05", + "oc/m06", + "oc/m07", + "oc/m08", + "oc/m09", + "oc/m10", + "oc-custom", + ], + omitted: 2, + }, + ]); }), - 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", - () => + it.effect("search matches every word and caps each provider's matches", () => 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)({ - getThreadShell: () => Effect.succeed(null), - 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)))); + assert.deepEqual(listing(yield* capabilitiesWith("GPT 5")), [ + { id: codexInstanceId, models: ["gpt-5"], omitted: 0 }, + { id: opencodeInstanceId, models: [], omitted: 0 }, + ]); + const opencode = listing(yield* capabilitiesWith("oc/m"))[1]; + assert.deepEqual(opencode, { + id: opencodeInstanceId, + models: ["oc/m12", "oc/m01", "oc/m03", "oc/m04", "oc/m05", "oc/m06", "oc/m07", "oc/m08"], + omitted: 3, + }); }), - ); - - 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)({ - getThreadShell: () => Effect.succeed(null), - 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("describes models by id, distinct label, and option values", () => + Effect.gen(function* () { + const codex = (yield* capabilitiesWith()).providers[0]; + assert.deepEqual(codex?.models.slice(0, 4), [ + { + id: "gpt-6", + label: "Sol", + options: [ + { id: "effort", values: ["low", "high"], default: "high" }, + { id: "fastMode", values: [false, true] }, + { id: "agent", values: ["build"], default: "build" }, + ], + }, + { id: "gpt-4" }, + { id: "gpt-3" }, + { id: "my-alpha" }, + ]); + }), + ); + }); it.effect("rejects delegation to a provider without a registered adapter", () => Effect.gen(function* () { @@ -1820,20 +1392,70 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.effect("refuses delegation to a provider the project turned off", () => + it.effect("driver-only delegation skips an instance the project turned off", () => Effect.gen(function* () { + const altInstanceId = ProviderInstanceId.make("codex-alt"); + const dispatched = yield* Ref.make>([]); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: altInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Summarize the diff.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + let delegated = false; const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(null), - hasReceipt: () => Effect.succeed(false), - getThreadRecords: () => Effect.succeed(parentProjection([])), - dispatch: () => - Effect.fail( - new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ - projectId, - instanceId: codexInstanceId, - }), + getThreadShell: () => Effect.succeed(null), + hasReceipt: () => Effect.succeed(false), + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(delegated ? [task] : []) + : childProjection, + ), + isProviderInstanceEnabledForProject: ({ instanceId }) => + Effect.succeed(instanceId !== codexInstanceId), + assertProviderInstanceEnabled: ({ projectId, instanceId }) => + instanceId === codexInstanceId + ? Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId, + instanceId, + }), + ) + : Effect.void, + 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), ), }), providerRegistryLayer([ @@ -1842,8 +1464,13 @@ describe("OrchestratorMcpService provider resolution", () => { driver: ProviderDriverKind.make("codex"), model: "gpt-5.4", }), + providerSnapshot({ + instanceId: altInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), ]), - adapterRegistryLayer([codexInstanceId]), + adapterRegistryLayer([codexInstanceId, altInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), @@ -1852,141 +1479,23 @@ describe("OrchestratorMcpService provider resolution", () => { 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"); + const result = yield* service.delegateTask(scope, { + task: "Summarize the diff.", + target: { driverKind: ProviderDriverKind.make("codex") }, + mode: "async", + clientRequestId: "delegate-driver-skip-disabled-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, altInstanceId); }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); }), ); - it.effect.each(["general", "checker", "test", "reviewer", "investigator"] as const)( - "driver-only delegation inherits the checkout for %s and skips disabled instances", - (role) => - Effect.gen(function* () { - const altInstanceId = ProviderInstanceId.make("codex-alt"); - const dispatched = yield* Ref.make>([]); - const task = { - id: taskId, - threadId: parentThreadId, - runId: parentRunId, - parentNodeId, - origin: "app_owned", - createdBy: "agent", - driver: ProviderDriverKind.make("codex"), - providerInstanceId: altInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Summarize the diff.", - title: null, - model: "gpt-5.4", - status: "running", - result: null, - startedAt: null, - completedAt: null, - }; - let delegated = false; - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(null), - hasReceipt: () => Effect.succeed(false), - getThreadRecords: (threadId) => - Effect.succeed( - threadId === parentThreadId - ? parentProjection(delegated ? [task] : []) - : childProjection, - ), - - isProviderInstanceEnabledForProject: ({ instanceId }) => - Effect.succeed(instanceId !== codexInstanceId), - assertProviderInstanceEnabled: ({ projectId, instanceId }) => - instanceId === codexInstanceId - ? Effect.fail( - new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ - projectId, - instanceId, - }), - ) - : Effect.void, - 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), - ), - }), - providerRegistryLayer([ - providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - providerSnapshot({ - instanceId: altInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - ]), - adapterRegistryLayer([codexInstanceId, altInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ServerSettings.layerTest(), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const result = yield* service.delegateTask(scope, { - task: "Summarize the diff.", - role, - target: { driverKind: ProviderDriverKind.make("codex") }, - mode: "async", - clientRequestId: "delegate-driver-skip-disabled-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 }; - role: string; - worktree?: unknown; - task: string; - }; - assert.equal(request.modelSelection.instanceId, altInstanceId); - assert.equal( - request.role, - role === "general" ? "investigator" : role === "test" ? "checker" : role, - ); - assert.isUndefined(request.worktree); - assert.equal( - request.task, - role === "general" - ? "Summarize the diff." - : `Act as the ${role === "test" ? "Checker" : role[0]!.toUpperCase() + role.slice(1)} sub-agent for this task.\n\nSummarize the diff.`, - ); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - it.effect( "retrying an accepted driver-only delegation replays even after the project disables every candidate", () => @@ -2269,111 +1778,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)({ - getThreadShell: () => Effect.succeed(null), - 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", () => @@ -2566,8 +1970,8 @@ describe("OrchestratorMcpService provider resolution", () => { (testCase.name === "explicit-disabled-without-adapter" ? "No V2 provider adapter is registered. " : "") + - "Provider instance is disabled."; - assert.include(error.message, expectedMessage); + "Provider instance is disabled. Provider executable is not installed. Provider is disabled in T3 Code settings."; + assert.equal(error.message, expectedMessage); const threadError = yield* service .createThreads(scope, { threads: [{ prompt: "Summarize the diff.", target }], @@ -2575,7 +1979,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) .pipe(Effect.flip); assert.equal(threadError.code, "provider_unavailable"); - assert.include(threadError.message, expectedMessage); + assert.equal(threadError.message, expectedMessage); } if (testCase.name === "cross-driver-no-available-candidate") { assert.isTrue(error.message.includes("driver claudeAgent"), testCase.name); @@ -2903,6 +2307,7 @@ describe("OrchestratorMcpService provider resolution", () => { const dependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId @@ -2922,7 +2327,6 @@ describe("OrchestratorMcpService provider resolution", () => { : [], } as unknown as OrchestrationV2ThreadProjection), ), - getThreadShell: () => Effect.succeed(null), }), ServerSettings.layerTest(), providerRegistryLayer([]), @@ -3001,9 +2405,9 @@ describe("OrchestratorMcpService provider resolution", () => { const dependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, - getThreadShell: () => Effect.succeed(null), getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId @@ -3061,69 +2465,6 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.effect("refuses stuck-root recovery to a Supervised caller", () => - Effect.gen(function* () { - const recover = () => Effect.die("A Supervised caller reached stuck-root recovery."); - const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( - Effect.provide( - layerOrchestratorService.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - 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)({}), - Layer.mock(IssueWorkService.IssueWorkService)({ - cancelRequest: recover, - restoreRoot: recover, - }), - ), - ), - ), - ), - ); - const scope: McpInvocationScope = { - environmentId: EnvironmentId.make("environment:recovery"), - requestNamespace: "client:recovery", - thread: undefined, - client: { sessionId: "recovery", label: "Claude Code", access: "approval-required" }, - capabilities: new Set(["orchestration"]), - issuedAt: 1, - }; - const repository = { - host: "github.com", - repository: "owner/repo", - projectId: ProjectId.make("project:recovery"), - reason: "Stuck root", - clientRequestId: CommandId.make("command:recovery"), - }; - const cancelled = yield* mcp - .cancelIssueRequest(scope, { - ...repository, - expectedRootThreadId: ThreadId.make("thread:stuck-root"), - requestId: CommandId.make("command:stuck-start"), - }) - .pipe(Effect.flip); - const restored = yield* mcp - .restoreRepositoryRoot(scope, { - ...repository, - repositoryId: "repository:recovery", - expectedRootThreadId: ThreadId.make("thread:stuck-root"), - expectedRevision: 1, - targetThreadId: ThreadId.make("thread:replacement"), - }) - .pipe(Effect.flip); - assert.equal(cancelled.code, "runtime_mode_escalation_denied"); - assert.equal(restored.code, "runtime_mode_escalation_denied"); - }), - ); - describe("resolveRuntimeMode", () => { const muse = { modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" }, @@ -3156,3 +2497,79 @@ describe("OrchestratorMcpService provider resolution", () => { ); }); }); + +describe("OrchestratorMcpService thread lookup", () => { + const bareId = ThreadId.make("5d0b4a3e-8c1f-4a52-9d37-2f6c7e1b9a04"); + const launched = liveThreadShell(ThreadId.make(`mcp:${bareId}`)); + const deletedLaunched = { ...launched, deletedAt: DateTime.makeUnsafe("2026-10-05T00:00:00Z") }; + const client: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:thread-lookup"), + requestNamespace: "client:thread-lookup", + thread: undefined, + client: { sessionId: "thread-lookup", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const service = (shells: ReadonlyArray, sent: Ref.Ref) => + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed(shells.find((shell) => shell.id === threadId) ?? null), + sendToThread: () => Ref.update(sent, (count) => count + 1).pipe(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 failuresFor = (shells: ReadonlyArray) => + Effect.gen(function* () { + const sent = yield* Ref.make(0); + return yield* Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const read = yield* mcp.readThread(client, { threadId: bareId }).pipe(Effect.flip); + const send = yield* mcp + .sendToThread(client, { threadId: bareId, message: "hello" }) + .pipe(Effect.flip); + return { read, send, sends: yield* Ref.get(sent) }; + }).pipe(Effect.provide(service(shells, sent))); + }); + + it.effect("points a bare uuid at the mcp: thread it belongs to", () => + Effect.gen(function* () { + const { read, send, sends } = yield* failuresFor([launched]); + for (const error of [read, send]) { + assert.equal(error.code, "thread_not_found"); + assert.include(error.message, `mcp:${bareId}`); + } + assert.equal(sends, 0); + }), + ); + + it.effect("keeps the plain message for an id that matches nothing", () => + Effect.gen(function* () { + const { read, send } = yield* failuresFor([]); + assert.equal(read.code, "thread_not_found"); + assert.equal(read.message, `Thread ${bareId} is no longer available.`); + assert.equal(send.code, "thread_not_found"); + assert.equal(send.message, `Thread ${bareId} was not found.`); + }), + ); + + it.effect("does not name an mcp: thread that was deleted", () => + Effect.gen(function* () { + const { read, send } = yield* failuresFor([deletedLaunched]); + assert.equal(read.message, `Thread ${bareId} is no longer available.`); + assert.equal(send.message, `Thread ${bareId} was not found.`); + }), + ); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 4835405b9e641..95efd68365f02 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -13,7 +13,10 @@ import { type OrchestrationV2TurnItem, OrchestratorMcpFailure, isProviderAvailable, + type OrchestratorMcpCapabilitiesInput, + type ProviderOptionDescriptor, type OrchestratorMcpCapabilitiesResult, + type OrchestratorMcpModelOption, type OrchestratorMcpCreateThreadsInput, type OrchestratorMcpCreateThreadsResult, type OrchestratorMcpCreatedThread, @@ -57,8 +60,11 @@ import { type ScheduledTask, type ScheduledTaskUpsertInput, type ServerProvider, + type ServerProviderModel, ThreadId, + isOrchestrationV2WorkActive, } from "@t3tools/contracts"; +import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, @@ -190,6 +196,7 @@ export interface OrchestratorMcpServiceShape { ) => Effect.Effect; readonly capabilities: ( scope: McpInvocationScope, + input?: OrchestratorMcpCapabilitiesInput, ) => Effect.Effect; readonly delegateTask: ( scope: McpInvocationScope, @@ -286,6 +293,26 @@ export const isLiveCallerShell = ( ) ?? false)); +/** + * Threads an agent starts over MCP are `mcp:`, and agents often paste the + * bare uuid. The id is never rewritten, so permission checks keyed on it still + * hold; the miss only names a live thread the caller could read by its full id. + */ +export const threadNotFound = Effect.fn("OrchestratorMcpService.threadNotFound")(function* ( + threads: Pick, + threadId: ThreadId, + message: string, +) { + const prefixed = ThreadId.make(`mcp:${threadId}`); + const shell = yield* threads.getThreadShell(prefixed).pipe(Effect.orElseSucceed(() => null)); + return yield* failure( + "thread_not_found", + shell === null || shell.deletedAt !== null + ? message + : `${message} Did you mean \`${prefixed}\`?`, + ); +}); + function threadManagementFailure(error: unknown): OrchestratorMcpFailure { if (!isThreadManagementError(error)) return failure("orchestration_error", errorMessage(error)); switch (error._tag) { @@ -338,6 +365,61 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato }; } +/** + * Large catalogs (OpenCode, Cursor, Pi) can list hundreds of models, so agents + * get a short list by default and search for the rest. These providers have + * small official catalogs, so all their current models are listed. + */ +const FULL_CATALOG_DRIVERS: ReadonlySet = new Set(["codex", "claudeAgent", "muse", "grok"]); +const LISTED_MODEL_LIMIT = 8; + +/** + * Picks the models orchestrator_capabilities shows for one provider. Hidden + * models never appear. With search terms, lists matches; otherwise lists pinned + * models (custom, favorite, the caller's own) plus the provider's non-legacy + * models, or only the first few in the user's model order for large catalogs. + */ +function listCapabilityModels(input: { + readonly provider: ServerProvider; + readonly preferences: + | { readonly hiddenModels: ReadonlyArray; readonly modelOrder: ReadonlyArray } + | undefined; + readonly isPinned: (model: ServerProviderModel) => boolean; + readonly searchTerms: ReadonlyArray; +}) { + const rank = new Map(input.preferences?.modelOrder.map((slug, index) => [slug, index])); + const visible = input.provider.models + .filter((model) => model.isCustom || !input.preferences?.hiddenModels.includes(model.slug)) + .toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size)); + if (input.searchTerms.length > 0) { + const matches = visible.filter((model) => { + const text = `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); + return input.searchTerms.every((term) => text.includes(term)); + }); + const listed = matches.slice(0, LISTED_MODEL_LIMIT); + return { listed, omitted: matches.length - listed.length }; + } + const current = visible.filter((model) => !model.isLegacy); + const shown = new Set( + FULL_CATALOG_DRIVERS.has(input.provider.driver) + ? current + : current.slice(0, LISTED_MODEL_LIMIT), + ); + const listed = visible.filter((model) => input.isPinned(model) || shown.has(model)); + return { listed, omitted: visible.length - listed.length }; +} + +/** Option ids and allowed values are all a delegating agent needs; labels are UI copy. */ +function capabilityModelOption(descriptor: ProviderOptionDescriptor): OrchestratorMcpModelOption { + const defaultValue = getProviderOptionCurrentValue(descriptor); + return { + id: descriptor.id, + values: + descriptor.type === "boolean" ? [false, true] : descriptor.options.map((choice) => choice.id), + ...(defaultValue === undefined ? {} : { default: defaultValue }), + }; +} + function providerConstraints( provider: ServerProvider | undefined, supportsOrchestrationV2: boolean, @@ -391,7 +473,7 @@ function taskStatusForRun( } } -export function delegatedTaskRun( +function delegatedTaskRun( childProjection: Pick, task: OrchestrationV2Subagent, ): OrchestrationV2Run | undefined { @@ -411,7 +493,7 @@ export function delegatedTaskRun( : childProjection.runs.find((run) => run.id === spawnTransfer.targetRunId); } -export function hasPendingChildRuns( +function hasPendingChildRuns( childProjection: Pick, delegatedRun: OrchestrationV2Run | undefined, ): boolean { @@ -972,7 +1054,9 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null { case "web_search": return jsonText({ patterns: item.patterns, results: item.results }); case "approval_request": - return item.prompt ?? item.requestKind; + return [item.prompt ?? item.requestKind, item.reason] + .filter((line): line is string => line !== undefined) + .join("\n"); case "checkpoint": return jsonText(item.files); case "run_interrupt_request": @@ -1193,7 +1277,7 @@ const make = Effect.gen(function* () { .getThreadShell(threadId) .pipe(Effect.mapError(threadManagementFailure)); if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`); } return yield* loadProjectThread(shell.projectId, threadId); }); @@ -1268,7 +1352,11 @@ const make = Effect.gen(function* () { .getThreadShell(threadId) .pipe(Effect.mapError(threadManagementFailure)); if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); + return yield* threadNotFound( + threadManagement, + threadId, + `Thread ${threadId} is no longer available.`, + ); } const target = yield* threadManagement .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [ @@ -2202,25 +2290,26 @@ const make = Effect.gen(function* () { return { status, secretRef: secretRef.value }; }), - capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope) { + capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope, input = {}) { const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); const settings = yield* serverSettings.getSettings.pipe( Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), ); - const modelPreferences = settings.providerModelPreferences ?? {}; const modelRoles = modelRoleStatuses( settings.modelRoles, providers, orchestrationCapableInstanceIds, yield* Clock.currentTimeMillis, ); + const inherited = parent?.thread.modelSelection; + const searchTerms = input.search?.toLowerCase().split(/\s+/) ?? []; return { parentThreadId: parent?.thread.id ?? null, - inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, - inheritedModel: parent?.thread.modelSelection.model ?? null, - inheritedOptions: parent?.thread.modelSelection.options ?? [], + inheritedProviderInstanceId: inherited?.instanceId ?? null, + inheritedModel: inherited?.model ?? null, + inheritedOptions: inherited?.options ?? [], runtimeMode: limits.runtimeMode, interactionMode: limits.interactionMode, modelRoles, @@ -2229,24 +2318,33 @@ const make = Effect.gen(function* () { provider, orchestrationCapableInstanceIds.has(provider.instanceId), ); + const { listed, omitted } = listCapabilityModels({ + provider, + preferences: settings.providerModelPreferences?.[provider.instanceId], + searchTerms, + isPinned: (model) => + model.isCustom || + (inherited?.instanceId === provider.instanceId && inherited.model === model.slug) || + (settings.favorites ?? []).some( + (favorite) => + favorite.provider === provider.instanceId && favorite.model === model.slug, + ), + }); return { providerInstanceId: provider.instanceId, driverKind: provider.driver, - displayName: provider?.displayName ?? null, - models: - provider?.models - .filter( - (model) => - model.isCustom || - !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug), - ) - .map((model) => ({ - id: model.slug, - label: model.name ?? null, - ...(model.capabilities?.optionDescriptors === undefined - ? {} - : { options: model.capabilities.optionDescriptors }), - })) ?? [], + displayName: provider.displayName ?? null, + models: listed.map((model) => { + const options = model.capabilities?.optionDescriptors ?? []; + return { + id: model.slug, + ...(model.name.toLowerCase() === model.slug.toLowerCase() + ? {} + : { label: model.name }), + ...(options.length === 0 ? {} : { options: options.map(capabilityModelOption) }), + }; + }), + ...(omitted === 0 ? {} : { omittedModels: omitted }), ...(provider.supportedRuntimeModes === undefined || provider.supportedRuntimeModes.length === 0 ? {} @@ -2667,12 +2765,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); @@ -2685,6 +2808,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.fork.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.fork.test.ts new file mode 100644 index 0000000000000..ec312faafaef2 --- /dev/null +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.fork.test.ts @@ -0,0 +1,4976 @@ +import * as Queue from "effect/Queue"; +import * as IssueLinks from "../issues/IssueLinks.ts"; +import * as IssueInbox from "../issues/IssueInbox.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +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, + MessageId, + NodeId, + RunId, + type ModelSelection, + type OrchestrationV2UserInputQuestion, + 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 ProviderUserInputAnswers, + 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 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 * as DontAskModeWorker from "../orchestration-v2/DontAskModeWorker.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.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"; + +import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +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 * as McpSessionRegistry from "./McpSessionRegistry.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { HttpServer } from "effect/http"; +import * as NetAddress from "effect/net/NetAddress"; + +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), +); + +// 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 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; + +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; + readonly model: string; + readonly optionDescriptors?: ReadonlyArray; +}): ServerProvider { + return { + instanceId: input.instanceId, + driver: input.driver, + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-17T00:00:00.000Z", + models: [ + { + slug: input.model, + name: input.model, + isCustom: false, + capabilities: + input.optionDescriptors === undefined + ? null + : { optionDescriptors: input.optionDescriptors }, + }, + ], + slashCommands: [], + skills: [], + }; +} + +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 respondToRuntimeRequest?: ( + response: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput, + ) => Effect.Effect; + 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: input.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* () { + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + const current = yield* orchestrator.getThreadProjection(threadId); + if (predicate(current)) return current; + const result = yield* orchestrator.streamStoredEventsFrom({ threadId, afterSequence }).pipe( + Stream.mapEffect(() => orchestrator.getThreadProjection(threadId)), + Stream.filter(predicate), + Stream.runHead, + ); + if (Option.isNone(result)) return yield* Effect.die(`Projection stream ended for ${threadId}.`); + return result.value; + }); +} + +const client = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "orchestrator-mcp-test", version: "1.0.0" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "orchestrator-mcp-test", version: "1.0.0" }, + }, + 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({ + get: (name) => Effect.succeed(Option.fromNullishOr(stored.get(name))), + set: (name, value) => Effect.sync(() => void stored.set(name, value)), + create: (name, value) => Effect.sync(() => void stored.set(name, value)), + getOrCreateRandom: (name, bytes) => + Effect.sync(() => { + const existing = stored.get(name); + if (existing) return existing; + const value = new Uint8Array(bytes).fill(7); + stored.set(name, value); + return value; + }), + remove: (name) => Effect.sync(() => void stored.delete(name)), + }); +}); + +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 approvalResponse = + yield* Deferred.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}`, + respondToRuntimeRequest: (response) => + Deferred.succeed(approvalResponse, response).pipe(Effect.asVoid), + }), + 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 continuationOfferSignals = yield* Queue.unbounded(); + const layerContinuationProbe = Layer.succeed( + ProviderContinuationRequests.ProviderContinuationRequests, + { + offer: (request) => + Ref.update(continuationOffers, (existing) => [...existing, request]).pipe( + Effect.andThen(Queue.offer(continuationOfferSignals, undefined)), + Effect.andThen( + request.threadId === approvalParentId + ? Deferred.succeed(approvalOffered, undefined) + : Effect.void, + ), + ), + take: Effect.never, + }, + ); + const waitForContinuationOffers = (count: number) => + Effect.gen(function* () { + while (true) { + const current = yield* Ref.get(continuationOffers); + if (current.length >= count) return current; + yield* Queue.take(continuationOfferSignals); + } + }); + const expectOffersToStay = (count: number) => + Effect.gen(function* () { + yield* (yield* EffectWorker.OrchestrationEffectWorkerV2).drain(); + expect(yield* Ref.get(continuationOffers)).toHaveLength(count); + }); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { + name: "orchestrator-mcp-toolkit", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ).pipe(Layer.provide(layerContinuationProbe)); + const layerOrchestration = Layer.merge( + layerOrchestrator, + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), + ); + const layerProviderRegistry = ProviderRegistryMock.layer([ + makeProviderSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: codexModel, + optionDescriptors: [ + { + id: "reasoning", + label: "Reasoning effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ], + }), + makeProviderSnapshot({ + instanceId: claudeInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + model: claudeModel, + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + }, + { id: "fastMode", label: "Fast mode", type: "boolean" }, + ], + }), + makeProviderSnapshot({ + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + model: "opencode/test", + }), + ]); + const scheduledStore = yield* Ref.make>([]); + const layerScheduledTaskStub = Layer.succeed( + ScheduledTaskService.ScheduledTaskService, + ScheduledTaskService.ScheduledTaskService.of({ + list: () => Ref.get(scheduledStore).pipe(Effect.map((tasks) => ({ tasks }))), + subscribeList: () => Stream.empty, + upsert: (input) => + Effect.gen(function* () { + const task = scheduledTaskFromUpsert(input); + yield* Ref.update(scheduledStore, (all) => [ + ...all.filter((candidate) => candidate.id !== task.id), + task, + ]); + return { task }; + }), + setEnabled: () => + Effect.die("ScheduledTaskService.setEnabled is unused in this test"), + delete: (input) => + Ref.update(scheduledStore, (all) => + all.filter((candidate) => candidate.id !== input.id), + ).pipe(Effect.as({ id: input.id })), + deleteForThread: () => Effect.die("unused in this test"), + runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), + rotateWebhookToken: () => Effect.die("unused in this test"), + listWebhookDeliveries: () => Effect.die("unused in this test"), + getWebhookDelivery: () => Effect.die("unused in this test"), + triggerWebhook: () => Effect.die("unused in this test"), + }), + ); + const layerTest = Layer.merge( + layerOrchestratorToolkit, + McpHttpServer.layerThreadToolkit, + ).pipe( + Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), + Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(layerOrchestration), + Layer.provide(layerRegistry), + Layer.provide(layerProviderRegistry), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge( + ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + 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.provideMerge(McpProviderSessions.layer), + 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 mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const credentialRegistry = yield* McpSessionRegistry.__testing.make().pipe( + Effect.provide(NodeCrypto.layer), + Effect.provideService( + HttpServer.HttpServer, + HttpServer.HttpServer.of({ + address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 43123), + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], + }), + ), + Effect.provideService( + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(invocation.environmentId), + getDescriptor: Effect.die("unused"), + }), + ), + ); + const scopeForThread = (projection: OrchestrationV2ThreadProjection) => + Effect.gen(function* () { + const providerThread = projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + ); + if (providerThread?.providerSessionId == null) + return yield* Effect.die( + `Thread ${projection.thread.id} has no active provider session.`, + ); + const issued = yield* credentialRegistry.issue({ + threadId: projection.thread.id, + providerInstanceId: providerThread.providerInstanceId, + }); + yield* mcpSessions.set(issued.config); + const scope = yield* credentialRegistry.resolve( + issued.config.authorizationHeader.replace(/^Bearer\s+/, ""), + ); + if (scope === undefined) + return yield* Effect.die("The issued MCP credential did not resolve."); + expect(scope.thread.providerSessionId).toMatch(/^[0-9a-f-]{36}$/); + expect(scope.thread.providerSessionId).not.toBe(providerThread.providerSessionId); + return scope; + }); + const startQuestion = ( + callScope: McpInvocationContext.McpInvocationScope, + questions: ReadonlyArray, + ) => + Effect.gen(function* () { + const questionId = questions[0]?.id; + if (questionId === undefined) + return yield* Effect.die("A T3 user-input test needs at least one question."); + const afterSequence = yield* orchestrator.getThreadEventSequence( + callScope.thread!.threadId, + ); + const call = yield* invokeAs(callScope, "t3_request_user_input", { + questions, + }).pipe(Effect.forkChild); + const pendingEvent = yield* orchestrator + .streamStoredEventsFrom({ + threadId: callScope.thread!.threadId, + afterSequence, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "turn-item.updated" && + stored.event.payload.type === "user_input_request" && + stored.event.payload.status === "waiting" && + stored.event.payload.questions[0]?.id === questionId, + ), + Stream.runHead, + Effect.orDie, + Effect.raceFirst( + Fiber.join(call).pipe( + Effect.flatMap((result) => + Effect.die( + new Error( + `Question tool ended before creating its card: ${JSON.stringify(declaredFailure(result))}`, + ), + ), + ), + ), + ), + ); + if (Option.isNone(pendingEvent)) + return yield* Effect.die("The T3 question card was not created."); + const event = pendingEvent.value.event; + if (event.type !== "turn-item.updated") + return yield* Effect.die("The T3 question card event was malformed."); + const item = event.payload; + if (item.type !== "user_input_request") + return yield* Effect.die("The T3 question card event was malformed."); + const projection = yield* orchestrator.getThreadProjection( + callScope.thread!.threadId, + ); + expect( + projection.runtimeRequests.find((request) => request.id === item.requestId) + ?.responseCapability.type, + ).toBe("app_owned"); + expect( + projection.turnItems.find((candidate) => candidate.id === item.id), + ).toMatchObject({ status: "waiting", questions }); + return { call, requestId: item.requestId, event }; + }); + const answerQuestion = ( + callScope: McpInvocationContext.McpInvocationScope, + questions: ReadonlyArray, + answers: ProviderUserInputAnswers, + ) => + Effect.gen(function* () { + const pending = yield* startQuestion(callScope, questions); + const messagesBeforeAnswer = (yield* orchestrator.getThreadProjection( + callScope.thread!.threadId, + )).messages.length; + const response = yield* invokeAs(callScope, "t3_pending_request_respond", { + requestId: pending.requestId, + answers, + }); + expect(response.isError).toBe(false); + const call = yield* Fiber.join(pending.call); + expect(call.isError).toBe(false); + expect(call.structuredContent).toEqual(answers); + const projection = yield* orchestrator.getThreadProjection( + callScope.thread!.threadId, + ); + expect(projection.messages).toHaveLength(messagesBeforeAnswer); + expect( + projection.runtimeRequests.find((request) => request.id === pending.requestId), + ).toMatchObject({ status: "resolved", answers }); + const item = projection.turnItems.find( + (candidate) => + candidate.type === "user_input_request" && + candidate.requestId === pending.requestId, + ); + expect(item).toMatchObject({ + type: "user_input_request", + questionAnswer: { requestId: pending.requestId, answers }, + }); + return pending.requestId; + }); + + 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; + let scopeForCaller = yield* scopeForThread(parent); + const areasQuestion: OrchestrationV2UserInputQuestion = { + id: "areas", + header: "Areas", + question: "Which areas should this change cover?", + options: [ + { label: "API", description: "Server and contracts" }, + { label: "Web", description: "Browser client" }, + ], + multiSelect: true, + }; + const multiSelectQuestions: ReadonlyArray = [ + areasQuestion, + { + id: "platform", + header: "Platform", + question: "Which platform should be prioritized?", + options: [ + { label: "Server", description: "T3 hosted locally" }, + { label: "Web", description: "T3 hosted remotely" }, + ], + multiSelect: false, + }, + ]; + const multipleAnswers: ProviderUserInputAnswers = { + areas: ["API", "Web"], + platform: "Web", + }; + yield* answerQuestion(scopeForCaller, multiSelectQuestions, multipleAnswers); + + // Owner-visible MCP questions still get the recorded away-policy answer. + const settings = yield* ServerSettings.ServerSettingsService; + const dontAsk = yield* DontAskModeWorker.make; + yield* settings.updateSettings({ dontAskMode: true }); + const awayQuestion = yield* startQuestion(scopeForCaller, [areasQuestion]); + yield* dontAsk.sweep; + expect((yield* Fiber.join(awayQuestion.call)).structuredContent).toEqual( + DontAskModeWorker.dontAskAnswers([areasQuestion]), + ); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.find( + (item) => + item.type === "user_input_request" && item.requestId === awayQuestion.requestId, + ), + ).toMatchObject({ + respondedBy: "dont_ask_mode", + questionAnswer: { answers: DontAskModeWorker.dontAskAnswers([areasQuestion]) }, + }); + yield* settings.updateSettings({ dontAskMode: false }); + + const supersededScope = scopeForCaller; + scopeForCaller = yield* scopeForThread( + yield* orchestrator.getThreadProjection(parentThreadId), + ); + const staleSessionCall = yield* invokeAs(supersededScope, "t3_request_user_input", { + questions: [areasQuestion], + }); + expect(declaredFailure(staleSessionCall)).toMatchObject({ code: "parent_not_active" }); + + const answeredBeforeSubscribeId = RuntimeRequestId.make( + "request:answer-before-subscribe", + ); + const answeredBeforeSubscribeProjection = + yield* orchestrator.getThreadProjection(parentThreadId); + const answerBeforeSubscribeRun = answeredBeforeSubscribeProjection.runs.find( + (run) => run.status === "running", + ); + const answerBeforeSubscribeThread = + answeredBeforeSubscribeProjection.providerThreads.find( + (thread) => thread.id === answerBeforeSubscribeRun?.providerThreadId, + ); + if ( + answerBeforeSubscribeRun === undefined || + answerBeforeSubscribeThread?.providerSessionId == null + ) + return yield* Effect.die( + "The Codex caller lost its active run before the race check.", + ); + const answerBeforeSubscribeCreated = yield* orchestrator.dispatch({ + type: "runtime-request.create-user-input", + commandId: CommandId.make("command:answer-before-subscribe:create"), + threadId: parentThreadId, + requestId: answeredBeforeSubscribeId, + runId: answerBeforeSubscribeRun.id, + providerSessionId: answerBeforeSubscribeThread.providerSessionId, + questions: [areasQuestion], + }); + const answerBeforeSubscribe = { areas: ["API", "Web"] }; + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId: CommandId.make("command:answer-before-subscribe:respond"), + threadId: parentThreadId, + requestId: answeredBeforeSubscribeId, + answers: answerBeforeSubscribe, + }); + const replayedAnswer = yield* orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: answerBeforeSubscribeCreated.sequence, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "runtime-request.updated" && + stored.event.payload.id === answeredBeforeSubscribeId && + stored.event.payload.status === "resolved", + ), + Stream.runHead, + Effect.orDie, + ); + expect( + Option.isSome(replayedAnswer) && + replayedAnswer.value.event.type === "runtime-request.updated" && + replayedAnswer.value.event.payload.answers, + ).toEqual(answerBeforeSubscribe); + + const staleRunCommand = yield* orchestrator + .dispatch({ + type: "runtime-request.create-user-input", + commandId: CommandId.make("command:mcp-parent:stale-run"), + threadId: parentThreadId, + requestId: RuntimeRequestId.make("request:mcp-parent:stale-run"), + runId: RunId.make("run:mcp-parent:stale"), + providerSessionId: answerBeforeSubscribeThread.providerSessionId, + questions: [areasQuestion], + }) + .pipe(Effect.result); + expect(staleRunCommand._tag).toBe("Failure"); + + const duplicateQuestionCall = yield* invokeAs(scopeForCaller, "t3_request_user_input", { + questions: [areasQuestion, areasQuestion], + }); + expect(declaredFailure(duplicateQuestionCall)).toMatchObject({ + code: "orchestration_error", + }); + const tooManyOptionsCall = yield* invokeAs(scopeForCaller, "t3_request_user_input", { + questions: [ + { + ...areasQuestion, + id: "too-many-options", + options: Array.from({ length: 21 }, (_, index) => ({ + label: `Option ${index + 1}`, + description: "A bounded option.", + })), + }, + ], + }); + expect(declaredFailure(tooManyOptionsCall)).toMatchObject({ + code: "orchestration_error", + }); + + const dismissQuestion: OrchestrationV2UserInputQuestion = { + id: "dismiss", + header: "Dismiss", + question: "Should this request be dismissed?", + options: [ + { label: "Yes", description: "Dismiss the request" }, + { label: "No", description: "Keep the request open" }, + ], + multiSelect: false, + }; + const dismissedQuestion = yield* startQuestion(scopeForCaller, [dismissQuestion]); + yield* orchestrator.dispatch({ + type: "thread.user-input.dismiss", + commandId: CommandId.make("command:mcp-parent:question-dismiss"), + threadId: parentThreadId, + requestId: dismissedQuestion.requestId, + }); + const dismissedCall = yield* Fiber.join(dismissedQuestion.call); + expect(declaredFailure(dismissedCall)).toMatchObject({ code: "invalid_request" }); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).runtimeRequests.find( + (request) => request.id === dismissedQuestion.requestId, + ), + ).toMatchObject({ status: "resolved", decision: "cancel" }); + + const stopQuestionThreadId = ThreadId.make("thread:mcp-question-stop"); + const stopQuestionProjectId = ProjectId.make("project:mcp-question-stop"); + const stopQuestionGate = yield* Deferred.make(); + parentTerminalGates.set(stopQuestionThreadId, stopQuestionGate); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:mcp-question-stop:create"), + createdBy: "user", + creationSource: "web", + threadId: stopQuestionThreadId, + projectId: stopQuestionProjectId, + title: "Question tool interruption caller", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const beforeStopRun = yield* orchestrator.getThreadEventSequence(stopQuestionThreadId); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-question-stop:start"), + threadId: stopQuestionThreadId, + messageId: MessageId.make("message:mcp-question-stop:start"), + text: "Keep the stop test caller active.", + attachments: [], + modelSelection: codexSelection, + dispatchMode: { type: "start_immediately" }, + }); + const stopTurnStarted = yield* orchestrator + .streamStoredEventsFrom({ + threadId: stopQuestionThreadId, + afterSequence: beforeStopRun, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "provider-turn.updated" && + stored.event.payload.status === "running", + ), + Stream.runHead, + Effect.orDie, + ); + expect(Option.isSome(stopTurnStarted)).toBe(true); + const stopQuestionProjection = + yield* orchestrator.getThreadProjection(stopQuestionThreadId); + const stopScope = yield* scopeForThread(stopQuestionProjection); + const stopQuestion = yield* startQuestion(stopScope, [dismissQuestion]); + const stopRun = stopQuestionProjection.runs.find((run) => run.status === "running"); + if (stopRun === undefined) + return yield* Effect.die("The Codex stop test caller lost its active run."); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:mcp-question-stop:interrupt"), + threadId: stopQuestionThreadId, + runId: stopRun.id, + reason: "Stop the agent while it is asking a question.", + }); + const stoppedCall = yield* Fiber.join(stopQuestion.call); + expect(declaredFailure(stoppedCall)).toMatchObject({ code: "invalid_request" }); + expect( + (yield* orchestrator.getThreadProjection(stopQuestionThreadId)).runtimeRequests.find( + (request) => request.id === stopQuestion.requestId, + ), + ).toMatchObject({ status: "resolved", decision: "cancel" }); + + const endedQuestionThreadId = ThreadId.make("thread:mcp-question-turn-ended"); + const endedQuestionProjectId = ProjectId.make("project:mcp-question-turn-ended"); + const endedQuestionGate = yield* Deferred.make(); + parentTerminalGates.set(endedQuestionThreadId, endedQuestionGate); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:mcp-question-turn-ended:create"), + createdBy: "user", + creationSource: "web", + threadId: endedQuestionThreadId, + projectId: endedQuestionProjectId, + title: "Question tool terminal caller", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const beforeEndedRun = + yield* orchestrator.getThreadEventSequence(endedQuestionThreadId); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-question-turn-ended:start"), + threadId: endedQuestionThreadId, + messageId: MessageId.make("message:mcp-question-turn-ended:start"), + text: "Keep the terminal test caller active until its question is pending.", + attachments: [], + modelSelection: codexSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator + .streamStoredEventsFrom({ + threadId: endedQuestionThreadId, + afterSequence: beforeEndedRun, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "provider-turn.updated" && + stored.event.payload.status === "running", + ), + Stream.runHead, + Effect.orDie, + ); + const endedQuestionProjection = + yield* orchestrator.getThreadProjection(endedQuestionThreadId); + const endedScope = yield* scopeForThread(endedQuestionProjection); + const endedQuestion = yield* startQuestion(endedScope, [dismissQuestion]); + const endedRun = endedQuestionProjection.runs.find((run) => run.status === "running"); + if (endedRun === undefined) + return yield* Effect.die("The terminal test caller lost its active run."); + const beforeTerminal = + yield* orchestrator.getThreadEventSequence(endedQuestionThreadId); + yield* Deferred.succeed(endedQuestionGate, undefined); + yield* orchestrator + .streamStoredEventsFrom({ + threadId: endedQuestionThreadId, + afterSequence: beforeTerminal, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === endedRun.id && + stored.event.payload.status === "completed", + ), + Stream.runHead, + Effect.orDie, + ); + const endedCall = yield* Fiber.join(endedQuestion.call); + expect(declaredFailure(endedCall)).toMatchObject({ code: "invalid_request" }); + expect( + (yield* orchestrator.getThreadProjection(endedQuestionThreadId)).runtimeRequests.find( + (request) => request.id === endedQuestion.requestId, + ), + ).toMatchObject({ status: "resolved", decision: "cancel" }); + 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, + }), + ], + }), + ]), + }); + + const scheduleTool = server.tools.find(({ tool }) => tool.name === "schedule_task"); + expect(scheduleTool?.tool.annotations?.destructiveHint).toBe(true); + const scheduleCall = yield* invoke("schedule_task", { + prompt: "wake up in this thread and say hello", + schedule: { type: "interval", everyMs: 60_000 }, + clientRequestId: "schedule-hello-1", + }); + expect(scheduleCall.isError).toBe(false); + expect(scheduleCall.structuredContent).toMatchObject({ + boundThreadId: parentThreadId, + projectId, + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + title: "wake up in this thread and say hello", + }); + const scheduledTaskId = (scheduleCall.structuredContent as { scheduledTaskId: string }) + .scheduledTaskId; + const storedAfterCreate = yield* Ref.get(scheduledStore); + expect(storedAfterCreate).toHaveLength(1); + expect(storedAfterCreate[0]).toMatchObject({ + threadId: parentThreadId, + projectId, + createdBy: "agent", + creationSource: "mcp", + modelSelection: codexSelection, + }); + + const scheduledListCall = yield* invoke("list_scheduled_tasks", {}); + expect(scheduledListCall.isError).toBe(false); + expect(scheduledListCall.structuredContent).toMatchObject({ + tasks: [{ scheduledTaskId, boundThreadId: parentThreadId }], + }); + + const scheduledUpdateCall = yield* invoke("update_scheduled_task", { + scheduledTaskId, + enabled: false, + }); + expect(scheduledUpdateCall.isError).toBe(false); + expect(scheduledUpdateCall.structuredContent).toMatchObject({ + scheduledTaskId, + enabled: false, + }); + + const scheduledDeleteCall = yield* invoke("delete_scheduled_task", { scheduledTaskId }); + expect(scheduledDeleteCall.isError).toBe(false); + expect(scheduledDeleteCall.structuredContent).toMatchObject({ + scheduledTaskId, + deleted: true, + }); + expect(yield* Ref.get(scheduledStore)).toHaveLength(0); + + // OpenCode 1.15 has emitted this exact nested-object-as-JSON-string + // shape. Decode it at the MCP boundary rather than failing a task + // the model otherwise specified correctly. + const serializedScheduleCall = yield* invoke("schedule_task", { + prompt: "check for new pull requests", + schedule: '{"type":"interval","everyMs":3600000}', + clientRequestId: "schedule-opencode-compat-1", + }); + expect(serializedScheduleCall.isError).toBe(false); + expect(serializedScheduleCall.structuredContent).toMatchObject({ + schedule: { type: "interval", everyMs: 3_600_000 }, + boundThreadId: parentThreadId, + }); + const serializedScheduledTaskId = ( + serializedScheduleCall.structuredContent as { scheduledTaskId: string } + ).scheduledTaskId; + yield* invoke("delete_scheduled_task", { + scheduledTaskId: serializedScheduledTaskId, + }); + expect(yield* Ref.get(scheduledStore)).toHaveLength(0); + + const secretRequests = yield* SecretRequests.SecretRequests; + const secretEvents = yield* EventSink.EventSinkV2; + const beforeSecret = yield* secretEvents.latestSequence(); + 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); + yield* secretEvents.stream({ afterSequence: beforeSecret, eventType: "turn-item.updated" }).pipe( + Stream.filter((stored) => stored.event.type === "turn-item.updated" && stored.event.threadId === parentThreadId && stored.event.payload.type === "secret_request" && stored.event.payload.secretStatus === "pending"), + Stream.take(1), + Stream.runDrain, + ); + const asked = yield* orchestrator.getThreadProjection(parentThreadId); + 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 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); + + // A blocking wait returns the child's question instead of holding it until timeout, + // and hands later delivery to the completion wake. + const tasksBeforeQuestion = new Set( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.map( + (task) => task.id, + ), + ); + const questionWait = yield* invoke("delegate_task", { + task: cancellationPrompt, + target: { providerInstanceId: codexInstanceId, model: codexModel }, + mode: "wait", + timeoutMs: 600_000, + clientRequestId: "delegate-question-wait-1", + }).pipe(Effect.forkChild); + const questionTask = (yield* waitForProjection( + orchestrator, + parentThreadId, + (projection) => + projection.subagents.some( + (task) => !tasksBeforeQuestion.has(task.id) && task.childThreadId !== null, + ), + )).subagents.find((task) => !tasksBeforeQuestion.has(task.id))!; + const questionChildThreadId = questionTask.childThreadId!; + yield* waitForProjection( + orchestrator, + questionChildThreadId, + (projection) => + projection.runs.some((run) => run.status === "running") && + projection.providerTurns.some((turn) => turn.status === "running"), + ); + const questionChild = yield* orchestrator.getThreadProjection(questionChildThreadId); + // An active orchestrator, including a blocking delegation wait, owns its child's answer. + yield* settings.updateSettings({ dontAskMode: true }); + const childScope = yield* scopeForThread(questionChild); + const routedQuestion = yield* startQuestion(childScope, [areasQuestion]); + const waitingQuestion = yield* Fiber.join(questionWait); + const routedProjection = yield* orchestrator.getThreadProjection(questionChildThreadId); + expect( + routedProjection.runtimeRequests.find( + (request) => request.id === routedQuestion.requestId, + ), + ).toMatchObject({ status: "pending", routedTo: parentThreadId }); + yield* dontAsk.handle(routedQuestion.event); + yield* dontAsk.sweep; + const pendingList = yield* invoke("t3_pending_request_list", { + threadId: questionChildThreadId, + }); + const parentAnswer = { areas: "Web" }; + const routedResponse = yield* invoke("t3_pending_request_respond", { + threadId: questionChildThreadId, + requestId: routedQuestion.requestId, + answers: parentAnswer, + }); + expect(declaredFailure(routedResponse)).toBeUndefined(); + expect(routedResponse.isError).toBe(false); + expect(pendingList.structuredContent).toEqual({ + requestIds: [routedQuestion.requestId], + }); + expect((yield* Fiber.join(routedQuestion.call)).structuredContent).toEqual( + parentAnswer, + ); + yield* settings.updateSettings({ dontAskMode: false }); + + const questionProviderThread = questionChild.providerThreads.find( + (thread) => thread.id === questionChild.thread.activeProviderThreadId, + )!; + const questionRequestId = routedQuestion.requestId; + const questionNodeId = routedProjection.runtimeRequests.find( + (request) => request.id === questionRequestId, + )!.nodeId; + const questionAt = yield* DateTime.now; + const questionResult = yield* decodeDelegateTaskResult( + waitingQuestion.structuredContent, + ).pipe(Effect.orDie); + expect(questionResult).toMatchObject({ + taskId: questionTask.id, + status: "running", + waitTimedOut: false, + waitingOnUser: { + kind: "input", + requestIds: [routedQuestion.requestId], + waitingOn: "orchestrator", + preview: expect.stringContaining(areasQuestion.question), + }, + }); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === questionTask.id, + )?.completionWake, + ).toBe("always"); + const answeredQuestionChild = + yield* orchestrator.getThreadProjection(questionChildThreadId); + const answeredQuestionItem = answeredQuestionChild.turnItems.find( + (item) => item.type === "user_input_request" && item.requestId === questionRequestId, + ); + expect(answeredQuestionItem).toMatchObject({ + status: "completed", + respondedBy: "orchestrator", + }); + if (answeredQuestionItem?.type !== "user_input_request") + return yield* Effect.die("The answered question was not found."); + const { + type: _type, + questions: _questions, + questionAnswer: _questionAnswer, + respondedBy: _respondedBy, + ...approvalItemBase + } = answeredQuestionItem; + const approvalRequestId = RuntimeRequestId.make("request:native-child-approval"); + // This parent answers in its blocking call; asynchronous wake delivery is tested separately. + yield* orchestrator.dispatch({ + type: "delegated_task.wake-policy", + commandId: CommandId.make("command:native-approval-blocking-wait"), + parentThreadId, + taskId: questionTask.id, + completionWake: "settled_only", + }); + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + id: EventId.make("event:native-child-approval-request"), + type: "runtime-request.updated", + threadId: questionChildThreadId, + occurredAt: questionAt, + payload: { + id: approvalRequestId, + nodeId: questionNodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: questionProviderThread.providerSessionId!, + }, + createdAt: questionAt, + resolvedAt: null, + }, + }, + { + id: EventId.make("event:native-child-approval-item"), + type: "turn-item.updated", + threadId: questionChildThreadId, + occurredAt: questionAt, + payload: { + ...approvalItemBase, + id: TurnItemId.make("item:native-child-approval"), + type: "approval_request", + requestId: approvalRequestId, + requestKind: "command", + status: "waiting", + completedAt: null, + prompt: "cat /tmp/requested-file", + }, + }, + ], + }); + const readApproval = yield* invoke("t3_pending_request_read", { + threadId: questionChildThreadId, + requestId: approvalRequestId, + }); + expect(readApproval.structuredContent).toMatchObject({ + requestId: approvalRequestId, + requestKind: "command", + prompt: "cat /tmp/requested-file", + }); + yield* settings.updateSettings({ dontAskMode: true }); + yield* dontAsk.sweep; + const approveChild = yield* invoke("t3_pending_request_respond", { + threadId: questionChildThreadId, + requestId: approvalRequestId, + decision: "accept", + }); + expect(approveChild.isError).toBe(false); + yield* settings.updateSettings({ dontAskMode: false }); + expect(yield* Deferred.await(approvalResponse)).toEqual({ + requestId: approvalRequestId, + decision: "accept", + }); + expect( + (yield* orchestrator.getThreadProjection(questionChildThreadId)).turnItems.find( + (item) => item.type === "approval_request" && item.requestId === approvalRequestId, + ), + ).toMatchObject({ status: "completed", respondedBy: "orchestrator" }); + // Clean up without a parent wake: settle delivery before the child terminalizes. + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.dispose", + commandId: CommandId.make("command:mcp-parent:dispose-question-wait"), + parentThreadId, + taskId: questionTask.id, + }); + yield* invoke("task_cancel", { + taskId: questionTask.id, + reason: "Clean up the question child.", + clientRequestId: "cancel-question-wait-1", + }); + yield* waitForProjection(orchestrator, parentThreadId, (projection) => + projection.subagents.some( + (task) => task.id === questionTask.id && task.status === "interrupted", + ), + ); + yield* expectOffersToStay(0); + + const liveQuestionWait = 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(liveQuestionWait)).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, + }); + yield* persistRequest({ + ...liveApproval, + responseCapability: { + type: "app_owned", + providerSessionId: approvalChild.providerSessions[0]!.id, + }, + }); + expect((yield* readApprovalStatus()).pendingApproval).toEqual(expectedApproval); + yield* persistRequest(liveApproval); + 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)); + }), + ), + ); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index f83abcba6d24f..09b4bd4f424d7 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,83 +1,37 @@ -import * as IssueLinks from "../issues/IssueLinks.ts"; -import * as IssueInbox from "../issues/IssueInbox.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; 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 OrchestrationV2UserInputQuestion, - 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 ProviderUserInputAnswers, - 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 * as DontAskModeWorker from "../orchestration-v2/DontAskModeWorker.ts"; -import * as ProjectionStore from "../orchestration-v2/ProjectionStore.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"; @@ -92,12 +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 * as McpSessionRegistry from "./McpSessionRegistry.ts"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import { HttpServer } from "effect/http"; -import * as NetAddress from "effect/net/NetAddress"; -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)({})), @@ -106,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, @@ -163,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; @@ -205,265 +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 respondToRuntimeRequest?: ( - response: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput, - ) => Effect.Effect; - 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: input.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* () { - const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); - const current = yield* orchestrator.getThreadProjection(threadId); - if (predicate(current)) return current; - const result = yield* orchestrator.streamStoredEventsFrom({ threadId, afterSequence }).pipe( - Stream.mapEffect(() => orchestrator.getThreadProjection(threadId)), - Stream.filter(predicate), - Stream.runHead, - ); - if (Option.isNone(result)) return yield* Effect.die(`Projection stream ended for ${threadId}.`); - return result.value; - }); -} - const client = McpSchema.McpServerClient.of({ clientId: 1, protocolVersion: "2025-06-18", @@ -477,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({ @@ -552,4510 +172,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 approvalResponse = - yield* Deferred.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}`, - respondToRuntimeRequest: (response) => - Deferred.succeed(approvalResponse, response).pipe(Effect.asVoid), - }), - makeDeterministicAdapter({ - instanceId: claudeInstanceId, - driver: ProviderDriverKind.make("claudeAgent"), - capabilities: ClaudeProviderCapabilitiesV2, - capturedTurns, - shouldComplete: (turn) => turn.message.text !== cancellationPrompt, - terminalGate: (turn) => - turn.message.text.startsWith("Delegated task") || - turn.message.text.startsWith("Delegated tasks") - ? deliveryTerminalGates.get(turn.threadId) - : parentTerminalGates.get(turn.threadId), - response: (turn) => - turn.message.text === delegatedPrompt - ? delegatedResult - : `Claude completed: ${turn.message.text}`, - steerReceived: (steer) => steerReceivedGates.get(steer.message.text), - }), - ]); - const approvalParentId = ThreadId.make("thread:mcp-approval-parent"); - const approvalOffered = yield* Deferred.make(); - const continuationOffers = yield* Ref.make< - ReadonlyArray - >([]); - const layerContinuationProbe = Layer.succeed( - ProviderContinuationRequests.ProviderContinuationRequests, - { - offer: (request) => - Ref.update(continuationOffers, (existing) => [...existing, request]).pipe( - Effect.andThen( - request.threadId === approvalParentId - ? Deferred.succeed(approvalOffered, undefined) - : Effect.void, - ), - ), - take: Effect.never, - }, - ); - const waitForContinuationOffers = (count: number) => - Effect.gen(function* () { - for (let attempt = 0; attempt < 1_000; attempt += 1) { - const current = yield* Ref.get(continuationOffers); - if (current.length >= count) { - return current; - } - yield* Effect.sleep("5 millis"); - } - return yield* Ref.get(continuationOffers); - }); - const expectOffersToStay = (count: number) => - Effect.gen(function* () { - for (let sample = 0; sample < 4; sample += 1) { - yield* Effect.sleep("50 millis"); - expect(yield* Ref.get(continuationOffers)).toHaveLength(count); - } - }); - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { - name: "orchestrator-mcp-toolkit", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ).pipe(Layer.provide(layerContinuationProbe)); - const layerOrchestration = Layer.merge( - layerOrchestrator, - ThreadManagementService.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), - ), - ), - ); - const layerProviderRegistry = ProviderRegistryMock.layer([ - makeProviderSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: codexModel, - optionDescriptors: [ - { - id: "reasoning", - label: "Reasoning effort", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - }, - ], - }), - makeProviderSnapshot({ - instanceId: claudeInstanceId, - driver: ProviderDriverKind.make("claudeAgent"), - model: claudeModel, - optionDescriptors: [ - { - id: "effort", - label: "Reasoning", - type: "select", - options: [ - { id: "high", label: "High" }, - { id: "max", label: "Max" }, - ], - }, - { id: "fastMode", label: "Fast mode", type: "boolean" }, - ], - }), - makeProviderSnapshot({ - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - model: "opencode/test", - }), - ]); - const scheduledStore = yield* Ref.make>([]); - const layerScheduledTaskStub = Layer.succeed( - ScheduledTaskService.ScheduledTaskService, - ScheduledTaskService.ScheduledTaskService.of({ - list: () => Ref.get(scheduledStore).pipe(Effect.map((tasks) => ({ tasks }))), - subscribeList: () => Stream.empty, - upsert: (input) => - Effect.gen(function* () { - const task = scheduledTaskFromUpsert(input); - yield* Ref.update(scheduledStore, (all) => [ - ...all.filter((candidate) => candidate.id !== task.id), - task, - ]); - return { task }; - }), - setEnabled: () => - Effect.die("ScheduledTaskService.setEnabled is unused in this test"), - delete: (input) => - Ref.update(scheduledStore, (all) => - all.filter((candidate) => candidate.id !== input.id), - ).pipe(Effect.as({ id: input.id })), - deleteForThread: () => Effect.die("unused in this test"), - runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), - rotateWebhookToken: () => Effect.die("unused in this test"), - listWebhookDeliveries: () => Effect.die("unused in this test"), - getWebhookDelivery: () => Effect.die("unused in this test"), - triggerWebhook: () => Effect.die("unused in this test"), - }), - ); - const layerTest = Layer.merge( - layerOrchestratorToolkit, - McpHttpServer.layerThreadToolkit, - ).pipe( - Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), - Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provideMerge(layerOrchestration), - Layer.provide(layerRegistry), - Layer.provide(layerProviderRegistry), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), - ), - 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.provideMerge(McpProviderSessions.layer), - 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 mcpSessions = yield* McpProviderSessions.McpProviderSessions; - const credentialRegistry = yield* McpSessionRegistry.__testing.make().pipe( - Effect.provide(NodeCrypto.layer), - Effect.provideService( - HttpServer.HttpServer, - HttpServer.HttpServer.of({ - address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 43123), - serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], - }), - ), - Effect.provideService( - ServerEnvironment.ServerEnvironment, - ServerEnvironment.ServerEnvironment.of({ - getEnvironmentId: Effect.succeed(invocation.environmentId), - getDescriptor: Effect.die("unused"), - }), - ), - ); - const scopeForThread = (projection: OrchestrationV2ThreadProjection) => - Effect.gen(function* () { - const providerThread = projection.providerThreads.find( - (thread) => thread.id === projection.thread.activeProviderThreadId, - ); - if (providerThread?.providerSessionId == null) - return yield* Effect.die( - `Thread ${projection.thread.id} has no active provider session.`, - ); - const issued = yield* credentialRegistry.issue({ - threadId: projection.thread.id, - providerInstanceId: providerThread.providerInstanceId, - }); - yield* mcpSessions.set(issued.config); - const scope = yield* credentialRegistry.resolve( - issued.config.authorizationHeader.replace(/^Bearer\s+/, ""), - ); - if (scope === undefined) - return yield* Effect.die("The issued MCP credential did not resolve."); - expect(scope.thread.providerSessionId).toMatch(/^[0-9a-f-]{36}$/); - expect(scope.thread.providerSessionId).not.toBe(providerThread.providerSessionId); - return scope; - }); - const startQuestion = ( - callScope: McpInvocationContext.McpInvocationScope, - questions: ReadonlyArray, - ) => - Effect.gen(function* () { - const questionId = questions[0]?.id; - if (questionId === undefined) - return yield* Effect.die("A T3 user-input test needs at least one question."); - const afterSequence = yield* orchestrator.getThreadEventSequence( - callScope.thread!.threadId, - ); - const call = yield* invokeAs(callScope, "t3_request_user_input", { - questions, - }).pipe(Effect.forkChild); - const pendingEvent = yield* orchestrator - .streamStoredEventsFrom({ - threadId: callScope.thread!.threadId, - afterSequence, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "turn-item.updated" && - stored.event.payload.type === "user_input_request" && - stored.event.payload.status === "waiting" && - stored.event.payload.questions[0]?.id === questionId, - ), - Stream.runHead, - Effect.orDie, - Effect.raceFirst( - Fiber.join(call).pipe( - Effect.flatMap((result) => - Effect.die( - new Error( - `Question tool ended before creating its card: ${JSON.stringify(declaredFailure(result))}`, - ), - ), - ), - ), - ), - ); - if (Option.isNone(pendingEvent)) - return yield* Effect.die("The T3 question card was not created."); - const event = pendingEvent.value.event; - if (event.type !== "turn-item.updated") - return yield* Effect.die("The T3 question card event was malformed."); - const item = event.payload; - if (item.type !== "user_input_request") - return yield* Effect.die("The T3 question card event was malformed."); - const projection = yield* orchestrator.getThreadProjection( - callScope.thread!.threadId, - ); - expect( - projection.runtimeRequests.find((request) => request.id === item.requestId) - ?.responseCapability.type, - ).toBe("app_owned"); - expect( - projection.turnItems.find((candidate) => candidate.id === item.id), - ).toMatchObject({ status: "waiting", questions }); - return { call, requestId: item.requestId, event }; - }); - const answerQuestion = ( - callScope: McpInvocationContext.McpInvocationScope, - questions: ReadonlyArray, - answers: ProviderUserInputAnswers, - ) => - Effect.gen(function* () { - const pending = yield* startQuestion(callScope, questions); - const messagesBeforeAnswer = (yield* orchestrator.getThreadProjection( - callScope.thread!.threadId, - )).messages.length; - const response = yield* invokeAs(callScope, "t3_pending_request_respond", { - requestId: pending.requestId, - answers, - }); - expect(response.isError).toBe(false); - const call = yield* Fiber.join(pending.call); - expect(call.isError).toBe(false); - expect(call.structuredContent).toEqual(answers); - const projection = yield* orchestrator.getThreadProjection( - callScope.thread!.threadId, - ); - expect(projection.messages).toHaveLength(messagesBeforeAnswer); - expect( - projection.runtimeRequests.find((request) => request.id === pending.requestId), - ).toMatchObject({ status: "resolved", answers }); - const item = projection.turnItems.find( - (candidate) => - candidate.type === "user_input_request" && - candidate.requestId === pending.requestId, - ); - expect(item).toMatchObject({ - type: "user_input_request", - questionAnswer: { requestId: pending.requestId, answers }, - }); - return pending.requestId; - }); - - 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; - let scopeForCaller = yield* scopeForThread(parent); - const areasQuestion: OrchestrationV2UserInputQuestion = { - id: "areas", - header: "Areas", - question: "Which areas should this change cover?", - options: [ - { label: "API", description: "Server and contracts" }, - { label: "Web", description: "Browser client" }, - ], - multiSelect: true, - }; - const multiSelectQuestions: ReadonlyArray = [ - areasQuestion, - { - id: "platform", - header: "Platform", - question: "Which platform should be prioritized?", - options: [ - { label: "Server", description: "T3 hosted locally" }, - { label: "Web", description: "T3 hosted remotely" }, - ], - multiSelect: false, - }, - ]; - const multipleAnswers: ProviderUserInputAnswers = { - areas: ["API", "Web"], - platform: "Web", - }; - yield* answerQuestion(scopeForCaller, multiSelectQuestions, multipleAnswers); - - // Owner-visible MCP questions still get the recorded away-policy answer. - const settings = yield* ServerSettings.ServerSettingsService; - const dontAsk = yield* DontAskModeWorker.make; - yield* settings.updateSettings({ dontAskMode: true }); - const awayQuestion = yield* startQuestion(scopeForCaller, [areasQuestion]); - yield* dontAsk.sweep; - expect((yield* Fiber.join(awayQuestion.call)).structuredContent).toEqual( - DontAskModeWorker.dontAskAnswers([areasQuestion]), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.find( - (item) => - item.type === "user_input_request" && item.requestId === awayQuestion.requestId, - ), - ).toMatchObject({ - respondedBy: "dont_ask_mode", - questionAnswer: { answers: DontAskModeWorker.dontAskAnswers([areasQuestion]) }, - }); - yield* settings.updateSettings({ dontAskMode: false }); - - const supersededScope = scopeForCaller; - scopeForCaller = yield* scopeForThread( - yield* orchestrator.getThreadProjection(parentThreadId), - ); - const staleSessionCall = yield* invokeAs(supersededScope, "t3_request_user_input", { - questions: [areasQuestion], - }); - expect(declaredFailure(staleSessionCall)).toMatchObject({ code: "parent_not_active" }); - - const answeredBeforeSubscribeId = RuntimeRequestId.make( - "request:answer-before-subscribe", - ); - const answeredBeforeSubscribeProjection = - yield* orchestrator.getThreadProjection(parentThreadId); - const answerBeforeSubscribeRun = answeredBeforeSubscribeProjection.runs.find( - (run) => run.status === "running", - ); - const answerBeforeSubscribeThread = - answeredBeforeSubscribeProjection.providerThreads.find( - (thread) => thread.id === answerBeforeSubscribeRun?.providerThreadId, - ); - if ( - answerBeforeSubscribeRun === undefined || - answerBeforeSubscribeThread?.providerSessionId == null - ) - return yield* Effect.die( - "The Codex caller lost its active run before the race check.", - ); - const answerBeforeSubscribeCreated = yield* orchestrator.dispatch({ - type: "runtime-request.create-user-input", - commandId: CommandId.make("command:answer-before-subscribe:create"), - threadId: parentThreadId, - requestId: answeredBeforeSubscribeId, - runId: answerBeforeSubscribeRun.id, - providerSessionId: answerBeforeSubscribeThread.providerSessionId, - questions: [areasQuestion], - }); - const answerBeforeSubscribe = { areas: ["API", "Web"] }; - yield* orchestrator.dispatch({ - type: "runtime-request.respond", - commandId: CommandId.make("command:answer-before-subscribe:respond"), - threadId: parentThreadId, - requestId: answeredBeforeSubscribeId, - answers: answerBeforeSubscribe, - }); - const replayedAnswer = yield* orchestrator - .streamStoredEventsFrom({ - threadId: parentThreadId, - afterSequence: answerBeforeSubscribeCreated.sequence, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "runtime-request.updated" && - stored.event.payload.id === answeredBeforeSubscribeId && - stored.event.payload.status === "resolved", - ), - Stream.runHead, - Effect.orDie, - ); - expect( - Option.isSome(replayedAnswer) && - replayedAnswer.value.event.type === "runtime-request.updated" && - replayedAnswer.value.event.payload.answers, - ).toEqual(answerBeforeSubscribe); - - const staleRunCommand = yield* orchestrator - .dispatch({ - type: "runtime-request.create-user-input", - commandId: CommandId.make("command:mcp-parent:stale-run"), - threadId: parentThreadId, - requestId: RuntimeRequestId.make("request:mcp-parent:stale-run"), - runId: RunId.make("run:mcp-parent:stale"), - providerSessionId: answerBeforeSubscribeThread.providerSessionId, - questions: [areasQuestion], - }) - .pipe(Effect.result); - expect(staleRunCommand._tag).toBe("Failure"); - - const duplicateQuestionCall = yield* invokeAs(scopeForCaller, "t3_request_user_input", { - questions: [areasQuestion, areasQuestion], - }); - expect(declaredFailure(duplicateQuestionCall)).toMatchObject({ - code: "orchestration_error", - }); - const tooManyOptionsCall = yield* invokeAs(scopeForCaller, "t3_request_user_input", { - questions: [ - { - ...areasQuestion, - id: "too-many-options", - options: Array.from({ length: 21 }, (_, index) => ({ - label: `Option ${index + 1}`, - description: "A bounded option.", - })), - }, - ], - }); - expect(declaredFailure(tooManyOptionsCall)).toMatchObject({ - code: "orchestration_error", - }); - - const dismissQuestion: OrchestrationV2UserInputQuestion = { - id: "dismiss", - header: "Dismiss", - question: "Should this request be dismissed?", - options: [ - { label: "Yes", description: "Dismiss the request" }, - { label: "No", description: "Keep the request open" }, - ], - multiSelect: false, - }; - const dismissedQuestion = yield* startQuestion(scopeForCaller, [dismissQuestion]); - yield* orchestrator.dispatch({ - type: "thread.user-input.dismiss", - commandId: CommandId.make("command:mcp-parent:question-dismiss"), - threadId: parentThreadId, - requestId: dismissedQuestion.requestId, - }); - const dismissedCall = yield* Fiber.join(dismissedQuestion.call); - expect(declaredFailure(dismissedCall)).toMatchObject({ code: "invalid_request" }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).runtimeRequests.find( - (request) => request.id === dismissedQuestion.requestId, - ), - ).toMatchObject({ status: "resolved", decision: "cancel" }); - - const stopQuestionThreadId = ThreadId.make("thread:mcp-question-stop"); - const stopQuestionProjectId = ProjectId.make("project:mcp-question-stop"); - const stopQuestionGate = yield* Deferred.make(); - parentTerminalGates.set(stopQuestionThreadId, stopQuestionGate); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("command:mcp-question-stop:create"), - createdBy: "user", - creationSource: "web", - threadId: stopQuestionThreadId, - projectId: stopQuestionProjectId, - title: "Question tool interruption caller", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - const beforeStopRun = yield* orchestrator.getThreadEventSequence(stopQuestionThreadId); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-question-stop:start"), - threadId: stopQuestionThreadId, - messageId: MessageId.make("message:mcp-question-stop:start"), - text: "Keep the stop test caller active.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - const stopTurnStarted = yield* orchestrator - .streamStoredEventsFrom({ - threadId: stopQuestionThreadId, - afterSequence: beforeStopRun, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "provider-turn.updated" && - stored.event.payload.status === "running", - ), - Stream.runHead, - Effect.orDie, - ); - expect(Option.isSome(stopTurnStarted)).toBe(true); - const stopQuestionProjection = - yield* orchestrator.getThreadProjection(stopQuestionThreadId); - const stopScope = yield* scopeForThread(stopQuestionProjection); - const stopQuestion = yield* startQuestion(stopScope, [dismissQuestion]); - const stopRun = stopQuestionProjection.runs.find((run) => run.status === "running"); - if (stopRun === undefined) - return yield* Effect.die("The Codex stop test caller lost its active run."); - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("command:mcp-question-stop:interrupt"), - threadId: stopQuestionThreadId, - runId: stopRun.id, - reason: "Stop the agent while it is asking a question.", - }); - const stoppedCall = yield* Fiber.join(stopQuestion.call); - expect(declaredFailure(stoppedCall)).toMatchObject({ code: "invalid_request" }); - expect( - (yield* orchestrator.getThreadProjection(stopQuestionThreadId)).runtimeRequests.find( - (request) => request.id === stopQuestion.requestId, - ), - ).toMatchObject({ status: "resolved", decision: "cancel" }); - - const endedQuestionThreadId = ThreadId.make("thread:mcp-question-turn-ended"); - const endedQuestionProjectId = ProjectId.make("project:mcp-question-turn-ended"); - const endedQuestionGate = yield* Deferred.make(); - parentTerminalGates.set(endedQuestionThreadId, endedQuestionGate); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("command:mcp-question-turn-ended:create"), - createdBy: "user", - creationSource: "web", - threadId: endedQuestionThreadId, - projectId: endedQuestionProjectId, - title: "Question tool terminal caller", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - const beforeEndedRun = - yield* orchestrator.getThreadEventSequence(endedQuestionThreadId); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-question-turn-ended:start"), - threadId: endedQuestionThreadId, - messageId: MessageId.make("message:mcp-question-turn-ended:start"), - text: "Keep the terminal test caller active until its question is pending.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: endedQuestionThreadId, - afterSequence: beforeEndedRun, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "provider-turn.updated" && - stored.event.payload.status === "running", - ), - Stream.runHead, - Effect.orDie, - ); - const endedQuestionProjection = - yield* orchestrator.getThreadProjection(endedQuestionThreadId); - const endedScope = yield* scopeForThread(endedQuestionProjection); - const endedQuestion = yield* startQuestion(endedScope, [dismissQuestion]); - const endedRun = endedQuestionProjection.runs.find((run) => run.status === "running"); - if (endedRun === undefined) - return yield* Effect.die("The terminal test caller lost its active run."); - const beforeTerminal = - yield* orchestrator.getThreadEventSequence(endedQuestionThreadId); - yield* Deferred.succeed(endedQuestionGate, undefined); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: endedQuestionThreadId, - afterSequence: beforeTerminal, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "run.updated" && - stored.event.payload.id === endedRun.id && - stored.event.payload.status === "completed", - ), - Stream.runHead, - Effect.orDie, - ); - const endedCall = yield* Fiber.join(endedQuestion.call); - expect(declaredFailure(endedCall)).toMatchObject({ code: "invalid_request" }); - expect( - (yield* orchestrator.getThreadProjection(endedQuestionThreadId)).runtimeRequests.find( - (request) => request.id === endedQuestion.requestId, - ), - ).toMatchObject({ status: "resolved", decision: "cancel" }); - const queueAutomaticCompletion = (suffix: string, taskText: string) => - Effect.gen(function* () { - const delegated = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make(`command:mcp-parent:${suffix}:delegate`), - parentThreadId, - parentRunId: parentRun.id, - parentNodeId: parentRootNodeId, - task: taskText, - modelSelection: claudeSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const taskEvent = delegated.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (taskEvent?.event.type !== "subagent.updated") { - return yield* Effect.die( - new Error(`Automatic completion task ${suffix} was not created.`), - ); - } - const task = taskEvent.event.payload; - const reserved = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => { - const delivery = projection.runs.find((run) => run.id === parentRun.id) - ?.delegatedCompletion?.delivery; - return ( - projection.subagents.find((candidate) => candidate.id === task.id)?.status === - "completed" && - delivery !== undefined && - delivery !== null && - delivery.taskIds.includes(task.id) - ); - }, - ); - const delivery = reserved.runs.find((run) => run.id === parentRun.id) - ?.delegatedCompletion?.delivery; - if (delivery === undefined || delivery === null) { - return yield* Effect.die( - new Error(`Automatic completion delivery ${suffix} was not reserved.`), - ); - } - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make(`command:mcp-parent:${suffix}:dispatch`), - threadId: parentThreadId, - messageId: delivery.messageId, - text: "Delegated task reached a terminal state.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: parentRun.id, - generation: delivery.generation, - taskIds: delivery.taskIds, - }, - }); - const queued = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some( - (run) => run.userMessageId === delivery.messageId && run.status === "queued", - ), - ); - const queuedRun = queued.runs.find( - (run) => run.userMessageId === delivery.messageId, - ); - if (queuedRun === undefined) { - return yield* Effect.die( - new Error(`Automatic completion delivery ${suffix} was not queued.`), - ); - } - return { task, delivery, queuedRun }; - }); - - const coalescedTask = (suffix: string) => - orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make(`command:mcp-parent:coalesced-${suffix}`), - parentThreadId, - parentRunId: parentRun.id, - parentNodeId: parentRootNodeId, - task: `Complete coalesced task ${suffix}.`, - modelSelection: claudeSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const firstCoalesced = yield* coalescedTask("one"); - const secondCoalesced = yield* coalescedTask("two"); - const coalescedTaskIds = [firstCoalesced, secondCoalesced].map((result) => { - const taskEvent = result.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (taskEvent?.event.type !== "subagent.updated") { - throw new Error("Coalesced delegated task projection missing."); - } - return taskEvent.event.payload.id; - }); - const coalescedProjection = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - coalescedTaskIds.every( - (taskId) => - projection.subagents.find((task) => task.id === taskId)?.status === "completed", - ) && - projection.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion !== - undefined, - ); - const coalescedCohort = coalescedProjection.runs.find( - (run) => run.id === parentRun.id, - )?.delegatedCompletion; - expect(coalescedCohort?.delivery?.taskIds).toEqual( - expect.arrayContaining(coalescedTaskIds), - ); - expect(coalescedCohort?.delivery?.taskIds).toHaveLength(2); - const coalescedOffers = yield* waitForContinuationOffers(1); - expect(coalescedOffers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - delegatedCompletion: expect.objectContaining({ - parentRunId: parentRun.id, - messageId: coalescedCohort?.delivery?.messageId, - }), - }), - ]), - ); - if (coalescedCohort?.delivery === undefined || coalescedCohort.delivery === null) { - return yield* Effect.die(new Error("Coalesced delivery reservation missing.")); - } - const coalescedDelivery = coalescedCohort.delivery; - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:mcp-parent:dispatch-coalesced-delivery"), - threadId: parentThreadId, - messageId: coalescedDelivery.messageId, - text: "Delegated tasks reached terminal states.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: parentRun.id, - generation: coalescedDelivery.generation, - taskIds: coalescedDelivery.taskIds, - }, - }); - const queuedCoalesced = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some( - (run) => - run.userMessageId === coalescedDelivery.messageId && run.status === "queued", - ), - ); - const queuedCoalescedRun = queuedCoalesced.runs.find( - (run) => run.userMessageId === coalescedDelivery.messageId, - ); - expect(queuedCoalescedRun?.status).toBe("queued"); - if (queuedCoalescedRun === undefined) { - return yield* Effect.die(new Error("Queued coalesced delivery missing.")); - } - - const completionEditError = yield* orchestrator - .dispatch({ - type: "queued-run.edit", - commandId: CommandId.make("command:mcp-parent:edit-coalesced-delivery"), - threadId: parentThreadId, - runId: queuedCoalescedRun.id, - text: "Rewrite the automatic delivery.", - }) - .pipe(Effect.flip); - expect(completionEditError._tag).toBe("OrchestratorCommandRejectedError"); - const completionReorderError = yield* orchestrator - .dispatch({ - type: "queued-run.reorder", - commandId: CommandId.make("command:mcp-parent:reorder-coalesced-delivery"), - threadId: parentThreadId, - runId: queuedCoalescedRun.id, - beforeRunId: null, - }) - .pipe(Effect.flip); - expect(completionReorderError._tag).toBe("OrchestratorDispatchError"); - const completionSteerError = yield* orchestrator - .dispatch({ - type: "queued-message.promote-to-steer", - commandId: CommandId.make("command:mcp-parent:steer-coalesced-delivery"), - threadId: parentThreadId, - queuedRunId: queuedCoalescedRun.id, - targetRunId: parentRun.id, - }) - .pipe(Effect.flip); - expect(completionSteerError._tag).toBe("OrchestratorDispatchError"); - - for (const taskId of coalescedTaskIds) { - const statusCall = yield* invoke("task_status", { taskId }); - expect(statusCall.isError).toBe(false); - } - const acknowledgedCoalesced = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.find((run) => run.id === queuedCoalescedRun.id)?.status === - "cancelled" && - coalescedTaskIds.every( - (taskId) => - projection.subagents.find((task) => task.id === taskId)?.completionDelivery - ?.state === "acknowledged", - ), - ); - expect( - acknowledgedCoalesced.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.delivery, - ).toBeNull(); - yield* Ref.set(continuationOffers, []); - - const directRead = yield* queueAutomaticCompletion( - "direct-child-read", - "Complete before a parent reads this child result directly.", - ); - if (directRead.task.childThreadId === null) { - return yield* Effect.die(new Error("Direct-read child thread missing.")); - } - const directChildThreadId = directRead.task.childThreadId; - const directChildWaitCall = yield* invoke("t3_thread_wait", { - threadId: directChildThreadId, - timeoutMs: 10_000, - }); - const directChildWait = yield* decodeThreadWaitResult( - directChildWaitCall.structuredContent, - ).pipe(Effect.orDie); - expect(directChildWait).toMatchObject({ - threadId: directChildThreadId, - status: "completed", - timedOut: false, - }); - const pendingAfterWait = yield* orchestrator.getThreadProjection(parentThreadId); - expect( - pendingAfterWait.subagents.find((task) => task.id === directRead.task.id) - ?.completionDelivery?.state, - ).toBe("claimed"); - expect( - pendingAfterWait.runs.find((run) => run.id === directRead.queuedRun.id)?.status, - ).toBe("queued"); - - const childPromptReadCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - limit: 1, - }); - const childPromptRead = yield* decodeThreadReadResult( - childPromptReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(childPromptRead.items.map((item) => item.type)).toEqual(["user_message"]); - if (childPromptRead.nextPosition === null) { - return yield* Effect.die(new Error("Direct-read child prompt position missing.")); - } - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === directRead.task.id, - )?.completionDelivery?.state, - ).toBe("claimed"); - - const truncatedResultReadCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - afterPosition: childPromptRead.nextPosition, - limit: 1, - maxCharsPerItem: 1, - }); - const truncatedResultRead = yield* decodeThreadReadResult( - truncatedResultReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(truncatedResultRead.items).toMatchObject([ - { type: "assistant_message", textTruncated: true }, - ]); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === directRead.task.id, - )?.completionDelivery?.state, - ).toBe("claimed"); - - const firstChunk = truncatedResultRead.items[0]!; - expect(firstChunk.nextTextOffset).toBe(1); - const remainderCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - itemId: firstChunk.itemId, - textOffset: firstChunk.nextTextOffset, - maxCharsPerItem: 50_000, - }); - const remainder = yield* decodeThreadReadResult(remainderCall.structuredContent).pipe( - Effect.orDie, - ); - expect(remainder.items[0]?.nextTextOffset).toBeNull(); - expect(firstChunk.text + (remainder.items[0]?.text ?? "")).toBe( - "Claude completed: Complete before a parent reads this child result directly.", - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === directRead.task.id, - )?.completionDelivery?.state, - ).toBe("claimed"); - - const terminalResultReadCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - afterPosition: childPromptRead.nextPosition, - limit: 1, - }); - const terminalResultRead = yield* decodeThreadReadResult( - terminalResultReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(terminalResultRead.items).toMatchObject([ - { - type: "assistant_message", - text: "Claude completed: Complete before a parent reads this child result directly.", - textTruncated: false, - }, - ]); - const longText = "界🧪\n".repeat(20_000) + "FINAL_CONSTRAINT"; - const childProjection = yield* orchestrator.getThreadProjection(directChildThreadId); - const sourceItem = childProjection.turnItems.find( - (item) => item.type === "user_message", - )!; - const oversizedItem = { - ...sourceItem, - id: TurnItemId.make("item:oversized-retrieval"), - type: "assistant_message" as const, - text: longText, - streaming: false, - ordinal: 999, - }; - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("event:oversized-retrieval"), - type: "turn-item.updated", - threadId: directChildThreadId, - occurredAt: yield* DateTime.now, - payload: oversizedItem, - }, - ], - }); - let recovered = ""; - let textOffset: number | null = 0; - while (textOffset !== null) { - const pageCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - itemId: oversizedItem.id, - textOffset, - maxCharsPerItem: 50_000, - }); - const page: OrchestratorMcpThreadReadResult = yield* decodeThreadReadResult( - pageCall.structuredContent, - ).pipe(Effect.orDie); - expect(page.items).toHaveLength(1); - recovered += page.items[0]!.text; - textOffset = page.items[0]!.nextTextOffset ?? null; - } - expect(recovered).toBe(longText); - - const acknowledgedByDirectRead = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.find((run) => run.id === directRead.queuedRun.id)?.status === - "cancelled" && - projection.subagents.find((task) => task.id === directRead.task.id) - ?.completionDelivery?.state === "acknowledged", - ); - expect( - acknowledgedByDirectRead.subagents.find((task) => task.id === directRead.task.id) - ?.completionDelivery, - ).toMatchObject({ state: "acknowledged", observedByRunId: parentRun.id }); - yield* Ref.set(continuationOffers, []); - - const queueRace = yield* queueAutomaticCompletion( - "queue-race", - "Complete before a user queues follow-up work.", - ); - const queuedUserMessageId = MessageId.make("message:mcp-parent:queue-race:user"); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:queue-race:user"), - threadId: parentThreadId, - messageId: queuedUserMessageId, - text: "🙂".repeat(16001), - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - }); - const queueRaceQueued = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some((run) => run.id === queueRace.queuedRun.id) && - projection.runs.some( - (run) => run.userMessageId === queuedUserMessageId && run.status === "queued", - ), - ); - const queuedUserRun = queueRaceQueued.runs.find( - (run) => run.userMessageId === queuedUserMessageId, - ); - if (queuedUserRun === undefined) { - return yield* Effect.die(new Error("Queued user follow-up missing.")); - } - const queueFirstPage = yield* invoke("t3_queue_list", { limit: 1 }); - expect(queueFirstPage.isError).toBe(false); - expect(queueFirstPage.structuredContent).toMatchObject({ - items: [{ queuedRunId: queueRace.queuedRun.id }], - nextCursor: 1, - }); - const queueDefinition = server.tools.find(({ tool }) => tool.name === "t3_queue_list"); - const validateQueue = new AjvJsonSchemaValidator().getValidator( - queueDefinition!.tool.outputSchema! as JsonSchemaType, - ); - expect(validateQueue(queueFirstPage.structuredContent).valid).toBe(true); - const missingThreadId = ThreadId.make("00000000-0000-4000-8000-000000000000"); - const missingThreadQueue = yield* invoke("t3_queue_list", { - threadId: missingThreadId, - limit: 1, - }); - expect(missingThreadQueue.isError).toBe(true); - expect(declaredFailure(missingThreadQueue)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "thread_not_found", - message: "The thread was not found.", - }); - expect(missingThreadQueue.structuredContent).toBeUndefined(); - expect(validateQueue({ items: "invalid", nextCursor: null }).valid).toBe(false); - const missingThreadRead = yield* invoke("t3_thread_read", { - threadId: missingThreadId, - }); - expect(missingThreadRead.isError).toBe(true); - expect(declaredFailure(missingThreadRead)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - }); - const queueSecondPage = yield* invoke("t3_queue_list", { cursor: 1, limit: 1 }); - expect(queueSecondPage.structuredContent).toEqual({ - items: [{ queuedRunId: queuedUserRun.id, text: "🙂".repeat(1000), truncated: true }], - nextCursor: null, - }); - const queueRead = yield* invoke("t3_queue_read", { queuedRunId: queuedUserRun.id }); - expect(queueRead.structuredContent).toEqual({ - queuedRunId: queuedUserRun.id, - text: "🙂".repeat(16000), - truncated: true, - }); - const missingQueueRead = yield* invoke("t3_queue_read", { queuedRunId: parentRun.id }); - expect(declaredFailure(missingQueueRead)).toMatchObject({ code: "invalid_request" }); - const queueRaceStatus = yield* invoke("task_status", { taskId: queueRace.task.id }); - expect(queueRaceStatus.isError).toBe(false); - yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.find((run) => run.id === queueRace.queuedRun.id)?.status === - "cancelled" && - projection.runs.find((run) => run.id === queuedUserRun.id)?.status === "queued" && - projection.subagents.find((task) => task.id === queueRace.task.id) - ?.completionDelivery?.state === "acknowledged", - ); - yield* orchestrator.dispatch({ - type: "queued-run.cancel", - commandId: CommandId.make("command:mcp-parent:queue-race:cleanup"), - threadId: parentThreadId, - runId: queuedUserRun.id, - }); - - const steerRace = yield* queueAutomaticCompletion( - "steer-race", - "Complete before a user steers the parent.", - ); - const steerMessageId = MessageId.make("message:mcp-parent:steer-race:user"); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:steer-race:user"), - threadId: parentThreadId, - messageId: steerMessageId, - text: "Steer the active parent after receiving the result.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "steer_active", targetRunId: parentRun.id }, - }); - const steerRaceStatus = yield* invoke("task_status", { taskId: steerRace.task.id }); - expect(steerRaceStatus.isError).toBe(false); - const acknowledgedSteerRace = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.messages.some((message) => message.id === steerMessageId) && - projection.runs.find((run) => run.id === parentRun.id)?.status === "running" && - projection.runs.find((run) => run.id === steerRace.queuedRun.id)?.status === - "cancelled" && - projection.subagents.find((task) => task.id === steerRace.task.id) - ?.completionDelivery?.state === "acknowledged", - ); - expect( - acknowledgedSteerRace.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.delivery, - ).toBeNull(); - - const restartRace = yield* queueAutomaticCompletion( - "restart-race", - "Complete before a user restarts the parent.", - ); - const attemptBeforeRestart = (yield* orchestrator.getThreadProjection( - parentThreadId, - )).runs.find((run) => run.id === parentRun.id)?.activeAttemptId; - const restartMessageId = MessageId.make("message:mcp-parent:restart-race:user"); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:restart-race:user"), - threadId: parentThreadId, - messageId: restartMessageId, - text: "Restart the active parent after receiving the result.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "restart_active", targetRunId: parentRun.id }, - }); - const restartedParent = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => { - const run = projection.runs.find((candidate) => candidate.id === parentRun.id); - return ( - run?.status === "running" && - run.activeAttemptId !== attemptBeforeRestart && - run.rootNodeId !== null - ); - }, - ); - const currentParentRun = restartedParent.runs.find((run) => run.id === parentRun.id); - if (currentParentRun?.rootNodeId === null || currentParentRun === undefined) { - return yield* Effect.die(new Error("Restarted parent run missing.")); - } - parentRootNodeId = currentParentRun.rootNodeId; - expect(currentParentRun.delegatedCompletion?.disposition).toBe("open"); - const restartRaceStatus = yield* invoke("task_status", { - taskId: restartRace.task.id, - }); - expect(restartRaceStatus.isError).toBe(false); - yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.messages.some((message) => message.id === restartMessageId) && - projection.runs.find((run) => run.id === restartRace.queuedRun.id)?.status === - "cancelled" && - projection.subagents.find((task) => task.id === restartRace.task.id) - ?.completionDelivery?.state === "acknowledged" && - projection.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.disposition === "open", - ); - yield* Ref.set(continuationOffers, []); - - const capabilitiesTool = server.tools.find( - ({ tool }) => tool.name === "orchestrator_capabilities", - ); - expect(capabilitiesTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(capabilitiesTool?.tool.annotations?.idempotentHint).toBe(true); - const delegateTool = server.tools.find(({ tool }) => tool.name === "delegate_task"); - expect(delegateTool?.tool.annotations?.destructiveHint).toBe(true); - expect(delegateTool?.tool.annotations?.openWorldHint).toBe(true); - const taskStatusTool = server.tools.find(({ tool }) => tool.name === "task_status"); - expect(taskStatusTool?.tool.annotations?.readOnlyHint).toBe(false); - expect(taskStatusTool?.tool.annotations?.idempotentHint).toBe(true); - const createThreadsTool = server.tools.find( - ({ tool }) => tool.name === "create_threads", - ); - expect(createThreadsTool?.tool.annotations?.destructiveHint).toBe(true); - const threadListTool = server.tools.find(({ tool }) => tool.name === "t3_thread_list"); - expect(threadListTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(threadListTool?.tool.annotations?.idempotentHint).toBe(true); - const threadReadTool = server.tools.find(({ tool }) => tool.name === "t3_thread_read"); - expect(threadReadTool?.tool.annotations?.readOnlyHint).toBe(false); - expect(threadReadTool?.tool.inputSchema).toMatchObject({ additionalProperties: false }); - const unknownPagingParamError = yield* invoke("t3_thread_read", { - threadId: parentThreadId, - cursor: 90, - }).pipe(Effect.flip); - expect(unknownPagingParamError.message).toContain("Expected no excess property"); - const threadUpdateTool = server.tools.find( - ({ tool }) => tool.name === "t3_thread_update", - ); - expect(threadUpdateTool?.tool.annotations?.destructiveHint).toBe(true); - expect(threadUpdateTool?.tool.annotations?.idempotentHint).toBe(false); - expect(threadUpdateTool?.tool.inputSchema).toMatchObject({ - type: "object", - properties: { - action: expect.any(Object), - title: expect.any(Object), - pullRequest: expect.any(Object), - }, - }); - const deniedThreadUpdate = yield* invokeAs( - { ...invocation, capabilities: new Set() }, - "t3_thread_update", - { action: "rename", title: "Denied title" }, - ); - expect(declaredFailure(deniedThreadUpdate)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "capability_denied", - }); - const threadSendTool = server.tools.find(({ tool }) => tool.name === "t3_thread_send"); - expect(threadSendTool?.tool.annotations?.destructiveHint).toBe(true); - const threadWaitTool = server.tools.find(({ tool }) => tool.name === "t3_thread_wait"); - expect(threadWaitTool?.tool.annotations?.readOnlyHint).toBe(true); - const threadInterruptTool = server.tools.find( - ({ tool }) => tool.name === "t3_thread_interrupt", - ); - expect(threadInterruptTool?.tool.annotations?.destructiveHint).toBe(true); - - const capabilities = yield* invoke("orchestrator_capabilities", {}); - expect(capabilities.isError).toBe(false); - expect(capabilities.structuredContent).toMatchObject({ - inheritedProviderInstanceId: codexInstanceId, - inheritedModel: codexModel, - features: { - appOwnedSubagents: true, - asyncPolling: true, - cancellation: true, - batchThreadCreation: true, - threadManagement: true, - incrementalThreadRead: true, - }, - providers: expect.arrayContaining([ - expect.objectContaining({ - providerInstanceId: claudeInstanceId, - canRunCrossProviderChildTask: true, - }), - expect.objectContaining({ - providerInstanceId: "opencode", - canRunChildTask: false, - }), - expect.objectContaining({ - providerInstanceId: codexInstanceId, - models: [ - expect.objectContaining({ - id: codexModel, - options: [expect.objectContaining({ id: "reasoning", type: "select" })], - }), - ], - }), - ]), - }); - - const scheduleTool = server.tools.find(({ tool }) => tool.name === "schedule_task"); - expect(scheduleTool?.tool.annotations?.destructiveHint).toBe(true); - const scheduleCall = yield* invoke("schedule_task", { - prompt: "wake up in this thread and say hello", - schedule: { type: "interval", everyMs: 60_000 }, - clientRequestId: "schedule-hello-1", - }); - expect(scheduleCall.isError).toBe(false); - expect(scheduleCall.structuredContent).toMatchObject({ - boundThreadId: parentThreadId, - projectId, - enabled: true, - schedule: { type: "interval", everyMs: 60_000 }, - title: "wake up in this thread and say hello", - }); - const scheduledTaskId = (scheduleCall.structuredContent as { scheduledTaskId: string }) - .scheduledTaskId; - const storedAfterCreate = yield* Ref.get(scheduledStore); - expect(storedAfterCreate).toHaveLength(1); - expect(storedAfterCreate[0]).toMatchObject({ - threadId: parentThreadId, - projectId, - createdBy: "agent", - creationSource: "mcp", - modelSelection: codexSelection, - }); - - const scheduledListCall = yield* invoke("list_scheduled_tasks", {}); - expect(scheduledListCall.isError).toBe(false); - expect(scheduledListCall.structuredContent).toMatchObject({ - tasks: [{ scheduledTaskId, boundThreadId: parentThreadId }], - }); - - const scheduledUpdateCall = yield* invoke("update_scheduled_task", { - scheduledTaskId, - enabled: false, - }); - expect(scheduledUpdateCall.isError).toBe(false); - expect(scheduledUpdateCall.structuredContent).toMatchObject({ - scheduledTaskId, - enabled: false, - }); - - const scheduledDeleteCall = yield* invoke("delete_scheduled_task", { scheduledTaskId }); - expect(scheduledDeleteCall.isError).toBe(false); - expect(scheduledDeleteCall.structuredContent).toMatchObject({ - scheduledTaskId, - deleted: true, - }); - expect(yield* Ref.get(scheduledStore)).toHaveLength(0); - - // OpenCode 1.15 has emitted this exact nested-object-as-JSON-string - // shape. Decode it at the MCP boundary rather than failing a task - // the model otherwise specified correctly. - const serializedScheduleCall = yield* invoke("schedule_task", { - prompt: "check for new pull requests", - schedule: '{"type":"interval","everyMs":3600000}', - clientRequestId: "schedule-opencode-compat-1", - }); - expect(serializedScheduleCall.isError).toBe(false); - expect(serializedScheduleCall.structuredContent).toMatchObject({ - schedule: { type: "interval", everyMs: 3_600_000 }, - boundThreadId: parentThreadId, - }); - const serializedScheduledTaskId = ( - serializedScheduleCall.structuredContent as { scheduledTaskId: string } - ).scheduledTaskId; - yield* invoke("delete_scheduled_task", { - scheduledTaskId: serializedScheduledTaskId, - }); - expect(yield* Ref.get(scheduledStore)).toHaveLength(0); - - const secretRequests = yield* SecretRequests.SecretRequests; - const secretFiber = yield* invoke("request_secret", { - label: "GitHub webhook secret", - reason: "Signs release webhooks. Enter the same value in GitHub's webhook settings.", - placeholder: "Paste the webhook secret", - clientRequestId: "release-webhook-secret", - }).pipe(Effect.forkChild); - const asked = yield* Effect.gen(function* () { - while (true) { - const projection = yield* orchestrator.getThreadProjection(parentThreadId); - if ( - projection.turnItems.some( - (item) => item.type === "secret_request" && item.secretStatus === "pending", - ) - ) { - return projection; - } - yield* Effect.sleep("5 millis"); - } - }); - const card = asked.turnItems.find((item) => item.type === "secret_request"); - if (card?.type !== "secret_request") { - return yield* Effect.die(new Error("Secret request card missing.")); - } - expect(card).toMatchObject({ - label: "GitHub webhook secret", - placeholder: "Paste the webhook secret", - }); - yield* orchestrator.dispatch({ - type: "secret_request.record", - commandId: CommandId.make("command:test:secret-request-replay"), - threadId: parentThreadId, - runId: card.runId!, - nodeId: card.nodeId!, - turnItemId: card.id, - label: "Something else", - reason: "A different reason.", - secretStatus: "pending", - }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.find( - (item) => item.id === card.id, - ), - ).toMatchObject({ label: "GitHub webhook secret", runId: card.runId }); - expect( - (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest, - ).toMatchObject({ kind: "user_input" }); - expect(threadShellFromProjection(asked).pendingRuntimeRequest).toMatchObject({ - kind: "user_input", - }); - yield* secretRequests.answer({ - threadId: parentThreadId, - turnItemId: card.id, - answer: { type: "save", secret: "github-webhook-secret" }, - }); - const secretCall = yield* Fiber.join(secretFiber); - expect(secretCall.isError).toBe(false); - const secretResult = secretCall.structuredContent as { - status: string; - secretRef?: string; - }; - expect(secretResult.status).toBe("saved"); - expect( - (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest ?? null, - ).toBeNull(); - expect(secretResult.secretRef).toMatch(/^secret-ref:[0-9a-f]{32}$/); - const received = [ - ...secretCall.content.map((part) => ("text" in part ? part.text : "")), - ...Object.values(secretResult), - ]; - expect(received.some((value) => value.includes("github-webhook-secret"))).toBe(false); - - const retried = yield* invoke("request_secret", { - label: "GitHub webhook secret", - reason: "Signs release webhooks. Enter the same value in GitHub's webhook settings.", - clientRequestId: "release-webhook-secret", - }); - expect(retried.structuredContent).toEqual(secretResult); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.filter( - (item) => item.type === "secret_request", - ), - ).toHaveLength(1); - - expect( - yield* secretRequests.consume({ - ref: secretResult.secretRef as never, - projectId, - }), - ).toBe("github-webhook-secret"); - const reused = yield* secretRequests - .consume({ ref: secretResult.secretRef as never, projectId }) - .pipe(Effect.flip); - expect(reused.message).toContain("already used"); - - const delegatedOptions = [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ]; - const delegatedCall = yield* invoke("delegate_task", { - task: delegatedPrompt, - target: { - providerInstanceId: claudeInstanceId, - model: claudeModel, - options: { effort: "max", fastMode: true }, - }, - mode: "wait", - timeoutMs: 10_000, - clientRequestId: "delegate-claude-1", - }); - expect(delegatedCall.isError).toBe(false); - const delegated = yield* decodeDelegateTaskResult(delegatedCall.structuredContent).pipe( - Effect.orDie, - ); - const delegatedSource = yield* orchestrator.getThreadProjection( - delegated.childThreadId, - ); - expect(delegatedSource.messages[0]).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - delegatedSource.turnItems.find((item) => item.type === "user_message"), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect(delegated.status).toBe("completed"); - expect(delegated.summary).toBe(delegatedResult); - expect(delegated.providerInstanceId).toBe(claudeInstanceId); - expect(delegated.options).toEqual(delegatedOptions); - - const completedParent = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.subagents.some( - (task) => - task.id === delegated.taskId && - task.status === "completed" && - task.result === delegatedResult, - ) && - projection.contextTransfers.some( - (transfer) => - transfer.type === "subagent_result" && - transfer.sourceThreadId === delegated.childThreadId, - ), - ); - const completedTask = completedParent.subagents.find( - (task) => task.id === delegated.taskId, - ); - expect(completedTask).toMatchObject({ - origin: "app_owned", - createdBy: "agent", - childThreadId: delegated.childThreadId, - status: "completed", - result: delegatedResult, - }); - const child = yield* orchestrator.getThreadProjection(delegated.childThreadId); - expect(child.thread.lineage).toEqual({ - parentThreadId, - relationshipToParent: "subagent", - rootThreadId: parentThreadId, - }); - expect(child.thread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(child.thread.modelSelection).toEqual({ - ...claudeSelection, - options: delegatedOptions, - }); - expect( - child.messages - .filter((message) => message.role === "user") - .map((message) => message.text), - ).toEqual([delegatedPrompt]); - expect( - child.contextTransfers.some( - (transfer) => - transfer.type === "subagent_spawn" && transfer.sourceThreadId === parentThreadId, - ), - ).toBe(true); - const capturedAfterDelegate = yield* Ref.get(capturedTurns); - expect( - capturedAfterDelegate.filter((turn) => turn.threadId === delegated.childThreadId), - ).toEqual([ - { - instanceId: claudeInstanceId, - threadId: delegated.childThreadId, - text: delegatedPrompt, - modelSelection: { ...claudeSelection, options: delegatedOptions }, - }, - ]); - expect( - capturedAfterDelegate.some( - (turn) => - turn.threadId === delegated.childThreadId && turn.text.includes(parentPrompt), - ), - ).toBe(false); - - const delegatedStatusCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatus = yield* decodeDelegateTaskResult( - delegatedStatusCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatus).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - hasPendingChildRuns: false, - latestTerminalRunId: delegated.childRunId, - latestTerminalStatus: "completed", - latestTerminalSummary: null, - latestTerminalResultContextTransferId: null, - }); - expect(delegatedStatus.resultContextTransferId).not.toBeNull(); - - yield* expectOffersToStay(0); - - const childFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, - message: "Confirm the delegated API boundary remains inspected.", - clientRequestId: "delegated-child-followup-1", - }); - const childFollowup = yield* decodeThreadSendResult( - childFollowupCall.structuredContent, - ).pipe(Effect.orDie); - const childFollowupWaitCall = yield* invoke("t3_thread_wait", { - threadId: delegated.childThreadId, - runId: childFollowup.runId, - timeoutMs: 10_000, - }); - const childFollowupWait = yield* decodeThreadWaitResult( - childFollowupWaitCall.structuredContent, - ).pipe(Effect.orDie); - expect(childFollowupWait).toMatchObject({ - runId: childFollowup.runId, - status: "completed", - timedOut: false, - }); - const delegatedStatusAfterFollowupCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatusAfterFollowup = yield* decodeDelegateTaskResult( - delegatedStatusAfterFollowupCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatusAfterFollowup).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - hasPendingChildRuns: false, - latestTerminalRunId: childFollowup.runId, - latestTerminalStatus: "completed", - }); - expect(delegatedStatusAfterFollowup.latestTerminalSummary).not.toBeNull(); - - const activeChildFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, - message: cancellationPrompt, - clientRequestId: "delegated-child-active-followup-1", - }); - const activeChildFollowup = yield* decodeThreadSendResult( - activeChildFollowupCall.structuredContent, - ).pipe(Effect.orDie); - yield* waitForProjection(orchestrator, delegated.childThreadId, (projection) => - projection.runs.some( - (run) => run.id === activeChildFollowup.runId && run.status === "running", - ), - ); - const delegatedStatusDuringFollowupCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatusDuringFollowup = yield* decodeDelegateTaskResult( - delegatedStatusDuringFollowupCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatusDuringFollowup).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - hasPendingChildRuns: true, - latestTerminalRunId: childFollowup.runId, - latestTerminalStatus: "completed", - }); - const activeChildProjection = yield* orchestrator.getThreadProjection( - delegated.childThreadId, - ); - const legacyChildProjection = { - ...activeChildProjection, - contextTransfers: activeChildProjection.contextTransfers.filter( - (transfer) => transfer.type !== "subagent_spawn", - ), - }; - const legacyDelegatedRun = delegatedTaskRun(legacyChildProjection, completedTask!); - expect(legacyDelegatedRun?.id).toBe(delegated.childRunId); - expect(hasPendingChildRuns(legacyChildProjection, legacyDelegatedRun)).toBe(true); - expect( - hasPendingChildRuns( - { - ...legacyChildProjection, - runs: legacyChildProjection.runs.map((run) => - run.id === activeChildFollowup.runId ? { ...run, status: "queued" } : run, - ), - }, - legacyDelegatedRun, - ), - ).toBe(true); - expect( - hasPendingChildRuns( - { - ...legacyChildProjection, - runs: legacyChildProjection.runs.map((run) => - run.id === activeChildFollowup.runId - ? { ...run, status: "queued", queueHeld: true } - : run, - ), - }, - legacyDelegatedRun, - ), - ).toBe(false); - const heldFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, - message: "This queued follow-up must remain stopped until resumed.", - mode: "queue", - clientRequestId: "delegated-child-held-followup", - }); - const heldFollowup = yield* decodeThreadSendResult( - heldFollowupCall.structuredContent, - ).pipe(Effect.orDie); - expect(heldFollowup.taskId).toBeDefined(); - const completedTaskCancelCall = yield* invoke("task_cancel", { - taskId: delegated.taskId, - reason: "Stop the child's later work too.", - clientRequestId: "cancel-completed-delegated-task-1", - }); - const completedTaskCancel = yield* decodeTaskCancelResult( - completedTaskCancelCall.structuredContent, - ).pipe(Effect.orDie); - expect(completedTaskCancel).toEqual({ - taskId: delegated.taskId, - status: "completed", - }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === delegated.taskId, - ), - ).toMatchObject({ - result: delegatedResult, - completionDelivery: { state: "disposed" }, - }); - yield* waitForProjection(orchestrator, delegated.childThreadId, (projection) => - projection.runs.some( - (run) => run.id === activeChildFollowup.runId && run.status === "interrupted", - ), - ); - const delegatedStatusAfterCancelCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatusAfterCancel = yield* decodeDelegateTaskResult( - delegatedStatusAfterCancelCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatusAfterCancel).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - hasPendingChildRuns: false, - latestTerminalRunId: activeChildFollowup.runId, - latestTerminalStatus: "interrupted", - }); - - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === heldFollowup.taskId && task.status === "idle", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find( - (run) => run.id === heldFollowup.runId, - ), - ).toMatchObject({ status: "queued", queueHeld: true, startedAt: null }); - const cancelHeldCall = yield* invoke("task_cancel", { - taskId: heldFollowup.taskId, - clientRequestId: "cancel-held-delegated-followup", - }); - expect( - (yield* decodeTaskCancelResult(cancelHeldCall.structuredContent).pipe(Effect.orDie)) - .status, - ).toBe("cancel_requested"); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === heldFollowup.taskId && task.status === "cancelled", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find( - (run) => run.id === heldFollowup.runId, - ), - ).toMatchObject({ status: "cancelled", startedAt: null }); - - expect(childFollowup.taskId).toBeDefined(); - expect(activeChildFollowup.taskId).toBeDefined(); - yield* waitForContinuationOffers(2); - yield* expectOffersToStay(2); - yield* invoke("task_status", { taskId: childFollowup.taskId }); - yield* invoke("task_status", { taskId: activeChildFollowup.taskId }); - yield* Ref.set(continuationOffers, []); - - // A send into a child run the server started has no task to join. - // It gets its own task only when it starts a new attempt; a steer - // into the running attempt leaves no run that could settle one. - const childSteerText = "Include the parent's latest guidance before finishing."; - const steerIntoUntaskedChildRun = ( - target: { readonly providerInstanceId: ProviderInstanceId; readonly model: string }, - name: string, - ) => - Effect.gen(function* () { - const delegateCall = yield* invoke("delegate_task", { - task: "Inspect the steering boundary and return the result.", - target, - mode: "wait", - timeoutMs: 10_000, - clientRequestId: `${name}-delegate`, - }); - const child = yield* decodeDelegateTaskResult(delegateCall.structuredContent).pipe( - Effect.orDie, - ); - expect(child.status).toBe("completed"); - const messageId = MessageId.make(`message:mcp-child:${name}`); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make(`command:mcp-child:${name}`), - threadId: child.childThreadId, - messageId, - text: cancellationPrompt, - attachments: [], - modelSelection: { instanceId: target.providerInstanceId, model: target.model }, - dispatchMode: { type: "start_immediately" }, - }); - const running = yield* waitForProjection( - orchestrator, - child.childThreadId, - (projection) => { - const run = projection.runs.find((row) => row.userMessageId === messageId); - return ( - run?.status === "running" && - projection.providerTurns.some( - (turn) => turn.nodeId === run.rootNodeId && turn.status === "running", - ) - ); - }, - ); - const run = running.runs.find((row) => row.userMessageId === messageId)!; - expect(run.delegatedTaskId).toBeUndefined(); - const sendCall = yield* invoke("t3_thread_send", { - threadId: child.childThreadId, - message: childSteerText, - mode: "steer", - clientRequestId: `${name}-steer`, - }); - const sent = yield* decodeThreadSendResult(sendCall.structuredContent).pipe( - Effect.orDie, - ); - expect(sent).toMatchObject({ runId: run.id, delivery: "steered" }); - return { child, sent }; - }); - const waitForChildRun = ( - childThreadId: ThreadId, - runId: string, - status: "completed" | "interrupted", - ) => - waitForProjection(orchestrator, childThreadId, (projection) => - projection.runs.some((run) => run.id === runId && run.status === status), - ); - - // Claude steers into the running turn, which then ends. - const steerReceived = yield* Deferred.make(); - steerReceivedGates.set(childSteerText, steerReceived); - const steeredSend = yield* steerIntoUntaskedChildRun( - { providerInstanceId: claudeInstanceId, model: claudeModel }, - "steer-untasked-run", - ); - // A steer that misses the turn is sent again as its own run, so - // end the turn only once the steer reached it. - yield* Deferred.await(steerReceived); - steerReceivedGates.delete(childSteerText); - yield* invoke("t3_thread_interrupt", { - threadId: steeredSend.child.childThreadId, - runId: steeredSend.sent.runId, - clientRequestId: "steer-untasked-run-interrupt", - }); - yield* waitForChildRun( - steeredSend.child.childThreadId, - steeredSend.sent.runId, - "interrupted", - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.filter( - (task) => - task.origin === "app_owned" && - task.childThreadId === steeredSend.child.childThreadId && - task.status === "running", - ), - ).toEqual([]); - expect(steeredSend.sent.taskId).toBeUndefined(); - - // This Codex session has active steering off, so the send restarts the - // turn as a new attempt that owns the follow-up task and settles it. - const restarted = yield* steerIntoUntaskedChildRun( - { providerInstanceId: codexInstanceId, model: codexModel }, - "restart-untasked-run", - ); - const restartedTaskId = restarted.sent.taskId; - expect(restartedTaskId).toBeDefined(); - yield* waitForChildRun( - restarted.child.childThreadId, - restarted.sent.runId, - "completed", - ); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === restartedTaskId && task.status === "completed", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(restarted.child.childThreadId)).runs.find( - (run) => run.id === restarted.sent.runId, - )?.delegatedTaskId, - ).toBe(restartedTaskId); - yield* invoke("task_status", { taskId: steeredSend.child.taskId }); - yield* invoke("task_status", { taskId: restarted.child.taskId }); - yield* invoke("task_status", { taskId: restartedTaskId }); - yield* Ref.set(continuationOffers, []); - - const repeatedDelegatedCall = yield* invoke("delegate_task", { - task: delegatedPrompt, - target: { - providerInstanceId: claudeInstanceId, - model: claudeModel, - }, - mode: "async", - clientRequestId: "delegate-claude-1", - }); - const repeatedDelegated = yield* decodeDelegateTaskResult( - repeatedDelegatedCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedDelegated.taskId).toBe(delegated.taskId); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.filter( - (task) => task.id === delegated.taskId, - ), - ).toHaveLength(1); - - const rejectedOptionsCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - options: { reasoning: "extreme" }, - }, - mode: "async", - clientRequestId: "delegate-rejected-options-1", - }); - expect(declaredFailure(rejectedOptionsCall)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "invalid_request", - message: expect.stringContaining("rejected options"), - }); - - const duplicateOptionsCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - options: [ - { id: "reasoning", value: "low" }, - { id: "reasoning", value: "high" }, - ], - }, - mode: "async", - clientRequestId: "delegate-duplicate-options-1", - }); - expect(declaredFailure(duplicateOptionsCall)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "invalid_request", - message: expect.stringContaining("more than once"), - }); - - const cancellableCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - options: { reasoning: "low" }, - }, - mode: "async", - clientRequestId: "delegate-cancel-1", - }); - const cancellable = yield* decodeDelegateTaskResult( - cancellableCall.structuredContent, - ).pipe(Effect.orDie); - expect(cancellable.status).toBe("running"); - expect(cancellable.hasPendingChildRuns).toBe(false); - yield* waitForProjection(orchestrator, cancellable.childThreadId, (projection) => - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const statusWhileOriginalRunningCall = yield* invoke("task_status", { - taskId: cancellable.taskId, - }); - const statusWhileOriginalRunning = yield* decodeDelegateTaskResult( - statusWhileOriginalRunningCall.structuredContent, - ).pipe(Effect.orDie); - expect(statusWhileOriginalRunning).toMatchObject({ - childRunId: cancellable.childRunId, - status: "running", - hasPendingChildRuns: false, - latestTerminalRunId: null, - latestTerminalStatus: null, - }); - const optionedChild = yield* orchestrator.getThreadProjection( - cancellable.childThreadId, - ); - expect(optionedChild.thread.modelSelection).toEqual({ - instanceId: codexInstanceId, - model: codexModel, - options: [{ id: "reasoning", value: "low" }], - }); - const cancelCall = yield* invoke("task_cancel", { - taskId: cancellable.taskId, - reason: "Parent no longer needs this work.", - clientRequestId: "cancel-1", - }); - const cancelResult = yield* decodeTaskCancelResult(cancelCall.structuredContent).pipe( - Effect.orDie, - ); - expect(cancelResult.status).toBe("cancel_requested"); - yield* waitForProjection(orchestrator, cancellable.childThreadId, (projection) => - projection.runs.some((run) => run.status === "interrupted"), - ); - const cancelledStatusCall = yield* invoke("task_status", { - taskId: cancellable.taskId, - }); - const cancelledStatus = yield* decodeDelegateTaskResult( - cancelledStatusCall.structuredContent, - ).pipe(Effect.orDie); - expect(cancelledStatus.status).toBe("interrupted"); - - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === cancellable.taskId, - )?.completionDelivery, - ).toMatchObject({ state: "disposed" }); - yield* expectOffersToStay(0); - - const createInput = { - clientRequestId: "create-thread-batch-1", - threads: [ - { - title: "Inherited empty thread", - }, - { - title: "Claude ordinary thread", - prompt: createdThreadPrompt, - target: { - driverKind: "claudeAgent", - }, - }, - ], - }; - const createCall = yield* invoke("create_threads", createInput); - expect(createCall.isError).toBe(false); - const created = yield* decodeCreateThreadsResult(createCall.structuredContent).pipe( - Effect.orDie, - ); - expect(created.threads).toHaveLength(2); - const emptyThread = created.threads[0]!; - const promptedThread = created.threads[1]!; - expect(emptyThread).toMatchObject({ - status: "idle", - createdBy: "agent", - creationSource: "mcp", - providerInstanceId: codexInstanceId, - model: codexModel, - }); - expect(promptedThread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - providerInstanceId: claudeInstanceId, - model: claudeModel, - }); - const createdSource = yield* orchestrator.getThreadProjection(promptedThread.threadId); - expect(createdSource.messages[0]).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - createdSource.turnItems.find((item) => item.type === "user_message"), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - const emptyProjection = yield* orchestrator.getThreadProjection(emptyThread.threadId); - expect(emptyProjection.thread.lineage).toEqual({ - parentThreadId: null, - relationshipToParent: null, - rootThreadId: emptyThread.threadId, - }); - expect(emptyProjection.thread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(emptyProjection.thread.forkedFrom).toBeNull(); - expect(emptyProjection.runs).toEqual([]); - - const defaultRenameCall = yield* invoke("t3_thread_update", { - action: "rename", - title: "MCP parent metadata", - clientRequestId: "metadata-default-thread-1", - }); - const defaultRenamed = yield* decodeThreadUpdateResult( - defaultRenameCall.structuredContent, - ).pipe(Effect.orDie); - expect(defaultRenamed).toMatchObject({ - threadId: parentThreadId, - action: "rename", - title: "MCP parent metadata", - }); - - const renameCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "rename", - title: "Metadata-managed thread", - clientRequestId: "metadata-rename-1", - }); - const renamed = yield* decodeThreadUpdateResult(renameCall.structuredContent).pipe( - Effect.orDie, - ); - expect(renamed).toMatchObject({ - threadId: emptyThread.threadId, - action: "rename", - title: "Metadata-managed thread", - linkedPullRequest: null, - }); - const repeatedRenameCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "rename", - title: "Metadata-managed thread", - clientRequestId: "metadata-rename-1", - }); - const repeatedRename = yield* decodeThreadUpdateResult( - repeatedRenameCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedRename.commandId).toBe(renamed.commandId); - expect(repeatedRename.sequence).toBe(renamed.sequence); - - const linkedCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "link_pull_request", - pullRequest: { - repository: "pingdotgg/t3code", - number: 8689, - url: "https://github.com/pingdotgg/t3code/pull/8689", - }, - clientRequestId: "metadata-link-1", - }); - const linked = yield* decodeThreadUpdateResult(linkedCall.structuredContent).pipe( - Effect.orDie, - ); - expect(linked.linkedPullRequest).toEqual({ - projectId, - repository: "pingdotgg/t3code", - number: 8689, - url: "https://github.com/pingdotgg/t3code/pull/8689", - }); - const metadataReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const metadataRead = yield* decodeThreadReadResult( - metadataReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(metadataRead.thread).toMatchObject({ - title: "Metadata-managed thread", - linkedPullRequest: linked.linkedPullRequest, - }); - const metadataListCall = yield* invoke("t3_thread_list", { limit: 100 }); - const metadataList = yield* decodeThreadListResult( - metadataListCall.structuredContent, - ).pipe(Effect.orDie); - expect( - metadataList.threads.find((thread) => thread.threadId === emptyThread.threadId), - ).toMatchObject({ - title: "Metadata-managed thread", - linkedPullRequest: linked.linkedPullRequest, - settled: false, - settledAt: null, - }); - expect(metadataRead.thread).toMatchObject({ settled: false, settledAt: null }); - - yield* orchestrator.dispatch({ - type: "thread.settle", - commandId: CommandId.make("command:mcp-empty:settle"), - threadId: emptyThread.threadId, - settledAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), - }); - const settledReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const settledRead = yield* decodeThreadReadResult( - settledReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(settledRead.thread).toMatchObject({ - settled: true, - settledAt: "2026-01-01T00:00:00.000Z", - }); - const settledListCall = yield* invoke("t3_thread_list", { settled: true, limit: 100 }); - const settledList = yield* decodeThreadListResult( - settledListCall.structuredContent, - ).pipe(Effect.orDie); - expect(settledList.threads.map((thread) => thread.threadId)).toEqual([ - emptyThread.threadId, - ]); - expect(settledList.threads[0]).toMatchObject({ - settled: true, - settledAt: "2026-01-01T00:00:00.000Z", - }); - const activeListCall = yield* invoke("t3_thread_list", { settled: false, limit: 100 }); - const activeList = yield* decodeThreadListResult(activeListCall.structuredContent).pipe( - Effect.orDie, - ); - expect( - activeList.threads.some((thread) => thread.threadId === emptyThread.threadId), - ).toBe(false); - yield* orchestrator.dispatch({ - type: "thread.unsettle", - commandId: CommandId.make("command:mcp-empty:unsettle"), - threadId: emptyThread.threadId, - reason: "user", - }); - - expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null }); - yield* orchestrator.dispatch({ - type: "thread.snooze", - commandId: CommandId.make("command:mcp-empty:snooze"), - threadId: emptyThread.threadId, - snoozedUntil: "2099-01-01T00:00:00.000Z", - }); - const snoozedListCall = yield* invoke("t3_thread_list", { snoozed: true, limit: 100 }); - const snoozedList = yield* decodeThreadListResult( - snoozedListCall.structuredContent, - ).pipe(Effect.orDie); - expect(snoozedList.threads.map((thread) => thread.threadId)).toEqual([ - emptyThread.threadId, - ]); - expect(snoozedList.threads[0]).toMatchObject({ - snoozed: true, - snoozedUntil: "2099-01-01T00:00:00.000Z", - }); - const snoozedReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const snoozedRead = yield* decodeThreadReadResult( - snoozedReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(snoozedRead.thread).toMatchObject({ - snoozed: true, - snoozedUntil: "2099-01-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.unsnooze", - commandId: CommandId.make("command:mcp-empty:unsnooze"), - threadId: emptyThread.threadId, - reason: "user", - }); - - const unlinkedCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "unlink_pull_request", - clientRequestId: "metadata-unlink-1", - }); - const unlinked = yield* decodeThreadUpdateResult(unlinkedCall.structuredContent).pipe( - Effect.orDie, - ); - expect(unlinked.linkedPullRequest).toBeNull(); - - // Links added through thread.pull-request.link leave linkedPullRequest unchanged. - yield* orchestrator.dispatch({ - type: "thread.pull-request.link", - commandId: CommandId.make("command:mcp-empty:link-other-repository"), - threadId: emptyThread.threadId, - host: "github.com", - repository: "octo/other-repo", - number: 42, - url: "https://github.com/octo/other-repo/pull/42", - source: "agent", - }); - yield* orchestrator.dispatch({ - type: "thread.pull-request.link", - commandId: CommandId.make("command:mcp-empty:link-dismissed"), - threadId: emptyThread.threadId, - host: "github.com", - repository: "octo/other-repo", - number: 43, - url: "https://github.com/octo/other-repo/pull/43", - source: "stack-dismissed", - }); - const otherRepositoryLink = { - host: "github.com", - repository: "octo/other-repo", - number: 42, - url: "https://github.com/octo/other-repo/pull/42", - source: "agent", - state: null, - }; - const otherRepositoryReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const otherRepositoryRead = yield* decodeThreadReadResult( - otherRepositoryReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(otherRepositoryRead.thread).toMatchObject({ - linkedPullRequest: null, - pullRequests: [otherRepositoryLink], - }); - const otherRepositoryListCall = yield* invoke("t3_thread_list", { limit: 100 }); - const otherRepositoryList = yield* decodeThreadListResult( - otherRepositoryListCall.structuredContent, - ).pipe(Effect.orDie); - expect( - otherRepositoryList.threads.find( - (thread) => thread.threadId === emptyThread.threadId, - ), - ).toMatchObject({ linkedPullRequest: null, pullRequests: [otherRepositoryLink] }); - expect( - otherRepositoryList.threads.find((thread) => thread.threadId === parentThreadId), - ).toMatchObject({ pullRequests: [] }); - - const regenerateCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "regenerate_title", - clientRequestId: "metadata-regenerate-title-1", - }); - const regenerating = yield* decodeThreadUpdateResult( - regenerateCall.structuredContent, - ).pipe(Effect.orDie); - expect(regenerating).toMatchObject({ - threadId: emptyThread.threadId, - action: "regenerate_title", - titleRegeneration: { requestId: regenerating.commandId }, - }); - const promptedProjection = yield* waitForProjection( - orchestrator, - promptedThread.threadId, - (projection) => projection.runs.some((run) => run.status === "completed"), - ); - expect(promptedProjection.thread.lineage.parentThreadId).toBeNull(); - expect( - promptedProjection.messages - .filter((message) => message.role === "user") - .map((message) => message.text), - ).toEqual([createdThreadPrompt]); - const createdThreadItems = (yield* orchestrator.getThreadProjection( - parentThreadId, - )).visibleTurnItems - .map((row) => row.item) - .filter((item) => item.type === "thread_created"); - expect( - createdThreadItems.map((item) => ({ - targetThreadId: item.targetThreadId, - targetRunId: item.targetRunId, - title: item.title, - providerInstanceId: item.targetProviderInstanceId, - model: item.targetModel, - })), - ).toEqual([ - { - targetThreadId: emptyThread.threadId, - targetRunId: null, - title: emptyThread.title, - providerInstanceId: codexInstanceId, - model: codexModel, - }, - { - targetThreadId: promptedThread.threadId, - targetRunId: promptedThread.runId, - title: promptedThread.title, - providerInstanceId: claudeInstanceId, - model: claudeModel, - }, - ]); - - const repeatedCreateCall = yield* invoke("create_threads", createInput); - const repeatedCreated = yield* decodeCreateThreadsResult( - repeatedCreateCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedCreated.threads.map((thread) => thread.threadId)).toEqual( - created.threads.map((thread) => thread.threadId), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).visibleTurnItems.filter( - (row) => { - if (row.item.type !== "thread_created") return false; - const targetThreadId = row.item.targetThreadId; - return created.threads.some((thread) => thread.threadId === targetThreadId); - }, - ), - ).toHaveLength(2); - - const promptedReadCall = yield* invoke("t3_thread_read", { - threadId: promptedThread.threadId, - limit: 1, - }); - const promptedRead = yield* decodeThreadReadResult( - promptedReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(promptedRead.thread.status).toBe("completed"); - expect(promptedRead.thread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(promptedRead.items.map((item) => item.type)).toEqual(["user_message"]); - expect(promptedRead.items[0]).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(promptedRead.hasMore).toBe(true); - const promptedReadNextCall = yield* invoke("t3_thread_read", { - threadId: promptedThread.threadId, - afterPosition: promptedRead.nextPosition, - limit: 1, - }); - const promptedReadNext = yield* decodeThreadReadResult( - promptedReadNextCall.structuredContent, - ).pipe(Effect.orDie); - expect(promptedReadNext.items.map((item) => item.type)).toEqual(["assistant_message"]); - expect(promptedReadNext.items[0]?.text).toBe( - `Claude completed: ${createdThreadPrompt}`, - ); - - const forkedThreadId = ThreadId.make("thread:mcp-orchestrator-inherited-read"); - yield* orchestrator.dispatch({ - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-orchestrator-inherited-read"), - sourceThreadId: promptedThread.threadId, - targetThreadId: forkedThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Inherited read thread", - }); - const forkedProjection = yield* orchestrator.getThreadProjection(forkedThreadId); - expect(forkedProjection.messages).toEqual([]); - expect( - forkedProjection.visibleTurnItems.some( - (row) => - row.sourceThreadId === promptedThread.threadId && - row.item.type === "user_message", - ), - ).toBe(true); - - const forkedReadCall = yield* invoke("t3_thread_read", { - threadId: forkedThreadId, - }); - const forkedRead = yield* decodeThreadReadResult(forkedReadCall.structuredContent).pipe( - Effect.orDie, - ); - expect( - forkedRead.items.find((item) => item.text === createdThreadPrompt), - ).toMatchObject({ - sourceThreadId: promptedThread.threadId, - createdBy: "agent", - creationSource: "mcp", - }); - - const ordinaryLoopPrompt = "Run an ordinary thread loop iteration."; - const sendCall = yield* invoke("t3_thread_send", { - threadId: emptyThread.threadId, - message: ordinaryLoopPrompt, - clientRequestId: "ordinary-loop-send-1", - }); - const sent = yield* decodeThreadSendResult(sendCall.structuredContent).pipe( - Effect.orDie, - ); - const sentSource = yield* orchestrator.getThreadProjection(emptyThread.threadId); - expect( - sentSource.messages.find((message) => message.id === sent.messageId), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - sentSource.turnItems.find( - (item) => item.type === "user_message" && item.messageId === sent.messageId, - ), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect(sent.delivery).toBe("started"); - const waitCall = yield* invoke("t3_thread_wait", { - threadId: emptyThread.threadId, - runId: sent.runId, - timeoutMs: 10_000, - }); - const waited = yield* decodeThreadWaitResult(waitCall.structuredContent).pipe( - Effect.orDie, - ); - expect(waited).toMatchObject({ - runId: sent.runId, - status: "completed", - timedOut: false, - }); - const repeatedSendCall = yield* invoke("t3_thread_send", { - threadId: emptyThread.threadId, - message: ordinaryLoopPrompt, - clientRequestId: "ordinary-loop-send-1", - }); - const repeatedSend = yield* decodeThreadSendResult( - repeatedSendCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedSend.runId).toBe(sent.runId); - expect( - (yield* orchestrator.getThreadProjection(emptyThread.threadId)).runs, - ).toHaveLength(1); - - const activeThreadCall = yield* invoke("create_threads", { - threads: [{ prompt: cancellationPrompt, title: "Managed active thread" }], - clientRequestId: "managed-active-thread-1", - }); - const activeThread = (yield* decodeCreateThreadsResult( - activeThreadCall.structuredContent, - ).pipe(Effect.orDie)).threads[0]!; - const activeThreadItem = (yield* orchestrator.getThreadProjection( - parentThreadId, - )).visibleTurnItems - .map((row) => row.item) - .find( - (item) => - item.type === "thread_created" && item.targetThreadId === activeThread.threadId, - ); - expect(activeThreadItem).toMatchObject({ - type: "thread_created", - title: "Managed active thread", - targetThreadId: activeThread.threadId, - targetRunId: activeThread.runId, - targetProviderInstanceId: codexInstanceId, - targetModel: codexModel, - }); - const activeProjection = yield* waitForProjection( - orchestrator, - activeThread.threadId, - (projection) => - projection.runs.some((run) => run.status === "running") && - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const activeRun = activeProjection.runs[0]!; - const activeTimeoutCall = yield* invoke("t3_thread_wait", { - threadId: activeThread.threadId, - runId: activeRun.id, - timeoutMs: 1, - }); - const activeTimeout = yield* decodeThreadWaitResult( - activeTimeoutCall.structuredContent, - ).pipe(Effect.orDie); - expect(activeTimeout).toMatchObject({ - runId: activeRun.id, - status: "running", - timedOut: true, - }); - const steerCall = yield* invoke("t3_thread_send", { - threadId: activeThread.threadId, - message: "Include the latest parent guidance before finishing.", - mode: "steer", - clientRequestId: "managed-active-steer-1", - }); - const steered = yield* decodeThreadSendResult(steerCall.structuredContent).pipe( - Effect.orDie, - ); - expect(steered).toMatchObject({ - runId: activeRun.id, - delivery: "steered", - }); - const steeredSource = yield* orchestrator.getThreadProjection(activeThread.threadId); - expect( - steeredSource.messages.find((message) => message.id === steered.messageId), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - steeredSource.turnItems.find( - (item) => item.type === "user_message" && item.messageId === steered.messageId, - ), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - const interruptCall = yield* invoke("t3_thread_interrupt", { - threadId: activeThread.threadId, - reason: "The orchestration loop has enough evidence.", - clientRequestId: "managed-active-interrupt-1", - }); - const interrupted = yield* decodeThreadInterruptResult( - interruptCall.structuredContent, - ).pipe(Effect.orDie); - expect(interrupted).toMatchObject({ - runId: activeRun.id, - status: "interrupt_requested", - }); - const interruptedWaitCall = yield* invoke("t3_thread_wait", { - threadId: activeThread.threadId, - runId: activeRun.id, - timeoutMs: 10_000, - }); - const interruptedWait = yield* decodeThreadWaitResult( - interruptedWaitCall.structuredContent, - ).pipe(Effect.orDie); - expect(interruptedWait.status).toBe("interrupted"); - const repeatedInterruptCall = yield* invoke("t3_thread_interrupt", { - threadId: activeThread.threadId, - runId: activeRun.id, - }); - const repeatedInterrupt = yield* decodeThreadInterruptResult( - repeatedInterruptCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedInterrupt.status).toBe("interrupted"); - - const foreignThreadId = ThreadId.make("thread:mcp-foreign-project"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-foreign-project:create"), - threadId: foreignThreadId, - projectId: ProjectId.make("project:mcp-foreign"), - title: "Foreign project thread", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - const foreignOrganizeCall = yield* invoke("t3_thread_organize", { - threadId: foreignThreadId, - action: "pin", - }); - expect(foreignOrganizeCall.isError).toBe(false); - expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).not.toBeNull(); - - const foreignReadCall = yield* invoke("t3_thread_read", { - threadId: foreignThreadId, - }); - expect(foreignReadCall.structuredContent).toMatchObject({ - thread: { threadId: foreignThreadId, projectId: "project:mcp-foreign" }, - }); - const foreignUpdateCall = yield* invoke("t3_thread_update", { - threadId: foreignThreadId, - action: "rename", - title: "Renamed from another project", - }); - expect(foreignUpdateCall.structuredContent).toMatchObject({ - threadId: foreignThreadId, - title: "Renamed from another project", - }); - const foreignListCall = yield* invoke("t3_thread_list", { - projectId: "project:mcp-foreign", - }); - const foreignListed = yield* decodeThreadListResult( - foreignListCall.structuredContent, - ).pipe(Effect.orDie); - expect(foreignListed.threads.map((thread) => thread.threadId)).toEqual([ - foreignThreadId, - ]); - const listCall = yield* invoke("t3_thread_list", { - includeSubagents: false, - limit: 100, - }); - const listed = yield* decodeThreadListResult(listCall.structuredContent).pipe( - Effect.orDie, - ); - expect(listed.projectId).toBe(projectId); - expect(listed.threads.map((thread) => thread.threadId)).toEqual( - expect.arrayContaining([ - parentThreadId, - emptyThread.threadId, - promptedThread.threadId, - activeThread.threadId, - ]), - ); - expect( - listed.threads.find((thread) => thread.threadId === emptyThread.threadId), - ).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(listed.threads.some((thread) => thread.threadId === foreignThreadId)).toBe( - false, - ); - expect( - listed.threads.some((thread) => thread.relationshipToParent === "subagent"), - ).toBe(false); - - const upgradedCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - }, - mode: "wait", - timeoutMs: 1, - clientRequestId: "delegate-wait-upgrade-1", - }); - const upgradedDelegated = yield* decodeDelegateTaskResult( - upgradedCall.structuredContent, - ).pipe(Effect.orDie); - expect(upgradedDelegated.status).toBe("running"); - yield* waitForProjection(orchestrator, upgradedDelegated.childThreadId, (projection) => - projection.providerTurns.some((turn) => turn.status === "running"), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === upgradedDelegated.taskId, - )?.completionWake, - ).toBe("always"); - const upgradeCancelCall = yield* invoke("task_cancel", { - taskId: upgradedDelegated.taskId, - reason: "Terminalize while the parent run is live.", - clientRequestId: "cancel-wait-upgrade-1", - }); - expect(upgradeCancelCall.isError).toBe(false); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === upgradedDelegated.taskId && task.status === "interrupted", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === upgradedDelegated.taskId, - )?.completionDelivery, - ).toMatchObject({ state: "disposed" }); - yield* expectOffersToStay(0); - - // A blocking wait returns the child's question instead of holding it until timeout, - // and hands later delivery to the completion wake. - const tasksBeforeQuestion = new Set( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.map( - (task) => task.id, - ), - ); - const questionWait = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { providerInstanceId: codexInstanceId, model: codexModel }, - mode: "wait", - timeoutMs: 600_000, - clientRequestId: "delegate-question-wait-1", - }).pipe(Effect.forkChild); - const questionTask = (yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.subagents.some( - (task) => !tasksBeforeQuestion.has(task.id) && task.childThreadId !== null, - ), - )).subagents.find((task) => !tasksBeforeQuestion.has(task.id))!; - const questionChildThreadId = questionTask.childThreadId!; - yield* waitForProjection( - orchestrator, - questionChildThreadId, - (projection) => - projection.runs.some((run) => run.status === "running") && - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const questionChild = yield* orchestrator.getThreadProjection(questionChildThreadId); - // An active orchestrator, including a blocking delegation wait, owns its child's answer. - yield* settings.updateSettings({ dontAskMode: true }); - const childScope = yield* scopeForThread(questionChild); - const routedQuestion = yield* startQuestion(childScope, [areasQuestion]); - const waitingQuestion = yield* Fiber.join(questionWait); - const routedProjection = yield* orchestrator.getThreadProjection(questionChildThreadId); - expect( - routedProjection.runtimeRequests.find( - (request) => request.id === routedQuestion.requestId, - ), - ).toMatchObject({ status: "pending", routedTo: parentThreadId }); - yield* dontAsk.handle(routedQuestion.event); - yield* dontAsk.sweep; - const pendingList = yield* invoke("t3_pending_request_list", { - threadId: questionChildThreadId, - }); - const parentAnswer = { areas: "Web" }; - const routedResponse = yield* invoke("t3_pending_request_respond", { - threadId: questionChildThreadId, - requestId: routedQuestion.requestId, - answers: parentAnswer, - }); - expect(declaredFailure(routedResponse)).toBeUndefined(); - expect(routedResponse.isError).toBe(false); - expect(pendingList.structuredContent).toEqual({ - requestIds: [routedQuestion.requestId], - }); - expect((yield* Fiber.join(routedQuestion.call)).structuredContent).toEqual( - parentAnswer, - ); - yield* settings.updateSettings({ dontAskMode: false }); - - const questionProviderThread = questionChild.providerThreads.find( - (thread) => thread.id === questionChild.thread.activeProviderThreadId, - )!; - const questionRequestId = routedQuestion.requestId; - const questionNodeId = routedProjection.runtimeRequests.find( - (request) => request.id === questionRequestId, - )!.nodeId; - const questionAt = yield* DateTime.now; - const questionResult = yield* decodeDelegateTaskResult( - waitingQuestion.structuredContent, - ).pipe(Effect.orDie); - expect(questionResult).toMatchObject({ - taskId: questionTask.id, - status: "running", - waitTimedOut: false, - waitingOnUser: { - kind: "input", - requestIds: [routedQuestion.requestId], - waitingOn: "orchestrator", - preview: expect.stringContaining(areasQuestion.question), - }, - }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === questionTask.id, - )?.completionWake, - ).toBe("always"); - const answeredQuestionChild = - yield* orchestrator.getThreadProjection(questionChildThreadId); - const answeredQuestionItem = answeredQuestionChild.turnItems.find( - (item) => item.type === "user_input_request" && item.requestId === questionRequestId, - ); - expect(answeredQuestionItem).toMatchObject({ - status: "completed", - respondedBy: "orchestrator", - }); - if (answeredQuestionItem?.type !== "user_input_request") - return yield* Effect.die("The answered question was not found."); - const { - type: _type, - questions: _questions, - questionAnswer: _questionAnswer, - respondedBy: _respondedBy, - ...approvalItemBase - } = answeredQuestionItem; - const approvalRequestId = RuntimeRequestId.make("request:native-child-approval"); - // This parent answers in its blocking call; asynchronous wake delivery is tested separately. - yield* orchestrator.dispatch({ - type: "delegated_task.wake-policy", - commandId: CommandId.make("command:native-approval-blocking-wait"), - parentThreadId, - taskId: questionTask.id, - completionWake: "settled_only", - }); - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("event:native-child-approval-request"), - type: "runtime-request.updated", - threadId: questionChildThreadId, - occurredAt: questionAt, - payload: { - id: approvalRequestId, - nodeId: questionNodeId, - providerTurnId: null, - nativeRequestRef: null, - kind: "command", - status: "pending", - responseCapability: { - type: "live", - providerSessionId: questionProviderThread.providerSessionId!, - }, - createdAt: questionAt, - resolvedAt: null, - }, - }, - { - id: EventId.make("event:native-child-approval-item"), - type: "turn-item.updated", - threadId: questionChildThreadId, - occurredAt: questionAt, - payload: { - ...approvalItemBase, - id: TurnItemId.make("item:native-child-approval"), - type: "approval_request", - requestId: approvalRequestId, - requestKind: "command", - status: "waiting", - completedAt: null, - prompt: "cat /tmp/requested-file", - }, - }, - ], - }); - const readApproval = yield* invoke("t3_pending_request_read", { - threadId: questionChildThreadId, - requestId: approvalRequestId, - }); - expect(readApproval.structuredContent).toMatchObject({ - requestId: approvalRequestId, - requestKind: "command", - prompt: "cat /tmp/requested-file", - }); - yield* settings.updateSettings({ dontAskMode: true }); - yield* dontAsk.sweep; - const approveChild = yield* invoke("t3_pending_request_respond", { - threadId: questionChildThreadId, - requestId: approvalRequestId, - decision: "accept", - }); - expect(approveChild.isError).toBe(false); - yield* settings.updateSettings({ dontAskMode: false }); - expect(yield* Deferred.await(approvalResponse)).toEqual({ - requestId: approvalRequestId, - decision: "accept", - }); - expect( - (yield* orchestrator.getThreadProjection(questionChildThreadId)).turnItems.find( - (item) => item.type === "approval_request" && item.requestId === approvalRequestId, - ), - ).toMatchObject({ status: "completed", respondedBy: "orchestrator" }); - // Clean up without a parent wake: settle delivery before the child terminalizes. - yield* orchestrator.dispatch({ - type: "delegated_task.completion-delivery.dispose", - commandId: CommandId.make("command:mcp-parent:dispose-question-wait"), - parentThreadId, - taskId: questionTask.id, - }); - yield* invoke("task_cancel", { - taskId: questionTask.id, - reason: "Clean up the question child.", - clientRequestId: "cancel-question-wait-1", - }); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === questionTask.id && task.status === "interrupted", - ), - ); - yield* expectOffersToStay(0); - - const liveQuestionWait = 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(liveQuestionWait)).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, - }); - yield* persistRequest({ - ...liveApproval, - responseCapability: { - type: "app_owned", - providerSessionId: approvalChild.providerSessions[0]!.id, - }, - }); - expect((yield* readApprovalStatus()).pendingApproval).toEqual(expectedApproval); - yield* persistRequest(liveApproval); - 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 }, @@ -5094,8 +210,6 @@ describe("orchestrator MCP toolkit", () => { }), ]); const layerTest = layerOrchestratorToolkit.pipe( - Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), - Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(layerOrchestration), Layer.provide( diff --git a/apps/server/src/mcp/PreviewAutomationBroker.fork.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.fork.test.ts new file mode 100644 index 0000000000000..52fbea5eccaf7 --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.fork.test.ts @@ -0,0 +1,237 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PreviewTabId, + ProviderInstanceId, + ThreadId, + type PreviewAutomationHost, + type PreviewAutomationRequest, + type PreviewAutomationStreamEvent, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Result from "effect/Result"; + +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; + +const brokerDependencies = (settings: Parameters[0] = {}) => + ServerSettings.layerTest(settings).pipe(Layer.provideMerge(NodeServices.layer)); +const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(brokerDependencies())); + +const scope = { + 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(["preview"] as const), + issuedAt: 1, +}; + +const makeHost = (overrides: Partial = {}): PreviewAutomationHost => ({ + clientId: "client-1", + environmentId: scope.environmentId, + ...overrides, +}); + +type RoutedRequest = PreviewAutomationRequest & { + readonly connectionId: PreviewAutomationStreamEvent["connectionId"]; +}; + +const requestsFrom = ( + events: Stream.Stream, + onConnected: (connectionId: PreviewAutomationStreamEvent["connectionId"]) => void = () => {}, +): Stream.Stream => + events.pipe( + Stream.filterMap((event) => { + if (event.type === "connected") { + onConnected(event.connectionId); + return Result.failVoid; + } + return Result.succeed({ ...event.request, connectionId: event.connectionId }); + }), + ); + +it.effect.each([ + { name: "heals a stuck reused tab once, then retries", limits: true, reuse: true }, + { name: "returns the timeout when agent browser tab limits are off", limits: false, reuse: true }, + { name: "does not retry an open that was creating a new tab", limits: true, reuse: false }, +])("timed-out open: $name", ({ limits, reuse }) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make.pipe( + Effect.provide(brokerDependencies({ agentBrowserTabLimits: limits })), + ); + const stuckTab = PreviewTabId.make("tab-stuck"); + // The session's host answers its first open, then stops answering. + const stuckReceived = yield* Deferred.make(); + let stuckRequests = 0; + yield* Stream.runForEach( + requestsFrom(yield* broker.connect(makeHost({ clientId: "stuck" }))), + (request) => + stuckRequests++ === 0 + ? broker.respond({ + clientId: "stuck", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { tabId: stuckTab }, + }) + : Deferred.succeed(stuckReceived, undefined), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.invoke({ scope, operation: "open", input: {} }); + + const healthyRequests: RoutedRequest[] = []; + yield* Stream.runForEach( + requestsFrom(yield* broker.connect(makeHost({ clientId: "healthy" }))), + (request) => { + healthyRequests.push(request); + return broker.respond({ + clientId: "healthy", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { tabId: request.tabId }, + }); + }, + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const opened = yield* broker + .invoke({ + scope, + operation: "open", + input: { url: "http://localhost:8791/", reuseExistingTab: reuse }, + }) + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(stuckReceived); + yield* Effect.yieldNow; + yield* TestClock.adjust(16_000); + const result = yield* Fiber.join(opened); + + if (limits && reuse) { + expect(result._tag).toBe("Success"); + expect( + healthyRequests.map(({ operation, input, tabId }) => ({ operation, input, tabId })), + ).toEqual([ + { + operation: "navigate", + input: { reload: "bypassCache", readiness: "none" }, + tabId: stuckTab, + }, + { + operation: "open", + input: { url: "http://localhost:8791/", reuseExistingTab: true }, + tabId: stuckTab, + }, + ]); + } else { + expect(result._tag === "Failure" && result.failure._tag).toBe( + "PreviewAutomationTimeoutError", + ); + expect(healthyRequests).toEqual([]); + } + }), + ), +); + +it.effect("stops after the retry of a timed-out open also times out", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const tabId = PreviewTabId.make("tab-stuck"); + const firstReceived = yield* Deferred.make(); + const retryReceived = yield* Deferred.make(); + const requests: RoutedRequest[] = []; + yield* Stream.runForEach( + requestsFrom(yield* broker.connect(makeHost({ clientId: "stuck" }))), + (request) => { + requests.push(request); + return Deferred.succeed(firstReceived, undefined); + }, + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const opened = yield* broker + .invoke({ scope, tabId, operation: "open", input: { url: "http://localhost:8791/" } }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Deferred.await(firstReceived); + + yield* Stream.runForEach( + requestsFrom(yield* broker.connect(makeHost({ clientId: "healthy" }))), + (request) => { + requests.push(request); + return request.operation === "navigate" + ? broker.respond({ + clientId: "healthy", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { tabId }, + }) + : Deferred.succeed(retryReceived, undefined); + }, + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust(16_000); + yield* Deferred.await(retryReceived); + yield* TestClock.adjust(16_000); + expect(yield* Fiber.join(opened)).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + operation: "open", + requestId: requests[2]!.requestId, + }); + expect(requests.map(({ operation, input, tabId }) => ({ operation, input, tabId }))).toEqual([ + { operation: "open", input: { url: "http://localhost:8791/" }, tabId }, + { operation: "navigate", input: { reload: "bypassCache", readiness: "none" }, tabId }, + { operation: "open", input: { url: "http://localhost:8791/" }, tabId }, + ]); + }), + ), +); + +it.effect("recovers from a host-reported navigation timeout on the same connection", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const tabId = PreviewTabId.make("tab-navigation-timeout"); + const requests: RoutedRequest[] = []; + yield* Stream.runForEach(requestsFrom(yield* broker.connect(makeHost())), (request) => { + requests.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ...(requests.length === 1 + ? { + ok: false, + error: { _tag: "PreviewAutomationTimeoutError", message: "Navigation timed out" }, + } + : { ok: true, result: { tabId } }), + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const input = { url: "http://localhost:8791/" }; + expect(yield* broker.invoke({ scope, tabId, operation: "navigate", input })).toEqual({ + tabId, + }); + expect(requests.map(({ operation, input, tabId }) => ({ operation, input, tabId }))).toEqual([ + { operation: "navigate", input, tabId }, + { operation: "navigate", input: { reload: "bypassCache", readiness: "none" }, tabId }, + { operation: "navigate", input, tabId }, + ]); + expect(new Set(requests.map((request) => request.connectionId)).size).toBe(1); + }), + ), +); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index c958ce2a4bcb9..7ae8bfbefead5 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,21 +14,14 @@ import { SERVER_BROWSER_AUTOMATION_CLIENT_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -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 ServerSettings from "../serverSettings.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; -const brokerDependencies = (settings: Parameters[0] = {}) => - ServerSettings.layerTest(settings).pipe(Layer.provideMerge(NodeServices.layer)); -const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(brokerDependencies())); +const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); const scope = { environmentId: EnvironmentId.make("environment-1"), @@ -205,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* () { @@ -330,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 }, @@ -580,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; @@ -893,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* () { @@ -1146,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* () { @@ -1321,363 +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.each([ - { name: "heals a stuck reused tab once, then retries", limits: true, reuse: true }, - { name: "returns the timeout when agent browser tab limits are off", limits: false, reuse: true }, - { name: "does not retry an open that was creating a new tab", limits: true, reuse: false }, -])("timed-out open: $name", ({ limits, reuse }) => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.make.pipe( - Effect.provide(brokerDependencies({ agentBrowserTabLimits: limits })), - ); - const stuckTab = PreviewTabId.make("tab-stuck"); - // The session's host answers its first open, then stops answering. - const stuckReceived = yield* Deferred.make(); - let stuckRequests = 0; - yield* Stream.runForEach( - requestsFrom(yield* broker.connect(makeHost({ clientId: "stuck" }))), - (request) => - stuckRequests++ === 0 - ? broker.respond({ - clientId: "stuck", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { tabId: stuckTab }, - }) - : Deferred.succeed(stuckReceived, undefined), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* broker.invoke({ scope, operation: "open", input: {} }); - - const healthyRequests: RoutedRequest[] = []; - yield* Stream.runForEach( - requestsFrom(yield* broker.connect(makeHost({ clientId: "healthy" }))), - (request) => { - healthyRequests.push(request); - return broker.respond({ - clientId: "healthy", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { tabId: request.tabId }, - }); - }, - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const opened = yield* broker - .invoke({ - scope, - operation: "open", - input: { url: "http://localhost:8791/", reuseExistingTab: reuse }, - }) - .pipe(Effect.result, Effect.forkScoped); - yield* Deferred.await(stuckReceived); - yield* Effect.yieldNow; - yield* TestClock.adjust(16_000); - const result = yield* Fiber.join(opened); - - if (limits && reuse) { - expect(result._tag).toBe("Success"); - expect( - healthyRequests.map(({ operation, input, tabId }) => ({ operation, input, tabId })), - ).toEqual([ - { - operation: "navigate", - input: { reload: "bypassCache", readiness: "none" }, - tabId: stuckTab, - }, - { - operation: "open", - input: { url: "http://localhost:8791/", reuseExistingTab: true }, - tabId: stuckTab, - }, - ]); - } else { - expect(result._tag === "Failure" && result.failure._tag).toBe( - "PreviewAutomationTimeoutError", - ); - expect(healthyRequests).toEqual([]); - } - }), - ), -); - -it.effect("stops after the retry of a timed-out open also times out", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const tabId = PreviewTabId.make("tab-stuck"); - const firstReceived = yield* Deferred.make(); - const retryReceived = yield* Deferred.make(); - const requests: RoutedRequest[] = []; - yield* Stream.runForEach( - requestsFrom(yield* broker.connect(makeHost({ clientId: "stuck" }))), - (request) => { - requests.push(request); - return Deferred.succeed(firstReceived, undefined); - }, - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const opened = yield* broker - .invoke({ scope, tabId, operation: "open", input: { url: "http://localhost:8791/" } }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Deferred.await(firstReceived); - - yield* Stream.runForEach( - requestsFrom(yield* broker.connect(makeHost({ clientId: "healthy" }))), - (request) => { - requests.push(request); - return request.operation === "navigate" - ? broker.respond({ - clientId: "healthy", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { tabId }, - }) - : Deferred.succeed(retryReceived, undefined); - }, - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(16_000); - yield* Deferred.await(retryReceived); - yield* TestClock.adjust(16_000); - expect(yield* Fiber.join(opened)).toMatchObject({ - _tag: "PreviewAutomationTimeoutError", - operation: "open", - requestId: requests[2]!.requestId, - }); - expect(requests.map(({ operation, input, tabId }) => ({ operation, input, tabId }))).toEqual([ - { operation: "open", input: { url: "http://localhost:8791/" }, tabId }, - { operation: "navigate", input: { reload: "bypassCache", readiness: "none" }, tabId }, - { operation: "open", input: { url: "http://localhost:8791/" }, tabId }, - ]); - }), - ), -); - -it.effect("recovers from a host-reported navigation timeout on the same connection", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const tabId = PreviewTabId.make("tab-navigation-timeout"); - const requests: RoutedRequest[] = []; - yield* Stream.runForEach(requestsFrom(yield* broker.connect(makeHost())), (request) => { - requests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ...(requests.length === 1 - ? { - ok: false, - error: { _tag: "PreviewAutomationTimeoutError", message: "Navigation timed out" }, - } - : { ok: true, result: { tabId } }), - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const input = { url: "http://localhost:8791/" }; - expect(yield* broker.invoke({ scope, tabId, operation: "navigate", input })).toEqual({ - tabId, - }); - expect(requests.map(({ operation, input, tabId }) => ({ operation, input, tabId }))).toEqual([ - { operation: "navigate", input, tabId }, - { operation: "navigate", input: { reload: "bypassCache", readiness: "none" }, tabId }, - { operation: "navigate", input, tabId }, - ]); - expect(new Set(requests.map((request) => request.connectionId)).size).toBe(1); - }), - ), -); - -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/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 70f09f880891c..7f69cffe017fa 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -349,7 +349,7 @@ const classifyResponseError = ( export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const crypto = yield* Crypto.Crypto; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); const state = yield* SynchronizedRef.make({ clients: new Map(), assignments: new Map(), @@ -701,9 +701,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { return result; }); - const tabLimitsEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.agentBrowserTabLimits), - Effect.orElseSucceed(() => true), + const tabLimitsEnabled = Option.isNone(serverSettings) ? Effect.succeed(true) : serverSettings.value.getSettings.pipe( + Effect.map((settings) => settings.agentBrowserTabLimits), Effect.orElseSucceed(() => true), ); const invoke = (request: PreviewAutomationInvokeInput) => { diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts deleted file mode 100644 index 411568f75745c..0000000000000 --- 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/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index 8f87797e9847b..af904f1d8e8a6 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -16,6 +16,7 @@ import * as Layer from "effect/Layer"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { threadNotFound } from "./OrchestratorMcpService.ts"; export class ThreadMetadataMcpService extends Context.Service< ThreadMetadataMcpService, @@ -168,7 +169,7 @@ const make = Effect.gen(function* () { ), ); if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`); } const target = yield* threadManagement .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index dc9972777291d..15fcc4a48f4cc 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -377,81 +377,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* () { @@ -465,18 +390,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* () { @@ -545,15 +458,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" }, @@ -568,60 +472,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* () { @@ -641,15 +491,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* () { @@ -659,17 +500,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* () { @@ -682,27 +512,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* () { @@ -779,15 +588,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(); @@ -812,32 +612,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* () { @@ -869,14 +643,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(); @@ -901,37 +667,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* () { @@ -943,20 +678,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: { @@ -974,46 +695,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/threadAccess.test.ts b/apps/server/src/mcp/threadAccess.test.ts new file mode 100644 index 0000000000000..a40f63baa6ca8 --- /dev/null +++ b/apps/server/src/mcp/threadAccess.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import { liveThreadShell } from "./McpToolAccess.testkit.ts"; +import { readThread } from "./threadAccess.ts"; + +const bareId = ThreadId.make("5d0b4a3e-8c1f-4a52-9d37-2f6c7e1b9a04"); +const client: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment:thread-access"), + requestNamespace: "client:thread-access", + thread: undefined, + client: { sessionId: "thread-access", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, +}; + +const readBare = (existing: ReadonlyArray) => + readThread(bareId).pipe( + Effect.flip, + Effect.provideService(McpInvocationContext.McpInvocationContext, client), + Effect.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed(existing.includes(threadId) ? liveThreadShell(threadId) : null), + }), + ), + ); + +it.effect("points a bare uuid at the mcp: thread it belongs to", () => + Effect.gen(function* () { + const error = yield* readBare([ThreadId.make(`mcp:${bareId}`)]); + + expect(error.code).toBe("thread_not_found"); + expect(error.message).toBe(`The thread was not found. Did you mean \`mcp:${bareId}\`?`); + }), +); + +it.effect("keeps the plain message for an id that matches nothing", () => + Effect.gen(function* () { + const error = yield* readBare([]); + + expect(error.code).toBe("thread_not_found"); + expect(error.message).toBe("The thread was not found."); + }), +); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 04e5b46e7a253..c548e2e8d8cf9 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -156,10 +156,11 @@ export const readThread = Effect.fn("mcp.readThread")(function* < const targetId = yield* resolveThreadId(context, threadId); const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); if (shell === null || shell.deletedAt !== null) { - return yield* new OrchestratorMcpFailure({ - code: "thread_not_found", - message: "The thread was not found.", - }); + return yield* OrchestrationMcp.threadNotFound( + context.threads, + targetId, + "The thread was not found.", + ); } const projection = yield* context.threads .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { diff --git a/apps/server/src/mcp/toolkits/core.fork.test.ts b/apps/server/src/mcp/toolkits/core.fork.test.ts new file mode 100644 index 0000000000000..c02185fa5004b --- /dev/null +++ b/apps/server/src/mcp/toolkits/core.fork.test.ts @@ -0,0 +1,440 @@ +import { ProjectArtifactsToolkit } from "./projectArtifacts/tools.ts"; +import * as IssueLinks from "../../issues/IssueLinks.ts"; +import * as IssueInbox from "../../issues/IssueInbox.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; + +import { expect, it } from "@effect/vitest"; + +import { + EnvironmentId, + NodeId, + OrchestratorMcpThreadReadResult, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnItemId, + type OrchestrationV2ProjectedTurnItem, +} from "@t3tools/contracts"; + +import * as Effect from "effect/Effect"; + +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { McpSchema, McpServer, Tool } from "effect/ai"; + +import { OrchestratorV2 } from "../../orchestration-v2/Orchestrator.ts"; + +import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; + +import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; +import * as SecretRequests from "../../secrets/SecretRequests.ts"; +import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as McpHttpServer from "../McpHttpServer.ts"; +import * as McpInvocationContext from "../McpInvocationContext.ts"; +import * as McpToolAccessTestkit from "../McpToolAccess.testkit.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 { ProjectToolkit } from "./project/tools.ts"; +import { AttachmentToolkit } from "./attachment/tools.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)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), +); + +// 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; +}; +import { PullRequestsToolkit } from "./pullRequests/tools.ts"; +import { HtmlToolkit } from "./html/tools.ts"; +import { + resolveT3McpToolDefinition, + resolveT3McpToolPresentation, +} from "@t3tools/shared/t3McpToolPresentation"; + +const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); + +const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(OrchestratorV2)({})), +); + +const decodeRepositoryLimit = Schema.decodeUnknownSync( + OrchestratorToolkit.tools.set_repository_worker_limit.parametersSchema, +); + +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, + ProjectArtifactsToolkit, + 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(resolveT3McpToolDefinition(name)?.summaryAction, name).not.toBeNull(); + } + } + } + expect(names.has("set_repository_worker_limit")).toBe(true); + const limitTool = OrchestratorToolkit.tools.set_repository_worker_limit; + expect( + decodeRepositoryLimit({ + host: "github.com", + repository: "owner/repo", + readOnlyLimit: 4, + expectedRevision: 1, + clientRequestId: "limit-read-only", + }), + ).toHaveProperty("readOnlyLimit", 4); + expect(limitTool.description).toContain("readOnlyLimit"); + 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"), + requestNamespace: "mcp-core-session", + thread: { + threadId, + providerSessionId: "mcp-core-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + issuedAt: 0, + capabilities: new Set(["orchestration"]), +}; +const client = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-core", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-core", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const clientScope = ( + access: McpInvocationContext.McpClientCaller["access"], +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("mcp-core-environment"), + requestNamespace: "client:session-1", + thread: undefined, + client: { sessionId: "session-1", label: "Claude Code", access }, + issuedAt: 0, + capabilities: new Set(["orchestration", "worktree", "pull-requests"]), +}); + +it.effect("refuses act-as-caller tools to a client caller", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "delegate_task", + arguments: { task: "Review", mode: "async" }, + }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + clientScope("full-access"), + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(result)).toMatchObject({ code: "thread_credential_required" }); + }).pipe( + Effect.provide( + layerOrchestratorToolkit.pipe( + Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), + Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ), + ), + ), +); + +it.effect("a caller cannot rewrite a scheduled task that runs above its own modes", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const update = yield* call("update_scheduled_task", { + scheduledTaskId: "task-full-access", + prompt: "Run something else", + }); + expect(declaredFailure(update)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" }); + expect(declaredFailure(remove)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const allowed = yield* call("update_scheduled_task", { + scheduledTaskId: "task-auto", + enabled: false, + }); + expect(allowed.isError).toBe(false); + }).pipe( + Effect.provide( + layerOrchestratorToolkit.pipe( + Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), + Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide( + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => + Effect.succeed({ + tasks: [ + scheduledTask("task-full-access", "full-access"), + scheduledTask("task-auto", "auto"), + ], + }), + upsert: (input) => + Effect.succeed({ + task: { ...(scheduledTask(input.id ?? "task-auto", "auto") as object), ...input }, + } as never), + }), + ), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ), + ), + ), +); + +function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never { + return { + id, + title: id, + prompt: "Check the build", + enabled: true, + projectId: "project-a", + threadId: null, + schedule: { type: "interval", everyMs: 3_600_000 }, + workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: true }, + modelSelection: { instanceId: "codex", model: "gpt-5" }, + runtimeMode, + interactionMode: "default", + createdBy: "user", + creationSource: "web", + nextRunAt: null, + lastRunStatus: "never", + lastRunAt: null, + lastRunThreadId: null, + lastRunError: null, + runCount: 0, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + } as never; +} + +it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( + "a caller cannot use %s on a thread that runs above its own modes", + (toolName) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: toolName, + arguments: { + threadId: "full-access-thread", + ...(toolName === "t3_workflow_stop" ? { subagentId: "workflow-coordinator" } : {}), + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + }).pipe( + Effect.provide( + layerOrchestratorToolkit.pipe( + Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), + Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + projectId: "project-a", + deletedAt: null, + runtimeMode: "full-access", + interactionMode: "default", + } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("full-access-thread"), + projectId: "project-a", + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + runs: [], + } as never), + stopWorkflow: () => Effect.die("workflow stop must not dispatch above the ceiling"), + interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ), + ), + ), +); + +it.effect("discovers workflow stop targets and requires orchestration capability", () => { + const threadId = ThreadId.make("workflow-parent"); + const shell = McpToolAccessTestkit.liveThreadShell(threadId); + const subagentId = NodeId.make("workflow-coordinator"); + const itemId = TurnItemId.make("workflow-coordinator-activity"); + const row: OrchestrationV2ProjectedTurnItem = { + position: 0, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: itemId, + item: { + id: itemId, + threadId, + runId: shell.activeRunId, + nodeId: subagentId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "running", + title: "Workflow: checkout review", + startedAt: shell.createdAt, + completedAt: null, + updatedAt: shell.updatedAt, + type: "subagent", + subagentId, + origin: "provider_native", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + childThreadId: ThreadId.make("workflow-child"), + prompt: "Review checkout", + result: null, + }, + }; + const stopped: Array<{ readonly threadId: ThreadId; readonly subagentId: string }> = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const scope = clientScope("full-access"); + const read = yield* server + .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(read.isError).toBe(false); + const result = yield* decodeThreadReadResult(read.structuredContent); + expect(result.items[0]?.subagentId).toBe(subagentId); + const call = (invocation: McpInvocationContext.McpInvocationScope) => + server + .callTool({ + name: "t3_workflow_stop", + arguments: { threadId, subagentId: result.items[0]?.subagentId }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const denied = yield* call({ ...scope, capabilities: new Set(["worktree"]) }); + expect(declaredFailure(denied)).toMatchObject({ code: "capability_denied" }); + expect(stopped).toEqual([]); + const allowed = yield* call(scope); + expect(allowed.isError).toBe(false); + expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); + }).pipe( + Effect.provide( + layerOrchestratorToolkit.pipe( + Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), + Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: () => + Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)), + getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }), + stopWorkflow: (input) => + Effect.sync(() => { + stopped.push(input); + }), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ), + ), + ); +}); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 7bdb0d2d58f38..02216cec576de 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,11 +1,6 @@ -import { ProjectArtifactsToolkit } from "./projectArtifacts/tools.ts"; -import * as IssueLinks from "../../issues/IssueLinks.ts"; -import * as IssueInbox from "../../issues/IssueInbox.ts"; 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, @@ -16,26 +11,25 @@ import { ProviderDriverKind, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, 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 { OrchestratorCommandRejectedError, OrchestratorDispatchError, OrchestratorProjectionError, - OrchestratorV2, } from "../../orchestration-v2/Orchestrator.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; @@ -55,18 +49,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)({})), @@ -79,13 +63,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); @@ -94,63 +71,8 @@ const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadR const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), - Layer.provide(Layer.mock(OrchestratorV2)({})), -); - -const decodeRepositoryLimit = Schema.decodeUnknownSync( - OrchestratorToolkit.tools.set_repository_worker_limit.parametersSchema, ); -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, - ProjectArtifactsToolkit, - 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("set_repository_worker_limit")).toBe(true); - const limitTool = OrchestratorToolkit.tools.set_repository_worker_limit; - expect( - decodeRepositoryLimit({ - host: "github.com", - repository: "owner/repo", - readOnlyLimit: 4, - expectedRevision: 1, - clientRequestId: "limit-read-only", - }), - ).toHaveProperty("readOnlyLimit", 4); - expect(limitTool.description).toContain("readOnlyLimit"); - 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"), @@ -204,36 +126,30 @@ it.effect("checks capability through the production registration", () => ), ); -it.effect("returns a bounded public failure without serializing storage causes", () => - Effect.gen(function* () { +it.effect("an answered question reports already answered instead of not found", () => { + const shell = McpToolAccessTestkit.liveThreadShell(threadId); + const requestId = RuntimeRequestId.make("answered-question"); + const projection = McpToolAccessTestkit.idleThreadProjection(shell); + return 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.", + const respond = (id: string) => + server + .callTool({ + name: "t3_pending_request_respond", + arguments: { requestId: id, answers: { size: "large" } }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(yield* respond(requestId))).toMatchObject({ + code: "invalid_request", + message: "The user-input request was already answered.", + }); + expect(declaredFailure(yield* respond("missing-question"))).toMatchObject({ + code: "invalid_request", + message: "The pending user-input request was not found.", }); - 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( @@ -241,19 +157,53 @@ it.effect("returns a bounded public failure without serializing storage causes", Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: new Error("private-storage-path"), - }), - ), + getThreadShell: () => Effect.succeed(shell), + getProjectThreadRecords: () => + Effect.succeed({ + ...projection, + runtimeRequests: [ + { + id: requestId, + nodeId: NodeId.make("root"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "resolved", + responseCapability: { type: "message" }, + createdAt: shell.createdAt, + resolvedAt: shell.updatedAt, + answers: { size: "large" }, + }, + ], + turnItems: [ + { + id: TurnItemId.make("answered-question-item"), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: shell.createdAt, + completedAt: shell.updatedAt, + updatedAt: shell.updatedAt, + type: "user_input_request", + requestId, + questions: [], + }, + ], + }), + dispatch: () => Effect.die("an answered question must not dispatch again"), }), ), ), ), - ), -); + ); +}); it("bounds public command rejections and redacts internal dispatch causes", () => { const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" }; @@ -337,28 +287,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; @@ -388,132 +316,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, @@ -733,8 +535,6 @@ it.effect("refuses act-as-caller tools to a client caller", () => }).pipe( Effect.provide( layerOrchestratorToolkit.pipe( - Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), - Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), @@ -774,8 +574,6 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode }).pipe( Effect.provide( layerOrchestratorToolkit.pipe( - Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), - Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), @@ -851,8 +649,6 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( }).pipe( Effect.provide( layerOrchestratorToolkit.pipe( - Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), - Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide( @@ -957,8 +753,6 @@ it.effect("discovers workflow stop targets and requires orchestration capability }).pipe( Effect.provide( layerOrchestratorToolkit.pipe( - Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), - Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide( diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 110d4cbba72a6..0ad4b95581b2d 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 4c06fbb878451..8fdbd264b8726 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.fork.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.fork.test.ts new file mode 100644 index 0000000000000..24ecad613e8ab --- /dev/null +++ b/apps/server/src/mcp/toolkits/environment/handlers.fork.test.ts @@ -0,0 +1,129 @@ +import { EnvironmentToolkit } from "./tools.ts"; +import * as EnvironmentHandlers from "./handlers.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as Sidebar from "../../../sidebarLayout.ts"; +import { EMPTY_SIDEBAR_LAYOUT } from "@t3tools/contracts"; +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 { McpSchema, McpServer } from "effect/ai"; + +import * as Environment from "../../../environment/ServerEnvironment.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 { liveThreadShell } from "../../McpToolAccess.testkit.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 }, + }), +}); + +const callTool = (name: string, args: Record) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + return yield* server.callTool({ name, arguments: args }).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"), + }), + ), + ); + }); +const layerMcpEnvironment = McpHttpServer.toolkitRegistration( + EnvironmentToolkit, + EnvironmentHandlers.layer, +).pipe( + Layer.provide(ThreadCommandExecutor.layer), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(liveThreadShell(id)), + }), + ), + Layer.provide(layerEnvironment), + Layer.provide( + Layer.mock(Sidebar.SidebarLayoutService)({ + get: Effect.succeed({ + revision: 0, + legacyImportedAt: null, + legacyImportId: null, + layout: EMPTY_SIDEBAR_LAYOUT, + }), + }), + ), + Layer.provideMerge(Settings.layerTest()), +); + +it.effect("updates preferences for a thread caller through the /mcp registration", () => + Effect.gen(function* () { + const result = yield* callTool("t3_environment_preferences_update", { + newWorktreesStartFromOrigin: true, + }); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ newWorktreesStartFromOrigin: true }); + }).pipe(Effect.provide(layerMcpEnvironment)), +); + +it.effect("edits local server hide rules against the latest list, keeping the owner's rules", () => + Effect.gen(function* () { + const settings = yield* Settings.ServerSettingsService; + const serena = { kind: "process", processName: "serena" } as const; + const syncthing = { kind: "process", processName: "syncthing" } as const; + const port = { kind: "ports", from: 3773, to: 3773 } as const; + yield* settings.updateSettings({ localServerHideRules: [serena] }); + // The owner adds a rule in Settings after the agent last read the list. + yield* settings.updateSettings({ localServerHideRules: [serena, syncthing] }); + const added = yield* callTool("t3_environment_preferences_update", { + addLocalServerHideRules: [port, { kind: "process", processName: "SERENA" }], + removeLocalServerHideRules: [{ kind: "process", processName: "not-hidden" }], + }); + expect(added.structuredContent).toMatchObject({ + localServerHideRules: [serena, syncthing, port], + }); + yield* callTool("t3_environment_preferences_update", { + removeLocalServerHideRules: [{ kind: "process", processName: "Serena" }], + }); + const read = yield* callTool("t3_environment_read", {}); + expect(read.structuredContent).toMatchObject({ + preferences: { localServerHideRules: [syncthing, port] }, + }); + }).pipe(Effect.provide(layerMcpEnvironment)), +); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts index 155cbecfcf0a0..b4d39bd41c17f 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -1,5 +1,3 @@ -import * as Sidebar from "../../../sidebarLayout.ts"; -import { EMPTY_SIDEBAR_LAYOUT } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, @@ -14,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"; @@ -29,106 +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 }, - }), -}); - -const callTool = (name: string, args: Record) => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - return yield* server.callTool({ name, arguments: args }).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"), - }), - ), - ); - }); -const layerMcpEnvironment = 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( - Layer.mock(Sidebar.SidebarLayoutService)({ - get: Effect.succeed({ - revision: 0, - legacyImportedAt: null, - legacyImportId: null, - layout: EMPTY_SIDEBAR_LAYOUT, - }), - }), - ), - Layer.provideMerge(Settings.layerTest()), -); - -it.effect("updates preferences for a thread caller through the /mcp registration", () => - Effect.gen(function* () { - const result = yield* callTool("t3_environment_preferences_update", { - newWorktreesStartFromOrigin: true, - }); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ newWorktreesStartFromOrigin: true }); - }).pipe(Effect.provide(layerMcpEnvironment)), -); - -it.effect("edits local server hide rules against the latest list, keeping the owner's rules", () => - Effect.gen(function* () { - const settings = yield* Settings.ServerSettingsService; - const serena = { kind: "process", processName: "serena" } as const; - const syncthing = { kind: "process", processName: "syncthing" } as const; - const port = { kind: "ports", from: 3773, to: 3773 } as const; - yield* settings.updateSettings({ localServerHideRules: [serena] }); - // The owner adds a rule in Settings after the agent last read the list. - yield* settings.updateSettings({ localServerHideRules: [serena, syncthing] }); - const added = yield* callTool("t3_environment_preferences_update", { - addLocalServerHideRules: [port, { kind: "process", processName: "SERENA" }], - removeLocalServerHideRules: [{ kind: "process", processName: "not-hidden" }], - }); - expect(added.structuredContent).toMatchObject({ - localServerHideRules: [serena, syncthing, port], - }); - yield* callTool("t3_environment_preferences_update", { - removeLocalServerHideRules: [{ kind: "process", processName: "Serena" }], - }); - const read = yield* callTool("t3_environment_read", {}); - expect(read.structuredContent).toMatchObject({ - preferences: { localServerHideRules: [syncthing, port] }, - }); - }).pipe(Effect.provide(layerMcpEnvironment)), -); it.effect("refuses a preferences update when the caller's turn ends while it waits", () => Effect.gen(function* () { @@ -136,14 +32,6 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai const updates = yield* Ref.make(0); const checked = yield* Deferred.make(); const layerDependencies = Layer.mergeAll( - Layer.mock(Sidebar.SidebarLayoutService)({ - get: Effect.succeed({ - revision: 0, - legacyImportedAt: null, - legacyImportId: null, - layout: EMPTY_SIDEBAR_LAYOUT, - }), - }), ThreadCommandExecutor.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId, @@ -172,7 +60,7 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai }), Layer.mock(Settings.ServerSettingsService)({ getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), - updateSettingsWith: () => + updateSettings: () => Ref.update(updates, (count) => count + 1).pipe(Effect.as(DEFAULT_SERVER_SETTINGS)), }), ); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index 5c34c5f8b31b3..195a6d676dc67 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -1,3 +1,4 @@ +import * as Option from "effect/Option"; import * as Sidebar from "../../../sidebarLayout.ts"; import { editLocalServerHideRules, @@ -59,9 +60,11 @@ export const layer = McpToolAccess.toLayer(EnvironmentToolkit, { platform: descriptor.platform, preferences: { ...preferences(current), - sidebarLayout: (yield* (yield* Sidebar.SidebarLayoutService).get.pipe( - Effect.mapError(unavailable), - )).layout, + ...yield* Effect.gen(function* () { + const sidebar = yield* Effect.serviceOption(Sidebar.SidebarLayoutService); + if (Option.isNone(sidebar)) return {}; + return { sidebarLayout: (yield* sidebar.value.get.pipe(Effect.mapError(unavailable))).layout }; + }), }, }; }), diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index 708ebe8a9694b..db72ce65307f3 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -39,7 +39,6 @@ const shared = { ThreadManagementService.ThreadManagementService, ServerEnvironment.ServerEnvironment, Settings.ServerSettingsService, - Sidebar.SidebarLayoutService, ThreadCommandExecutor.ThreadCommandExecutor, ], }; diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index b85e808fa4fa7..37592e0432713 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -91,11 +91,11 @@ const handlers = { return yield* service.restoreRepositoryRoot(scope, input); }), ), - orchestrator_capabilities: McpToolAccess.reads(() => + orchestrator_capabilities: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.capabilities(scope); + return yield* service.capabilities(scope, input); }), ), delegate_task: McpToolAccess.actsAsCaller((input) => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index b8f735069d31c..75db3e4d59786 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -1,91 +1,16 @@ -import { assert, describe, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import { Tool } from "effect/ai"; -import { - CreateThreadsTool, - DelegateTaskTool, - OrchestratorToolkit, - ScheduleTaskTool, - ThreadUpdateTool, -} from "./tools.ts"; +import { OrchestratorToolkit } 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"); - }); +// Oversized page sizes clamp on decode, so the schema has no `maximum`; Codex also drops +// bounds from its TypeScript view. The description is the only place a model sees a limit. +it("states t3_thread_read page size limits in the parameter descriptions", () => { + const schema = Tool.getJsonSchema(OrchestratorToolkit.tools.t3_thread_read) as { + readonly properties: Readonly>; + }; + const limits = { limit: "100", runLimit: "50", maxCharsPerItem: "50000" }; + for (const [name, maximum] of Object.entries(limits)) { + expect(schema.properties[name]?.description, name).toContain(maximum); + } }); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index b04795cb72982..e7b61e9c4c243 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestrationV2StopWorkflowInput, OrchestratorMcpCreateThreadsInput, @@ -80,7 +81,8 @@ const ProposeIssueChangeTool = Tool.make("propose_issue_change", { const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller, plus the user's model role alternatives with exact targets, weekly quota headroom, and why any is unavailable. Choices are ordered by current headroom; choose for task fit. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. It also includes the user's model role alternatives with exact targets, weekly quota headroom, and availability; choices are ordered by current headroom. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -91,7 +93,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='worktree' for a new branch and checkout from this thread's current local commit; project setup runs before the child starts, and uncommitted edits are not copied. The result and task_status include branch and worktreePath. Implementor defaults to worktree, falling back to this checkout with a workspaceNote when no Git commit exists; other roles default to shared. Set isolation='shared' to keep editing in this checkout. Child commits remain on their branch until merged or cherry-picked, and T3 does not remove the worktree automatically. 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, pendingApproval or waitingOnUser is present, 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. Read the fresh full request before responding, and answer or approve only within the delegated task's authority. 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. For repository-managed issue work, supply issue and use async mode: this starts an eligible issue directly or returns its current claimed worker. Managed issue workers always get a new issue worktree on a fresh branch from the repository default branch; isolation applies only to other tasks.", parameters: OrchestratorMcpDelegateTaskInput, @@ -142,9 +144,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. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. 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, @@ -171,7 +173,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, @@ -205,7 +207,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, @@ -247,7 +249,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 not supported here; use t3_worktree_handoff to move into a worktree and t3_worktree_return_to_root to move back to the main checkout. 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 a2a88e14fcefa..c46e358518084 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 21816ebb10f18..f1c1930fc3fb8 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -39,9 +39,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 4212c711c3f2e..6275e8cf41a4e 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 478fc0ed85155..4cd06bc4233b7 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"; @@ -15,12 +13,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; -import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; import * as ServerConfig from "../../../config.ts"; import * as Preview from "../../../preview/Manager.ts"; -import * as ProjectFaviconResolver from "../../../project/ProjectFaviconResolver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; -import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; @@ -56,27 +51,19 @@ it.effect.each([ capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), issuedAt: 0, }; - const configLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-preview-controls-", - }).pipe(Layer.provide(NodeServices.layer)); const manager = yield* Preview.make.pipe( - Effect.provide(Layer.mergeAll(configLayer, NodeCrypto.layer, ServerSettings.layerTest())), - ); - const tab = yield* manager.open({ threadId, url: "http://localhost:3000" }); - const layerDependencies = Layer.mergeAll( - // The broker reads the agent tab limit and can sign file URLs; neither runs here. - PreviewAutomationBroker.layer.pipe( - Layer.provide( - Layer.mergeAll( - configLayer, - NodeServices.layer, - ServerSettings.layerTest(), - Layer.mock(ProjectFaviconResolver.ProjectFaviconResolver)({}), - Layer.mock(ServerSecretStore.ServerSecretStore)({}), - Layer.mock(WorkspacePaths.WorkspacePaths)({}), + 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" }); + const layerDependencies = Layer.mergeAll( + PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)), Layer.succeed(Preview.PreviewManager, manager), Layer.succeed(McpInvocationContext.McpInvocationContext, scope), McpToolAccessTestkit.liveThreadsLayer, @@ -114,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), - ), - Layer.mergeAll(NodeCrypto.layer, ServerSettings.layerTest()), - ), - ), - ); - 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.fork.test.ts b/apps/server/src/mcp/toolkits/project/handlers.fork.test.ts new file mode 100644 index 0000000000000..01be8ea9fad97 --- /dev/null +++ b/apps/server/src/mcp/toolkits/project/handlers.fork.test.ts @@ -0,0 +1,151 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ServerConfig from "../../../config.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import * as Project from "../../../project/ProjectService.ts"; +import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as ProjectHandlers from "./handlers.ts"; +import { ProjectToolkit } from "./tools.ts"; + +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 }; + }), + update: (input) => + Effect.sync(() => { + return { ...createdProject, id: input.projectId }; + }), + 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", + sectionId: "games", + sidebarExpanded: true, + }); + 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", + sectionId: "games", + sidebarExpanded: true, + }); + 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"]); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 2e253417555de..4c22393bf4761 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -3,9 +3,9 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, - ThreadId, - type OrchestrationV2ThreadShell, type Project as ProjectRecord, + type ModelSelection, + type RuntimeMode, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -29,295 +29,11 @@ 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), - // The launch guard reads only whether the caller is a managed issue session. - getThreadRecords: () => Effect.succeed({ thread: caller } as never), - }), - 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), - // The launch guard reads only whether the caller is a managed issue session. - getThreadRecords: () => Effect.succeed({ thread: caller } as never), - }), - 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 }; - }), - update: (input) => - Effect.sync(() => { - return { ...createdProject, id: input.projectId }; - }), - 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", - sectionId: "games", - sidebarExpanded: true, - }); - 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", - sectionId: "games", - sidebarExpanded: true, - }); - 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 runtimeModeCeiling: RuntimeMode; + readonly defaultRuntimeMode?: RuntimeMode; + /** Where Settings saves a project's model; the legacy project field stays null. */ + readonly overrideModel?: ModelSelection; readonly launched: Array; readonly workspaceRoot?: string; readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; @@ -328,7 +44,19 @@ const clientLaunchHarness = (input: { model: "claude-opus", }; const layerDependencies = Layer.mergeAll( - ServerSettings.layerTest(), + ServerSettings.layerTest({ + ...(input.defaultRuntimeMode === undefined + ? {} + : { defaultRuntimeMode: input.defaultRuntimeMode }), + ...(input.overrideModel === undefined + ? {} + : { + projectSettingsFolded: true, + projectSettingsOverrides: { + [projectId]: { defaultModelSelection: input.overrideModel }, + }, + }), + }), NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), @@ -368,7 +96,7 @@ const clientLaunchHarness = (input: { ? Option.some({ id, workspaceRoot: input.workspaceRoot ?? "/projects/client-target", - defaultModelSelection: modelSelection, + defaultModelSelection: input.overrideModel === undefined ? modelSelection : null, } as unknown as ProjectRecord) : Option.none(), ), @@ -388,35 +116,85 @@ const clientLaunchHarness = (input: { return { projectId, modelSelection, dependencies: layerDependencies }; }; -it.effect("a client launches at its ceiling with the project's default model", () => - Effect.gen(function* () { - const launched: Array = []; - const { projectId, modelSelection, dependencies } = clientLaunchHarness({ - runtimeModeCeiling: "auto-accept-edits", - launched, - }); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), - ), - ); - const handle = (params: Parameters>[1]) => - toolkit - .handle("t3_thread_launch", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); +it.effect( + "a client launches with the project's default model and mode, capped at its ceiling", + () => + Effect.gen(function* () { + const launchWith = Effect.fn("test.launchWith")(function* ( + runtimeModeCeiling: RuntimeMode, + defaultRuntimeMode: RuntimeMode, + params: { + readonly title: string; + readonly message?: string; + readonly runtimeMode?: RuntimeMode; + readonly targeted?: false; + readonly overrideModel?: ModelSelection; + }, + ) { + const { targeted, overrideModel, ...input } = params; + const launched: Array = []; + const { projectId, modelSelection, dependencies } = clientLaunchHarness({ + runtimeModeCeiling, + defaultRuntimeMode, + launched, + ...(overrideModel === undefined ? {} : { overrideModel }), + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", targeted === false ? input : { ...input, projectId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + return { result: result.at(-1)?.result, launched, projectId, modelSelection }; + }); - const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); - expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); - expect(launched[0]?.runtimeMode).toBe("auto-accept-edits"); - expect(launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); + const belowCeiling = yield* launchWith("full-access", "approval-required", { + title: "Fix", + message: "Fix the bug", + }); + expect(belowCeiling.result).toMatchObject({ + projectId: belowCeiling.projectId, + modelSelection: belowCeiling.modelSelection, + }); + expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); + expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); - const escalated = yield* handle({ title: "Fix", projectId, runtimeMode: "full-access" }); - expect(escalated.at(-1)?.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { title: "Fix" }); + expect(aboveCeiling.launched[0]?.runtimeMode).toBe("auto-accept-edits"); - const untargeted = yield* handle({ title: "Fix" }); - expect(untargeted.at(-1)?.result).toMatchObject({ code: "target_required" }); - expect(launched).toHaveLength(1); - }), + const requested = yield* launchWith("full-access", "approval-required", { + title: "Fix", + runtimeMode: "auto", + }); + expect(requested.launched[0]?.runtimeMode).toBe("auto"); + + const escalated = yield* launchWith("auto-accept-edits", "auto-accept-edits", { + title: "Fix", + runtimeMode: "full-access", + }); + expect(escalated.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + expect(escalated.launched).toHaveLength(0); + + const untargeted = yield* launchWith("full-access", "full-access", { + title: "Fix", + targeted: false, + }); + expect(untargeted.result).toMatchObject({ code: "target_required" }); + + const overrideModel = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-settings", + }; + const fromSettings = yield* launchWith("full-access", "full-access", { + title: "Fix", + overrideModel, + }); + expect(fromSettings.launched[0]?.modelSelection).toEqual(overrideModel); + }), ); it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index a338ccd479c3f..edb7c7c60853d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -9,6 +9,9 @@ import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -142,10 +145,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }), ), ); + // A caller outside a thread has nothing to inherit, so it starts with the + // project's effective defaults, as the composer does. Its mode stays + // within the ceiling it was approved with. + const projectDefaults = + caller === undefined + ? yield* ServerSettings.ServerSettingsService.pipe( + Effect.flatMap((settings) => settings.getSettings), + Effect.mapError(unavailable), + Effect.flatMap((settings) => + Effect.map( + readProject, + (project) => resolveProjectSettings(settings, projectId, project).settings, + ), + ), + ) + : undefined; const modelSelection = input.modelSelection ?? caller?.modelSelection ?? - (yield* readProject)?.defaultModelSelection ?? + projectDefaults?.defaultModelSelection ?? undefined; if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ @@ -153,13 +172,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { message: "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", }); + const startedRuntimeMode = + projectDefaults !== undefined && input.runtimeMode === undefined + ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( + Effect.orElseSucceed(() => runtimeMode), + ) + : runtimeMode; const result = yield* ThreadMessageIntake.launchThread({ commandId, threadId, projectId, title: input.title, modelSelection, - runtimeMode, + runtimeMode: startedRuntimeMode, interactionMode, workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, ...(input.message === undefined && attachments.length === 0 diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 23d48eba21e89..e9ee5a05ad943 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/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 9ae14192d3c0e..330fc85bc9ff1 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -67,8 +67,7 @@ const readPendingRequest = Effect.fn("mcp.readPendingRequest")(function* (input: (request) => request.id === input.requestId && request.kind !== "auth_refresh" && - request.kind !== "dynamic_tool_call" && - request.status === "pending", + request.kind !== "dynamic_tool_call", ); const item = context.projection.turnItems.find( (item) => @@ -81,7 +80,15 @@ const readPendingRequest = Effect.fn("mcp.readPendingRequest")(function* (input: ) return yield* new OrchestratorMcpFailure({ code: "invalid_request", - message: "The pending question or approval was not found.", + message: "The pending user-input request was not found.", + }); + if (request.status !== "pending") + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + request.status === "resolved" + ? "The user-input request was already answered." + : `The user-input request is no longer pending (${request.status}).`, }); return { ...context, request, item }; }); @@ -277,7 +284,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { ), t3_pending_request_respond: writesThread((input) => Effect.gen(function* () { - const { threads, projection, caller } = yield* readThread(input.threadId); + const { threads, projection, caller } = yield* readPendingRequest(input); const result = yield* threads .respondToPendingRequest({ projectId: projection.thread.projectId, diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 5710f09a94ed6..b1f1f5b8808b9 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -161,7 +161,7 @@ const RequestUserInputTool = Tool.make("t3_request_user_input", { success: ProviderUserInputAnswers, failure: OrchestratorMcpFailure, failureMode: "return" as const, - dependencies: [...commandTool.dependencies, Orchestrator.OrchestratorV2], + dependencies: commandTool.dependencies, }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.fork.test.ts similarity index 96% rename from apps/server/src/mcp/toolkits/worktree/registration.test.ts rename to apps/server/src/mcp/toolkits/worktree/registration.fork.test.ts index a65b3ebed8c2b..726afe52202df 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.fork.test.ts @@ -1,3 +1,4 @@ +import * as Context from "effect/Context"; import * as ProjectArtifacts from "../../../project/ProjectArtifacts.ts"; import * as Sidebar from "../../../sidebarLayout.ts"; import { EMPTY_SIDEBAR_LAYOUT } from "@t3tools/contracts"; @@ -102,16 +103,18 @@ const decodeToolsListPayload = Schema.decodeUnknownEffect(ToolsListPayload); it.effect("production mcp layer lists worktree tools over http", () => Effect.scoped( Effect.gen(function* () { + const layerRegistry = McpSessionRegistry.layer; const layerRoutes = McpHttpServer.layer.pipe( Layer.provide(Layer.mock(IssueLinks.IssueLinks)({})), Layer.provide(Layer.mock(IssueInbox.IssueInbox)({})), Layer.provide(Layer.mock(ProjectArtifacts.ProjectArtifacts)({})), - Layer.provide(McpSessionRegistry.layer), + Layer.provide(layerRegistry), ); - yield* HttpRouter.serve(layerRoutes, { + const context = yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true, }).pipe( + Layer.provideMerge(layerRegistry), Layer.provide( Layer.mock(ServerEnvironment.ServerEnvironment)({ getEnvironmentId: Effect.succeed("environment-scratch" as never), @@ -123,7 +126,7 @@ it.effect("production mcp layer lists worktree tools over http", () => Layer.build, ); - const registry = McpSessionRegistry.issueActiveMcpCredential({ + const registry = Context.get(context, McpSessionRegistry.McpSessionRegistry).issue({ threadId: ThreadId.make("thread-scratch"), providerInstanceId: ProviderInstanceId.make("claudeAgent"), }); diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index e98f824d60889..1fde091c06423 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 d70f5af1d096d..c749c1a845798 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 2ccf2591fe7e2..767e10bce8fb6 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 3e03190a6edb3..5e4d55ff59bd0 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 0beb466e50194..0000000000000 --- 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 d015dce3c2a55..e452f4f5ce341 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 ea1e60012d415..0000000000000 --- 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/Metrics.ts b/apps/server/src/observability/Metrics.ts index 6ba17819ceafb..9d3b3e13c0fcb 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -63,6 +63,18 @@ export const serverEventLoopDelayMax = Metric.gauge("t3_server_event_loop_delay_ attributes: { unit: "ms" }, }); +export const runsTotal = Metric.counter("t3_runs_total", { + description: "Runs that finished, by outcome. A later rollback does not count again.", +}); + +export const runDuration = Metric.timer("t3_run_duration", { + description: "Wall time of a run, from when it started to its terminal status.", +}); + +export const runQueueWait = Metric.timer("t3_run_queue_wait", { + description: "Time a run waited between being requested and starting.", +}); + export const gitCommandsTotal = Metric.counter("t3_git_commands_total", { description: "Total git commands executed by the server runtime.", }); diff --git a/apps/server/src/observability/OtlpEndpointCheck.test.ts b/apps/server/src/observability/OtlpEndpointCheck.test.ts index 47d4ad76a6fe3..ad9cf66004e08 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 84827fa180455..0000000000000 --- 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 170c3802e5b3f..0000000000000 --- 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 82cd3d7a3a127..1a0c6caf1f925 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 06e165ada7796..fab0020b7e03e 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 340bdd08898e5..16d657bc192c6 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", [ @@ -115,17 +97,21 @@ 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" }, + it("keeps the question card to the agent's own options so a typed answer cannot be sent", () => { + const question = flavor.extractPermissionQuestion?.( + permissionRequest("interaction_2", [ + { optionId: "opt-main", name: "main", kind: "allow_once" }, + { optionId: "opt-dev", name: "dev", kind: "allow_once" }, ]), ); + assert.strictEqual(question?.question.allowCustomAnswer, false); assert.deepEqual( - options?.map((option) => option.decision), - ["accept", "decline", "cancel"], + question?.question.options.map((option) => option.value), + ["opt-main", "opt-dev"], ); + assert.deepEqual(question?.respond({ interaction_2: "opt-dev" }), { + outcome: { outcome: "selected", optionId: "opt-dev" }, + }); }); }); @@ -408,111 +394,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/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index fb7130ed16392..1ea491b76b71c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -198,16 +198,7 @@ export function makeAntigravityAcpAdapterFlavor( const question = extractAntigravityUserInputQuestion(request); if (question === undefined) return undefined; return { - question: { - id: question.id, - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - multiSelect: false, - }, + question, respond: (answers) => makeAntigravityUserInputResponse(request, answers), }; }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.fork.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.fork.test.ts index 222743c9366bb..eab6d9c5f8d19 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.fork.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.fork.test.ts @@ -1,229 +1,198 @@ +import { + WAKE_NATIVE_SESSION, + claudeSdkFrame, + makeResultFrame, + awaitUntil, + makeWakeHarness, + providerThreadRosterEvents, +} from "./ClaudeAdapterV2.testHarness.ts"; +import { + DEFAULT_CLAUDE_SETTINGS, + CLAUDE_TEST_MODEL_SELECTION, + CLAUDE_TEST_RUNTIME_POLICY, + makeClaudeTestTurnInput, +} from "./ClaudeAdapterV2.testHarness.ts"; // @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams + +import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import { beforeEach, vi } from "vite-plus/test"; + +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + import * as NodeServices from "@effect/platform-node/NodeServices"; -import { MessageId, ProviderSessionId, RunAttemptId, ThreadId } from "@t3tools/contracts"; +import { ProviderSessionId, RunAttemptId, ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; + import * as DateTime from "effect/DateTime"; + import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; + import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; + import * as Layer from "effect/Layer"; + 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 McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; + +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - awaitUntil, - CLAUDE_TEST_RUNTIME_POLICY, - claudeSdkFrame, - DEFAULT_CLAUDE_SETTINGS, - makeClaudeTestTurnInput, - makeResultFrame, - makeWakeHarness, - MANIFEST_ONLY_CLAUDE_CATALOG, - MANIFEST_ONLY_MODEL_SELECTION, - providerThreadRosterEvents, - turnOneResult, - WAKE_NATIVE_SESSION, - WAKE_TASK_ID, - wakeAssistant, - wakeNotification, - wakeResult, - wakeTaskStarted, - wakeTurnInit, -} from "./ClaudeAdapterV2.testHarness.ts"; -describe("ClaudeAdapterV2 fetched model catalog", () => { - it.effect("sizes and compiles manifest-only models from the fetched catalog", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-catalog-", - }); - let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const messages = yield* Queue.unbounded>(); - 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, - modelCatalog: Effect.succeed(MANIFEST_ONLY_CLAUDE_CATALOG), - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-catalog"), - open: (input) => - Effect.sync(() => { - openedOptions = input.options; - return { - messages: Stream.fromQueue(messages), - offer: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-catalog"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-catalog"), - modelSelection: MANIFEST_ONLY_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - // The handoff budget is sized from this window; the bundled list said 200k. - assert.equal(runtime.getModelContextWindow?.(MANIFEST_ONLY_MODEL_SELECTION), 1_000_000); +vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({ + ...(await importOriginal()), + resolveSettings: vi.fn(), +})); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: MANIFEST_ONLY_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-catalog"), - text: "hello", - attachments: [], - modelSelection: { - ...MANIFEST_ONLY_MODEL_SELECTION, - options: [{ id: "effort", value: "ultracode" }], - }, - }), - ); - assert.equal(openedOptions?.model, "claude-opus-5-5-manifest-only"); - assert.equal(openedOptions?.effort, "xhigh"); - const compacted = yield* runtime.events.pipe( - Stream.filter( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.tokenUsage?.usedTokens === 400, - ), - Stream.take(1), - Stream.runCollect, - Effect.forkScoped, - ); - yield* Queue.offer( - messages, - claudeSdkFrame({ - type: "system", - subtype: "compact_boundary", - compact_metadata: { trigger: "auto", pre_tokens: 1_000, post_tokens: 400 }, - uuid: "00000000-0000-4000-8000-000000000991", - session_id: "native-thread-claude-catalog", - }), - ); - const [usage] = yield* Fiber.join(compacted); - assert.equal( - usage?.type === "provider_turn.updated" ? usage.providerTurn.tokenUsage?.maxTokens : null, - 1_000_000, - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); +beforeEach(() => { + vi.mocked(ClaudeSdk.resolveSettings).mockReset().mockResolvedValue({ + effective: {}, + provenance: {}, + sources: [], + }); }); + describe("ClaudeAdapterV2 background wake turns", () => { - it.effect("learns where the session writes task output from what Claude reports", () => + it.effect("keeps a running Claude monitor typed after many newer monitors end", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + let frameNumber = 0; + const nextUuid = () => `00000000-0000-4000-8000-${String(++frameNumber).padStart(12, "0")}`; + const monitorFrames = (index: number) => { + const taskId = `monitor-task-${index}`; + const toolUseId = `toolu_monitor_${index}`; + const description = `Monitor ${index}`; + return { + taskId, + start: [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_monitor_${index}`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Monitor", + input: { description, command: "sleep 8 && echo MONITOR_DONE" }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description, + is_backgrounded: true, + task_type: "local_bash", + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `Monitor started (task ${taskId}).`, + }, + ], + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { taskId, persistent: false }, + }), + ], + end: claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: taskId, + tool_use_id: toolUseId, + status: "completed", + output_file: `/tmp/claude-replay/tasks/${taskId}.output`, + summary: `Monitor "${description}" stream ended`, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + }; + }; + yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-task-output-dir"), - text: "Watch the build.", + now, + attemptId: RunAttemptId.make("attempt-claude-many-monitors"), + text: "Watch the deploy, then re-arm short watches.", attachments: [], }), ); - const outputDir = () => - harness.runtime.backgroundTaskOutputDir?.({ providerThread: harness.providerThread }) ?? - Effect.die("Claude must report its task output directory."); - // Each roster change is a receipt that the frames before it were handled. - const taskStarted = (taskId: string, uuidSuffix: string) => + const longRunning = monitorFrames(0); + for (const frame of longRunning.start) { + yield* Queue.offer(harness.sdkMessages, frame); + } + for (let index = 1; index <= 65; index++) { + const monitor = monitorFrames(index); + for (const frame of monitor.start) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.offer(harness.sdkMessages, monitor.end); + } + yield* Queue.offer( + harness.sdkMessages, claudeSdkFrame({ type: "system", - subtype: "task_started", - task_id: taskId, - tool_use_id: `toolu-${taskId}`, - description: taskId, - task_type: "local_bash", - uuid: `00000000-0000-4000-8000-00000000093${uuidSuffix}`, - session_id: WAKE_NATIVE_SESSION, - }); - - // A Monitor's tool result names no file. - yield* harness.offerAndWait(taskStarted("bmonitor1", "1")); - assert.isNull(yield* outputDir()); - - const tasksDir = `/tmp/claude-1000/-home-me-project/${WAKE_NATIVE_SESSION}/tasks`; - for (const frame of [ - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "toolu-bshell1", - content: [ - { - type: "text", - text: `Command running in background with ID: bshell1. Output is being written to: ${tasksDir}/bshell1.output. You will be notified when it completes.`, - }, - ], - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000932", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: longRunning.taskId, + task_type: "local_bash", + description: "Monitor 0", + }, + ], + uuid: nextUuid(), session_id: WAKE_NATIVE_SESSION, }), - taskStarted("bshell1", "3"), - ]) - yield* harness.offerAndWait(frame); - assert.equal(yield* outputDir(), tasksDir); + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: nextUuid(), result: "Watching." }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - // A finished task's output_file reports it too, and a later report wins. - const movedDir = `/tmp/claude-1000/-home-me-other/${WAKE_NATIVE_SESSION}/tasks`; - for (const frame of [ - claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: "bdone1", - status: "completed", - output_file: `${movedDir}/bdone1.output`, - summary: "done", - uuid: "00000000-0000-4000-8000-000000000934", - session_id: WAKE_NATIVE_SESSION, - }), - taskStarted("bshell2", "5"), - ]) - yield* harness.offerAndWait(frame); - assert.equal(yield* outputDir(), movedDir); + const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks; + assert.deepEqual(roster, [ + { + taskId: longRunning.taskId, + kind: "monitor", + description: "Monitor 0", + commandKind: "watcher", + command: "sleep 8 && echo MONITOR_DONE", + startedAt: now, + }, + ]); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -233,122 +202,262 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "keeps a background Bash command, tag and start across snapshots and stops it via the parent's query", + "clears the replaced sibling native thread roster when openQuery switches processes", () => Effect.scoped( Effect.gen(function* () { - const AGENT_TOOL_USE_ID = "toolu-shell-owner-agent"; - const BASH_TOOL_USE_ID = "toolu-shell-owner-bash"; - const command = "for i in $(seq 1 40); do echo tick $i; sleep 3; done"; - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-sibling-replace-", + }); + const nativeIds = ["native-thread-roster-a", "native-thread-roster-b"] as const; + let allocateIndex = 0; + // Real two-process model: each openQuery owns its own message queue. + // A shared queue would mask sibling process death on replacement. + const processQueues: Array<{ + readonly nativeThreadId: string; + readonly queue: Queue.Queue; + }> = []; + 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.sync(() => { + const next = + nativeIds[allocateIndex] ?? `native-thread-roster-extra-${allocateIndex}`; + allocateIndex += 1; + return next; + }), + open: (openInput) => + Effect.gen(function* () { + const nativeThreadId = openInput.options.sessionId ?? openInput.options.resume; + if (typeof nativeThreadId !== "string" || nativeThreadId.length === 0) { + return yield* Effect.die("openQuery must supply a native session id"); + } + const queue = yield* Queue.unbounded(); + processQueues.push({ nativeThreadId, queue }); + return { + messages: Stream.fromQueue(queue), + 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(queue), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const appThreadA = ThreadId.make("thread-claude-roster-a"); + const appThreadB = ThreadId.make("thread-claude-roster-b"); + const runtime = yield* adapter.openSession({ + threadId: appThreadA, + providerSessionId: ProviderSessionId.make("provider-session-claude-sibling-replace"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThreadA = yield* runtime.ensureThread({ + threadId: appThreadA, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThreadB = yield* runtime.ensureThread({ + threadId: appThreadB, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + assert.notEqual( + providerThreadA.nativeThreadRef?.nativeId, + providerThreadB.nativeThreadRef?.nativeId, + ); + 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.", + ); + } + if (runtime.hasPendingBackgroundWorkForThread === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWorkForThread.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const hasPendingBackgroundWorkForThread = runtime.hasPendingBackgroundWorkForThread; + const now = yield* DateTime.now; + const taskA = "task-roster-a"; + const taskB = "task-roster-b"; + + yield* runtime.startTurn( makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-subagent-shell-owner"), - text: "Have a subagent print 40 ticks in the background.", + threadId: appThreadA, + providerThread: providerThreadA, + now, + attemptId: RunAttemptId.make("attempt-roster-iso-a"), + text: "Background work on A.", attachments: [], }), ); - const shellTask = { - task_id: WAKE_TASK_ID, - description: "Print 40 ticks, 3 seconds apart", - }; - for (const frame of [ + assert.equal(processQueues.length, 1); + const processA = processQueues[0]!; + yield* Queue.offer( + processA.queue, claudeSdkFrame({ type: "system", subtype: "task_started", - task_id: "task-shell-owner-agent", - tool_use_id: AGENT_TOOL_USE_ID, - description: "Print 40 ticks", - task_type: "local_agent", - prompt: "Print 40 ticks.", - uuid: "00000000-0000-4000-8000-000000000921", - session_id: WAKE_NATIVE_SESSION, + task_id: taskA, + tool_use_id: "toolu-roster-a", + description: "work on A", + is_backgrounded: true, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000301", + session_id: nativeIds[0], }), - claudeSdkFrame({ - type: "assistant", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: BASH_TOOL_USE_ID, - name: "Bash", - input: { command, run_in_background: true }, - }, - ], - }, - parent_tool_use_id: AGENT_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000922", - session_id: WAKE_NATIVE_SESSION, + ); + yield* Queue.offer( + processA.queue, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000302", + 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", + ); + const rosterAAfterSettle = providerThreadRosterEvents(events).findLast( + (event) => event.providerThread.id === providerThreadA.id, + )?.providerThread.pendingBackgroundTasks; + assert.deepEqual(rosterAAfterSettle ?? [], [ + { taskId: taskA, description: "work on A", kind: "command", startedAt: now }, + ]); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: appThreadB, + providerThread: { ...providerThreadB, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-roster-iso-b"), + text: "Background work on B.", + attachments: [], }), + ); + 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", + ); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + + const processB = processQueues[1]!; + yield* Queue.offer( + processB.queue, claudeSdkFrame({ type: "system", subtype: "task_started", - ...shellTask, - tool_use_id: BASH_TOOL_USE_ID, + task_id: taskB, + tool_use_id: "toolu-roster-b", + description: "work on B", + is_backgrounded: true, task_type: "local_bash", - uuid: "00000000-0000-4000-8000-000000000923", - session_id: WAKE_NATIVE_SESSION, - }), - ]) - yield* harness.offerAndWait(frame); - const rosters = () => - providerThreadRosterEvents(harness.events).flatMap((event) => - (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0 - ? [event.providerThread.pendingBackgroundTasks] - : [], - ); - const firstRoster = yield* Queue.take(harness.rosterReceipts); - const startedAt = firstRoster.providerThread.pendingBackgroundTasks?.[0]?.startedAt; - assert.isDefined(startedAt); - assert.equal(firstRoster.providerThread.pendingBackgroundTasks?.[0]?.command, command); - // A later snapshot must not restart the shell's clock. - yield* TestClock.adjust("1 hour"); - // Snapshots carry ids only; the tag must survive the replacement. - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [{ ...shellTask, task_type: "local_bash" }], - uuid: "00000000-0000-4000-8000-000000000924", - session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000303", + 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", + ); + assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); - yield* Queue.take(harness.rosterReceipts); - const childThreadId = - harness.events.find((event) => event.type === "subagent.updated")?.subagent - .childThreadId ?? undefined; - assert.isString(childThreadId); - // The parent's session owns the shell, so it stays on the parent's roster. - assert.deepEqual(rosters().at(-1), [ - { - taskId: WAKE_TASK_ID, - description: "Print 40 ticks, 3 seconds apart", - kind: "command", - commandKind: "shell", - command, - ownerThreadId: childThreadId, - startedAt, - }, - ]); - - assert.isTrue(yield* harness.hasPendingBackgroundWork); - // The shell runs in the parent's session, so the parent's query stops it. - const stop = harness.runtime.stopBackgroundTask; - if (stop === undefined) return yield* Effect.die("Claude must stop background tasks."); - yield* stop({ providerThread: harness.providerThread, taskId: WAKE_TASK_ID }); - const unknown = yield* Effect.exit( - stop({ - providerThread: harness.providerThread, - taskId: "task-not-on-roster", + yield* Queue.offer( + processB.queue, + claudeSdkFrame({ + type: "result", + subtype: "error_during_execution", + duration_ms: 10, + duration_api_ms: 10, + is_error: true, + num_turns: 1, + result: "B failed", + 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: ["B failed"], + terminal_reason: "model_error", + uuid: "00000000-0000-4000-8000-000000000304", + session_id: nativeIds[1], }), ); - assert.isTrue(Exit.isFailure(unknown)); - assert.deepEqual(harness.stoppedTaskIds, [WAKE_TASK_ID]); + yield* awaitUntil( + () => + events.some( + (event) => + event.type === "turn.terminal" && + event.providerThreadId === providerThreadB.id && + event.status === "failed", + ), + "thread B failed terminal", + ); + + const rosterBAfterFail = providerThreadRosterEvents(events).findLast( + (event) => event.providerThread.id === providerThreadB.id, + )?.providerThread.pendingBackgroundTasks; + assert.deepEqual(rosterBAfterFail ?? [], []); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + assert.isFalse(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -356,516 +465,4 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ); - - it.effect("tags a Monitor watcher so it never pins the idle session as work", () => - Effect.scoped( - Effect.gen(function* () { - const monitorToolUseId = "toolu-monitor"; - const monitorTaskId = "task-monitor"; - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-monitor"), - text: "Watch the replay log.", - attachments: [], - }), - ); - const monitorEntry = { - task_id: monitorTaskId, - task_type: "local_bash", - description: "replay runner steps", - }; - yield* Queue.offerAll(harness.sdkMessages, [ - claudeSdkFrame({ - type: "assistant", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: monitorToolUseId, - name: "Monitor", - input: { - description: "replay runner steps", - command: "tail -n +1 -f /tmp/replay.log", - timeout_ms: 1_800_000, - }, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000951", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [monitorEntry], - uuid: "00000000-0000-4000-8000-000000000952", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - ...monitorEntry, - tool_use_id: monitorToolUseId, - uuid: "00000000-0000-4000-8000-000000000953", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [monitorEntry], - uuid: "00000000-0000-4000-8000-000000000954", - session_id: WAKE_NATIVE_SESSION, - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000955", - result: "Watching the replay.", - }), - ]); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks; - assert.deepEqual( - roster?.map((task) => [task.taskId, task.kind]), - [[monitorTaskId, "monitor"]], - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("tags a Monitor started before the wake run attaches", () => - Effect.scoped( - Effect.gen(function* () { - const monitorToolUseId = "toolu-idle-monitor"; - let nextUuid = 0; - const frame = (fields: Record) => - claudeSdkFrame({ - session_id: WAKE_NATIVE_SESSION, - uuid: `uuid-${nextUuid++}`, - ...fields, - }); - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-idle-monitor"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offerAll(harness.sdkMessages, [wakeTaskStarted, turnOneResult]); - yield* Queue.take(harness.terminalReceipts); - const monitorEntry = { task_id: "task-idle-monitor", task_type: "local_bash" }; - yield* Queue.offerAll(harness.sdkMessages, [ - wakeNotification, - frame({ type: "system", subtype: "init" }), - frame({ - type: "assistant", - parent_tool_use_id: null, - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: monitorToolUseId, - name: "Monitor", - input: { command: "tail -f /tmp/dev.log", description: "dev server" }, - }, - ], - }, - }), - frame({ type: "system", subtype: "background_tasks_changed", tasks: [monitorEntry] }), - frame({ - type: "system", - subtype: "task_started", - ...monitorEntry, - tool_use_id: monitorToolUseId, - }), - ]); - yield* awaitUntil( - () => - providerThreadRosterEvents(harness.events) - .at(-1) - ?.providerThread.pendingBackgroundTasks?.some( - (task) => task.taskId === "task-idle-monitor" && task.kind === "monitor", - ) ?? false, - "idle Monitor tagged as a monitor", - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("settles a wake run whose turn Claude folded into a queued user run", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const turnInput = (attempt: string, text: string, ordinal: number, wake = false) => - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(attempt), - text, - attachments: [], - providerTurnOrdinal: ordinal, - ...(wake ? { messageCreatedBy: "agent", messageCreationSource: "provider" } : {}), - }); - - yield* harness.runtime.startTurn( - turnInput("attempt-claude-fold-1", "Run the build in the background.", 1), - ); - yield* Queue.offerAll(harness.sdkMessages, [wakeTaskStarted, turnOneResult]); - yield* Queue.take(harness.terminalReceipts); - - // Claude starts its notification turn before the user's queued - // follow-up attaches: the init is buffered and requests the wake run, - // which queues behind the follow-up. - yield* Queue.offerAll(harness.sdkMessages, [wakeNotification, wakeTurnInit]); - yield* Queue.take(harness.continuationReceipts); - yield* harness.runtime.startTurn( - turnInput("attempt-claude-fold-2", "How is the build going?", 2), - ); - assert.lengthOf(harness.offeredMessages, 2); - - // Claude folds the follow-up into the running turn: one result ends - // both, and it completes the user's run. - yield* Queue.offerAll(harness.sdkMessages, [wakeAssistant, wakeResult]); - yield* Queue.take(harness.terminalReceipts); - - // The wake run finds only the stale init; no result will follow. - yield* harness.runtime.startTurn( - turnInput("attempt-claude-fold-3", "Background task completed.", 3, true), - ); - yield* Queue.take(harness.terminalReceipts); - assert.equal(harness.terminalEvents()[2]?.status, "completed"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("opens a fresh wake run for the turn after a zero-turn notification result", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const turnInput = (attempt: string, text: string, ordinal: number, wake = false) => - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(attempt), - text, - attachments: [], - providerTurnOrdinal: ordinal, - ...(wake ? { messageCreatedBy: "agent", messageCreationSource: "provider" } : {}), - }); - - yield* harness.runtime.startTurn( - turnInput("attempt-claude-empty-wake-1", "Run the build in the background.", 1), - ); - yield* Queue.offerAll(harness.sdkMessages, [wakeTaskStarted, turnOneResult]); - yield* Queue.take(harness.terminalReceipts); - - yield* Queue.offerAll(harness.sdkMessages, [wakeNotification, wakeTurnInit]); - yield* Queue.take(harness.continuationReceipts); - yield* harness.runtime.startTurn( - turnInput("attempt-claude-empty-wake-2", "Background task completed.", 2, true), - ); - assert.lengthOf(harness.terminalEvents(), 1); - - // The first turn ends without a model turn; Claude then opens the real one. - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000110", - result: "", - numTurns: 0, - origin: { kind: "task-notification" }, - }), - ); - yield* Queue.take(harness.terminalReceipts); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "init", - uuid: "00000000-0000-4000-8000-000000000111", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.take(harness.continuationReceipts); - yield* harness.runtime.startTurn( - turnInput("attempt-claude-empty-wake-3", "Background task completed.", 3, true), - ); - assert.lengthOf(harness.terminalEvents(), 2); - - yield* Queue.offerAll(harness.sdkMessages, [wakeAssistant, wakeResult]); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "completed"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("queues a steer behind an open question and cancels a question the CLI aborts", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attemptId = RunAttemptId.make("attempt-claude-question-steer"); - const input = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId, - text: "Pick a target.", - attachments: [], - }); - yield* harness.runtime.startTurn(input); - const canUseTool = harness.getOpenedOptions()?.canUseTool; - assert.isFunction(canUseTool); - const abort = new AbortController(); - const questionResult = yield* Effect.promise(() => - canUseTool!( - "AskUserQuestion", - { - questions: [ - { - header: "Target", - question: "Where?", - options: [ - { label: "Prod", description: "Production." }, - { label: "Staging", description: "Staging." }, - ], - multiSelect: false, - }, - ], - }, - { signal: abort.signal, toolUseID: "tool-steer-question", requestId: "request-steer" }, - ), - ).pipe(Effect.forkScoped); - yield* Queue.take(harness.requestReceipts); - - yield* 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: "agent", - creationSource: "server", - messageId: MessageId.make("message-question-steer"), - text: "Delegated task finished.", - attachments: [], - }, - }); - assert.equal(harness.offeredMessages[1]?.priority, "next"); - - abort.abort(); - assert.equal((yield* Fiber.join(questionResult))?.behavior, "deny"); - const cancelledRequest = yield* Queue.take(harness.requestReceipts); - assert.equal(cancelledRequest.runtimeRequest.status, "cancelled"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("holds a wake turn's question until its continuation run attaches", () => - 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-wake-question-1"), - text: "Check the build.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* Queue.take(harness.terminalReceipts); - - // The CLI starts a wake turn on its own and asks before T3 has attached - // the continuation run that should own the question. - const questionInput = { - questions: [ - { - header: "Next", - question: "Ship it?", - options: [ - { label: "Yes", description: "Ship now." }, - { label: "No", description: "Wait." }, - ], - multiSelect: false, - }, - ], - }; - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "assistant", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-wake-question", - name: "AskUserQuestion", - input: questionInput, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000911", - session_id: WAKE_NATIVE_SESSION, - }), - ); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); - const canUseTool = harness.getOpenedOptions()?.canUseTool; - assert.isFunction(canUseTool); - const questionResult = yield* Effect.promise(() => - canUseTool!("AskUserQuestion", questionInput, { - signal: new AbortController().signal, - toolUseID: "tool-wake-question", - requestId: "request-wake-question", - }), - ).pipe(Effect.forkScoped); - yield* Queue.take(harness.continuationReceipts); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-question-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - const request = yield* Queue.take(harness.requestReceipts); - assert.lengthOf( - harness.terminalEvents(), - 1, - "continuation stays open while the question waits", - ); - - yield* harness.runtime.respondToRuntimeRequest({ - requestId: request.runtimeRequest.id, - answers: { "Ship it?": ["Yes"] }, - }); - assert.deepEqual(yield* Fiber.join(questionResult), { - behavior: "allow", - updatedInput: { questions: questionInput.questions, answers: { "Ship it?": "Yes" } }, - toolUseID: "tool-wake-question", - }); - - yield* Queue.offer(harness.sdkMessages, wakeResult); - yield* Queue.take(harness.terminalReceipts); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); }); - -it.effect.each(["task_started", "task_progress"] as const)( - "gives a worker subagent's thread the Implementor role named by %s", - (namedBy) => - Effect.scoped( - 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-subagent-role"), - text: "Spawn a worker.", - attachments: [], - }), - ); - for (const frame of [ - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-subagent-role", - tool_use_id: "toolu-subagent-role", - description: "Build the parser", - ...(namedBy === "task_started" ? { subagent_type: "worker" } : {}), - task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000391", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_progress", - task_id: "task-subagent-role", - tool_use_id: "toolu-subagent-role", - description: "Building the parser", - ...(namedBy === "task_progress" ? { subagent_type: "worker" } : {}), - usage: { total_tokens: 1, tool_uses: 1, duration_ms: 1 }, - uuid: "00000000-0000-4000-8000-000000000393", - session_id: WAKE_NATIVE_SESSION, - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000392", - result: "Spawned the worker.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.take(harness.terminalReceipts); - - const children = harness.events.flatMap((event) => - event.type === "app_thread.created" ? [event.appThread] : [], - ); - assert.equal(children.at(-1)?.role, "implementor"); - assert.equal(new Set(children.map((thread) => thread.id)).size, 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), -); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6378bf8c1fa8c..f26808cb620e0 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,24 +249,26 @@ 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"], + it("gives delegated children Claude's subagent prompt cache TTL", () => { + const lineage = (relationshipToParent: "fork" | "subagent" | null) => ({ + lineage: { + parentThreadId: null, + relationshipToParent, + rootThreadId: ThreadId.make("thread-root"), + }, }); - - assert.equal((options.settings as { autoCompactWindow?: number }).autoCompactWindow, 300_000); - assert.equal(options.onUserDialog, onUserDialog); - assert.deepEqual(options.supportedDialogKinds, ["resume_return"]); + const env = { PATH: "/bin" }; + const ttl = (environment: NodeJS.ProcessEnv, ...args: Parameters) => + ClaudeAdapterV2.claudeQueryEnvironment(environment, lineage(...args), "5m") + .CLAUDE_CODE_PROMPT_CACHE_TTL; + assert.equal(ttl(env, "subagent"), "5m"); + assert.isUndefined(ttl(env, null)); + assert.isUndefined(ttl(env, "fork")); + assert.equal(ttl({ ...env, CLAUDE_CODE_PROMPT_CACHE_TTL: "1h" }, "subagent"), "1h"); + assert.strictEqual( + ClaudeAdapterV2.claudeQueryEnvironment(env, lineage("subagent"), undefined), + env, + ); }); it("projects AskUserQuestion input with question text as the answer key", () => { @@ -359,52 +314,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 +337,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 +403,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 +420,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 +447,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 +466,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 +569,68 @@ 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, - ); +describe("ClaudeAdapterV2 approval presentation", () => { + it("marks asks that full access could not auto-allow and keeps the CLI's reason", () => { + const presentation = ClaudeAdapterV2.claudeApprovalPresentation({ + permissionMode: "bypassPermissions", + prompt: 'Claude wants to run rm -r -- "$rr/$h"', + callbackOptions: { + decisionReason: "\u001b[33mDangerous rm on a path built from a variable\u001b[39m", + suppressAlwaysAllowRule: true, + }, + }); + assert.deepEqual(presentation, { + reason: "Dangerous rm on a path built from a variable", + fullAccessOverride: "provider_check", + options: [ + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ], + }); }); - 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 }, + it("attributes a bypass ask to the user's ask rule when one matched", () => { + const presentation = ClaudeAdapterV2.claudeApprovalPresentation({ + permissionMode: "bypassPermissions", + prompt: "Claude wants to run git push", + callbackOptions: { + matchedAskRule: { source: "userSettings", toolName: "Bash", ruleContent: "git push:*" }, }, - 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"); + }); + assert.deepEqual(presentation, { fullAccessOverride: "ask_rule" }); + }); + + it("adds nothing to ordinary approvals outside full access", () => { + const presentation = ClaudeAdapterV2.claudeApprovalPresentation({ + permissionMode: "default", + prompt: "Run make?", + callbackOptions: { decisionReason: "Run make?" }, + }); + assert.deepEqual(presentation, {}); }); }); 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 +912,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 +979,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 +1051,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 +1557,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 +1839,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 +1916,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 +2112,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 +2171,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 +2213,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 +2252,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 +2273,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 +2329,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 +2372,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 +2702,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 +2751,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 +3060,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 +3386,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 +3635,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; @@ -4797,64 +4128,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([ - { recovered: false, expected: "Claude usage limit reached" }, - { recovered: true, expected: "Claude gave up after repeated API errors" }, - ])("tracks whether a rejected usage window recovered ($recovered)", ({ recovered, expected }) => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(`attempt-claude-window-${recovered}`), - text: "Continue.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "rate_limit_event", - rate_limit_info: { status: "rejected", rateLimitType: "five_hour" }, - uuid: "00000000-0000-4000-8000-000000000608", - session_id: WAKE_NATIVE_SESSION, - }), - ); - if (recovered) { - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "rate_limit_event", - rate_limit_info: { status: "allowed", rateLimitType: "five_hour" }, - uuid: "00000000-0000-4000-8000-000000000609", - session_id: WAKE_NATIVE_SESSION, - }), - ); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000610", - result: recovered ? "API Error" : "API Error: 400 Specific provider detail.", - terminalReason: "api_error", - }), - ); - - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.include(terminal.failure.message, expected); - assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect.each([ { name: "fails a /compact the usage limit stopped", @@ -5131,54 +4404,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 +4569,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 +4711,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,109 +5161,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("titles Claude reads, searches, and skills on tool completion", () => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-read-images"), - text: "Read the files", - attachments: [], - }), - ); - const tools = [ - { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } }, - { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } }, - { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } }, - { id: "skill", name: "Skill", input: { skill: "full-send" } }, - { - id: "write", - name: "Write", - input: { file_path: "/workspace/output.png", content: "text" }, - }, - ]; - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "assistant", - uuid: "00000000-0000-4000-8000-000000000601", - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - id: "msg_image_reads", - model: "claude-sonnet-4-6", - type: "message", - role: "assistant", - content: tools.map((tool) => ({ type: "tool_use", ...tool })), - stop_reason: "tool_use", - stop_sequence: null, - usage: { - input_tokens: 1, - output_tokens: 1, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "user", - uuid: "00000000-0000-4000-8000-000000000602", - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - role: "user", - content: tools.map((tool) => ({ - type: "tool_result", - tool_use_id: tool.id, - content: "ok", - })), - }, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000603", result: "Read files" }), - ); - yield* Queue.take(harness.terminalReceipts); - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.status === "completed" - ? [event.turnItem] - : [], - ); - const image = items.find((item) => item.nativeItemRef?.nativeId === "image"); - assert.equal(image?.type, "dynamic_tool"); - if (image?.type === "dynamic_tool") - assert.equal(image.viewedImagePath, "/workspace/reference.png"); - assert.equal(image?.title, "Read /workspace/reference.png"); - assert.equal( - items.find((item) => item.nativeItemRef?.nativeId === "text")?.title, - "Read /workspace/README.md", - ); - assert.equal( - items.find((item) => item.nativeItemRef?.nativeId === "search")?.title, - "Searched TODO in src", - ); - assert.equal( - items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title, - "Skill: full-send", - ); - for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) - assert.notProperty(item, "viewedImagePath"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), - ), - ), - ); - it.effect.each(["claude-haiku-4-5-20251001", "claude-sonnet-4-6"])( "renegotiates auto only when switching API models after fallback to default: %s", (nextModel) => @@ -6842,33 +5853,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 +5972,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 +6026,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 +6043,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,7 +6057,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Opened GitHub.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn"); + yield* harness.awaitTerminal(1); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -7081,125 +6066,56 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("carries exhausted retry progress into the terminal provider error", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-api-retry-exhausted"), - text: "Open github.com.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "api_retry", - attempt: 10, - max_retries: 10, - retry_delay_ms: 38_010, - error_status: 529, - error: "overloaded", - uuid: "00000000-0000-4000-8000-000000000203", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000204", - result: "Claude is temporarily overloaded.", - isError: true, - apiErrorStatus: 529, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed Claude turn"); - - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.deepEqual(terminal.retry, { - attempt: 10, - maxAttempts: 10, - retryDelayMs: 38_010, - }); - assert.isDefined(terminal.retryStartedAt); - assert.equal(terminal.failure.code, "api_error_529"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([ - "api_error", - "malformed_tool_use_exhausted", - "budget_exhausted", - "structured_output_retry_exhausted", - "tool_deferred_unavailable", - "turn_setup_failed", - "blocking_limit", - "rapid_refill_breaker", - "prompt_too_long", - "image_error", - "model_error", - "overloaded_status", - ] as const)("fails a success-shaped Claude result with %s", (terminalReason) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), - text: "Complete the task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000205", - result: "Provider failure details.", - isError: false, - ...(terminalReason === "overloaded_status" - ? { apiErrorStatus: 529 } - : { terminalReason }), - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.isNotEmpty(terminal.failure.message); - assert.equal(terminal.failure.class, "provider_error"); - assert.isFalse( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.text === "Provider failure details.", + it.effect.each(["api_error", "overloaded_status"] as const)( + "fails a success-shaped Claude result with %s", + (terminalReason) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), + text: "Complete the task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000205", + result: "Provider failure details.", + isError: false, + ...(terminalReason === "overloaded_status" + ? { apiErrorStatus: 529 } + : { terminalReason }), + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.isNotEmpty(terminal.failure.message); + assert.equal(terminal.failure.class, "provider_error"); + assert.isFalse( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.text === "Provider failure details.", + ), + ); + assert.equal( + terminal.failure.code, + terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ); - assert.equal( - terminal.failure.code, - terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), - ), ); // Frames follow #19832: a full context window ends the turn with @@ -7305,7 +6221,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 +6250,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 +6395,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; @@ -7529,7 +6441,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); @@ -7541,57 +6453,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); @@ -7606,6 +6471,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({ @@ -7629,7 +6495,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), @@ -7673,7 +6539,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -7696,7 +6562,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); @@ -7735,18 +6601,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( @@ -7761,7 +6625,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({ @@ -7777,69 +6641,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), @@ -7853,6 +6655,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({ @@ -7937,7 +6740,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -7990,15 +6793,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, @@ -8021,15 +6822,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)); @@ -8048,14 +6847,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); @@ -8087,15 +6884,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( @@ -8194,7 +6989,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({ @@ -8209,14 +7004,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(() => @@ -8237,10 +7026,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, @@ -8262,7 +7048,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({ @@ -8278,7 +7064,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) => @@ -8394,7 +7180,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) => @@ -8459,7 +7245,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({ @@ -8473,8 +7259,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { runtimePolicy: approvalPolicy, }), ); - yield* Queue.offer( - harness.sdkMessages, + yield* harness.offerAndWait( claudeSdkFrame({ type: "assistant", message: { @@ -8496,12 +7281,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", @@ -8514,9 +7293,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", @@ -8544,18 +7322,26 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("drops a zero-turn task-notification result while awaiting a prompt echo", () => + it.effect("releases output held for a prompt echo when the stream ends", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => Queue.shutdown(sdkMessages), + }); const now = yield* DateTime.now; - const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1"); - const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2"); + const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); + const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); const stamp = (frame: SDKMessage, promptIndex: number) => claudeSdkFrame({ ...frame, user_message_uuid: harness.promptUuid(promptIndex), }); + const assistantTexts = () => + harness.events.flatMap((event) => + event.type === "message.updated" && event.message.role === "assistant" + ? [event.message.text] + : [], + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -8570,18 +7356,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer( harness.sdkMessages, stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }), + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), 0, ), ); yield* Queue.offer( harness.sdkMessages, stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), 0, ), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -8594,43 +7380,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 2, }), ); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000764", text: "Stale." }), - ); - yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }), - 1, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }), - 1, - ), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 0); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" - ? [event.message.text] - : [], - ), - ["One.", "Stale.", "Two."], - ); - assert.isFalse( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.text === STALE_TASK_NOTIFICATION_RESULT_TEXT, - ), + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000703", text: "Two." }), ); + assert.deepEqual(assistantTexts(), ["One."]); + + yield* Queue.shutdown(harness.sdkMessages); + yield* harness.awaitTerminal(2); + assert.deepEqual(assistantTexts(), ["One.", "Two."]); + assert.equal(harness.terminalEvents()[1]?.status, "failed"); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -8639,117 +7397,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("releases output held for a prompt echo when the stream ends", () => + it.effect("fails a prompt Claude completes without starting a turn for it", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => Queue.shutdown(sdkMessages), - }); - const now = yield* DateTime.now; - const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); - const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); - const stamp = (frame: SDKMessage, promptIndex: number) => - claudeSdkFrame({ - ...frame, - user_message_uuid: harness.promptUuid(promptIndex), - }); - const assistantTexts = () => - harness.events.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" - ? [event.message.text] - : [], - ); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: firstAttempt, - text: "First.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), - 0, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), - 0, - ), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: secondAttempt, - text: "Second.", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000703", text: "Two." }), - ); - let heldYields = 0; - yield* awaitUntil(() => heldYields++ >= 50, "unechoed frame to be held"); - assert.deepEqual(assistantTexts(), ["One."]); - - yield* Queue.shutdown(harness.sdkMessages); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); - assert.deepEqual(assistantTexts(), ["One.", "Two."]); - assert.equal(harness.terminalEvents()[1]?.status, "failed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("gives the same run attempt a fresh prompt uuid on every offer", () => - Effect.scoped( - Effect.gen(function* () { - const original = yield* makeWakeHarness; - const copy = yield* makeWakeHarness; - const now = yield* DateTime.now; - for (const harness of [original, copy]) { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-shared-copy"), - text: "Continue where you left off.", - attachments: [], - }), - ); - } - assert.notEqual(original.promptUuid(0), copy.promptUuid(0)); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("fails a prompt Claude completes without starting a turn for it", () => - Effect.scoped( - Effect.gen(function* () { - // Claude answers a prompt whose uuid its transcript already holds with - // a lone completed lifecycle frame and never runs a turn for it. - const harness = yield* makeWakeHarness; + // Claude answers a prompt whose uuid its transcript already holds with + // a lone completed lifecycle frame and never runs a turn for it. + const harness = yield* makeWakeHarness; const now = yield* DateTime.now; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -8771,7 +7424,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; @@ -8785,6 +7438,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* () { @@ -8828,7 +7482,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( @@ -8856,27 +7510,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); @@ -8906,7 +7556,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); @@ -8931,7 +7581,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( @@ -8947,61 +7597,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) => @@ -9132,10 +7727,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({ @@ -9149,7 +7744,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); @@ -9160,7 +7755,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( @@ -9181,7 +7776,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( @@ -9205,114 +7800,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* () { @@ -9336,7 +7823,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 @@ -9364,10 +7851,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)); @@ -9386,7 +7870,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)); @@ -9398,60 +7882,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", () => @@ -9498,12 +7928,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"); @@ -9516,109 +7941,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("drops zero-turn task-notification debris racing interrupt", () => - Effect.scoped( - Effect.gen(function* () { - const closeGate = yield* Deferred.make(); - const testScope = yield* Scope.Scope; - yield* Scope.addFinalizer(testScope, Deferred.succeed(closeGate, undefined)); - const interruptStarted = yield* Deferred.make(); - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => - Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), - stopTask: () => Effect.void, - interrupt: Deferred.succeed(interruptStarted, undefined), - }); - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const now = yield* DateTime.now; - const attemptId = RunAttemptId.make("attempt-claude-interrupt-zero-notif"); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }); - const staleText = "Zero-turn debris must not leak."; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId, - text: "Stop this task.", - attachments: [], - }), - ); - yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* Deferred.await(interruptStarted); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000116", - result: staleText, - numTurns: 0, - origin: { kind: "task-notification" }, - }), - ); - let debrisYields = 0; - yield* awaitUntil(() => debrisYields++ >= 50, "zero-turn debris consumed"); - yield* Deferred.succeed(closeGate, undefined); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.isFalse( - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === staleText, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("fails a positive task-notification error result", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-positive-notif-error"), - text: "Run the task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000117", - result: "The task failed.", - numTurns: 7, - origin: { kind: "task-notification" }, - subtype: "error_during_execution", - isError: true, - errors: ["The task failed."], - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("settles a continuation turn immediately when no wake output is buffered", () => + it.effect("settles a continuation turn immediately when no wake output is buffered", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -9637,7 +7960,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( @@ -9800,7 +8123,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 @@ -9855,451 +8178,38 @@ describe("ClaudeAdapterV2 background wake turns", () => { 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), + 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", () => @@ -10345,7 +8255,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({ @@ -10370,18 +8280,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."]); @@ -10410,7 +8318,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({ @@ -10431,7 +8339,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), [ @@ -10463,210 +8371,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* () { @@ -10699,7 +8403,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({ @@ -10753,12 +8457,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 [ @@ -10792,15 +8494,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, @@ -10822,7 +8522,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( @@ -10924,13 +8624,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({ @@ -10952,13 +8652,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( @@ -11045,87 +8743,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.shutdown(harness.sdkMessages); } 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), + 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.each(["before", "after"] as const)( @@ -11171,7 +8804,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, @@ -11180,7 +8813,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, @@ -11249,14 +8882,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, @@ -11278,7 +8907,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(), [ @@ -11348,7 +8977,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, @@ -11366,7 +8995,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({ @@ -11400,15 +9029,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, @@ -11430,7 +9055,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}`]); @@ -11446,82 +9071,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"], @@ -11572,7 +9121,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", @@ -11626,10 +9175,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({ @@ -11650,7 +9196,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); @@ -11692,71 +9238,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* () { @@ -11826,7 +9307,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); @@ -12010,98 +9491,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* () { @@ -12138,65 +9527,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"], @@ -12395,148 +9725,38 @@ describe("ClaudeAdapterV2 background wake turns", () => { tool_use_id: toolUseId, description: "Haiku puzzle", task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000207", - session_id: WAKE_NATIVE_SESSION, - }), - ); - if (source === "observed-after" || source === "synthetic-after") { - yield* Queue.offer(harness.sdkMessages, observed); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000208", - result: "Spawned the subagent.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - const subagents = harness.events.filter((event) => event.type === "subagent.updated"); - const observedSlug = "claude-haiku-4-5"; - const initialModel = - source === "observed-before" - ? observedSlug - : source === "inherit" - ? parentModel - : source === "unknown" - ? null - : "claude-haiku-5-5"; - assert.equal(subagents[0]?.subagent.model, initialModel); - assert.equal( - subagents.at(-1)?.subagent.model, - source.startsWith("observed") ? observedSlug : initialModel, - ); - const child = harness.events.find((event) => event.type === "app_thread.created"); - assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([ - { agentModel: "opus", slug: "claude-opus-5-5", requested: undefined, expected: "max" }, - { agentModel: "opus", slug: "claude-opus-5-5", requested: "low", expected: "low" }, - { agentModel: "haiku-4.5", slug: "claude-haiku-4-5", requested: undefined, expected: null }, - ])( - "records the effort a $agentModel subagent runs at (Agent effort $requested)", - ({ agentModel, slug, requested, expected }) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const toolUseId = "toolu-subagent-effort"; - const parentSelection = { - instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, - model: "claude-opus-4-6", - options: [ - { id: "effort", value: "max" }, - { id: "fastMode", value: false }, - ], - } satisfies ModelSelection; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-subagent-effort"), - text: "Spawn a subagent.", - attachments: [], - modelSelection: parentSelection, - }), - ); - for (const frame of [ - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: null, - message: { - model: parentSelection.model, - id: "msg_subagent_effort_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: toolUseId, - name: "Agent", - input: { - description: "Research", - subagent_type: "general-purpose", - model: agentModel, - ...(requested === undefined ? {} : { effort: requested }), - prompt: "Research the topic.", - }, - }, - ], - }, - uuid: "00000000-0000-4000-8000-000000000211", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-subagent-effort", - tool_use_id: toolUseId, - description: "Research", - task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000212", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: toolUseId, - message: { - model: slug, - id: "msg_subagent_effort_observed", - type: "message", - role: "assistant", - content: [{ type: "text", text: "Researching." }], - }, - uuid: "00000000-0000-4000-8000-000000000213", + 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), @@ -12808,7 +10028,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, @@ -12832,10 +10052,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."); @@ -12846,7 +10063,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), @@ -12906,7 +10123,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({ @@ -12914,11 +10131,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, @@ -12940,7 +10157,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); @@ -13029,7 +10246,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({ @@ -13037,13 +10254,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({ @@ -13064,7 +10278,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); @@ -13147,10 +10361,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); @@ -13174,10 +10385,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"); @@ -13224,6 +10432,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({ @@ -13246,7 +10455,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), @@ -13287,7 +10496,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -13321,10 +10530,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; @@ -13344,298 +10550,62 @@ describe("ClaudeAdapterV2 background wake turns", () => { modelSelection: alternateModel, }), ); - assert.equal(processQueues.length, 2); - - yield* awaitUntil( - () => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && - event.providerThread.updatedAt !== undefined, - ), - "roster cleared on process replace while remaining active", - ); - assert.isFalse(yield* hasPendingBackgroundWork); - const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ); - assert.isAtLeast(emptyActiveRosterEvents.length, 1); - assert.deepEqual( - emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], - [], - ); - assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); - - const secondProcess = processQueues[1]!; - yield* Queue.offer(secondProcess, wakeNotification); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "stale notification settle"); - assert.lengthOf(continuationRequests, 0); - - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000602", - result: "Process restart turn finished.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "second turn terminal", - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect( - "preserves buffered local_bash notification classification across model/policy query replacement", - () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-buffer-replace-", - }); - const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = - []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-buffer-replace"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), - text: "Run the build in the background.", - attachments: [], - }), - ); - assert.equal(processQueues.length, 1); - const firstProcess = processQueues[0]!; - yield* Queue.offer(firstProcess, wakeTaskStarted); - yield* Queue.offer(firstProcess, turnOneResult); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", - ); - assert.isTrue(yield* hasPendingBackgroundWork); - - yield* Queue.offer(firstProcess, wakeNotification); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); - assert.lengthOf(continuationRequests, 0); - - const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), - text: "Switch model while background work completes.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ); - assert.equal(processQueues.length, 2); - const secondProcess = processQueues[1]!; - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000701", - result: "User turn finished after model switch.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "user turn terminal after replace", - ); - assert.isFalse(yield* hasPendingBackgroundWork); - assert.lengthOf(continuationRequests, 0); - - yield* Queue.offer(secondProcess, wakeResult); - yield* awaitUntil(() => continuationRequests.length === 1, "continuation after result"); - assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - modelSelection: alternateModel, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 3, - "continuation terminal after buffered drain", - ); - assert.isTrue( - events.some( - (event) => - event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, - ), - ); - assert.isFalse( - events.some( - (event) => - event.type === "subagent.updated" || - (event.type === "node.updated" && event.node.kind === "subagent"), - ), - ); - assert.isFalse( - events.some( - (event) => - event.type !== "provider_thread.updated" && - JSON.stringify(event).includes(WAKE_TASK_ID), - ), - ); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + assert.equal(processQueues.length, 2); + + yield* changes.until(() => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && + event.providerThread.updatedAt !== undefined, ), + ); + assert.isFalse(yield* hasPendingBackgroundWork); + const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ); + assert.isAtLeast(emptyActiveRosterEvents.length, 1); + assert.deepEqual( + emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], + [], + ); + assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); + + const secondProcess = processQueues[1]!; + yield* Queue.offer(secondProcess, wakeNotification); + yield* yieldTicks(50); + assert.lengthOf(continuationRequests, 0); + + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000602", + result: "Process restart turn finished.", + }), + ); + yield* changes.until( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), + ), ); it.effect( - "does not opaque-misclassify a buffered subagent notification across model/policy query replacement", + "preserves buffered local_bash notification classification across model/policy query replacement", () => Effect.scoped( Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-buffer-replace-subagent"; - const SUBAGENT_TOOL_USE_ID = "toolu-buffer-replace-subagent"; - const SUBAGENT_SUMMARY = "SUB_BUFFER_REPLACE_DONE"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Background research", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", - uuid: "00000000-0000-4000-8000-000000000801", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-buffer-replace-subagent.output", - summary: SUBAGENT_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000802", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentAsyncAck = claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: SUBAGENT_TOOL_USE_ID, - content: [{ type: "text", text: "Async agent launched successfully." }], - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000803", - session_id: WAKE_NATIVE_SESSION, - tool_use_result: { - isAsync: true, - status: "async_launched", - agentId: SUBAGENT_TASK_ID, - prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", - }, - }); - + const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-subagent-buffer-replace-", + prefix: "t3-claude-v2-buffer-replace-", }); const processQueues: Array> = []; const events: Array = []; @@ -13654,7 +10624,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), @@ -13679,12 +10649,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, }); @@ -13697,7 +10665,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -13707,15 +10675,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( @@ -13723,33 +10682,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( @@ -13757,8 +10704,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, @@ -13769,31 +10716,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, @@ -13802,24 +10743,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( @@ -13832,6 +10778,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; @@ -13892,7 +10839,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -13932,7 +10879,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; @@ -13973,7 +10920,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, @@ -13994,6 +10941,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* () { @@ -14077,199 +11025,67 @@ describe("ClaudeAdapterV2 background wake turns", () => { 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; + 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, @@ -14280,10 +11096,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), @@ -14318,12 +11131,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, }); @@ -14336,7 +11147,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -14353,20 +11164,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; @@ -14376,7 +11182,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, @@ -14385,73 +11191,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), @@ -14460,84 +11201,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 = ( @@ -14722,10 +11385,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)( @@ -14855,16 +11515,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" && @@ -14872,105 +11522,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) => 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), @@ -15215,10 +11802,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); @@ -15244,9 +11828,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, @@ -15387,552 +11974,49 @@ describe("ClaudeAdapterV2 background wake turns", () => { 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.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([ @@ -16363,325 +12447,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { stoppedTasks, 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.", - ); + 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]) { - 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( @@ -16691,74 +12473,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* () { @@ -16821,10 +12535,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( @@ -16854,14 +12565,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); @@ -16873,10 +12582,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, @@ -16902,9 +12608,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; @@ -16945,11 +12650,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")); @@ -16961,11 +12665,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( @@ -17294,153 +12997,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* () { @@ -17490,282 +13046,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({ @@ -17851,45 +13131,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 c03133eb9ecaf..dade910097212 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -54,6 +54,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; // @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI. import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -92,6 +93,8 @@ import { type OrchestrationV2WebSearchResult, type OrchestratorMcpModelRole, type ProviderApprovalDecision, + type ProviderApprovalFullAccessOverride, + type ProviderApprovalOption, ProviderDriverKind, type ProviderInstanceId, type ProviderRequestKind, @@ -99,9 +102,11 @@ import { type ProviderThreadId, type RunId, type ThreadId, + resolveIdleCompaction, } 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"; @@ -137,6 +142,7 @@ import * as ServerConfig from "../../config.ts"; import { combineApprovalPrompt } from "../../provider/approvalPrompt.ts"; import { claudeTaskOutputDir, reportedTaskOutputPaths } from "../backgroundTaskFiles.ts"; import { classifyShellCommand } from "@t3tools/shared/shellCommand"; +import * as ServerSettings from "../../serverSettings.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; @@ -197,7 +203,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, @@ -314,27 +320,45 @@ 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; + +const CLAUDE_GROUP_WATCH_SCRIPT = [ + "{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } /dev/null 2>&1 &", + 'exec "$@" 3<&-', +].join("\n"); /** * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's * `bun run start` wrapper dies and its `bun server.ts` keeps running, writing to a pipe nobody * reads (#3089). Those stay in the CLI's group, so the rest of the group gets SIGKILL once the * CLI exits for any reason. + * + * That cleanup needs this server alive, and the CLI finishes its turn after its pipes close. A + * server that crashes or is killed would leave the CLI working next to the session it resumes + * (#23370), so a watcher in the group reads a pipe only this server holds and stops the group at + * its end-of-file. */ -function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess { - const child = NodeChildProcess.spawn(options.command, options.args, { - cwd: options.cwd, - env: options.env, - signal: options.signal, - stdio: ["pipe", "pipe", "ignore"], - detached: true, - }); +export function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess { + const child = NodeChildProcess.spawn( + "/bin/sh", + ["-c", CLAUDE_GROUP_WATCH_SCRIPT, "t3-claude", options.command, ...options.args], + { + cwd: options.cwd, + env: options.env, + signal: options.signal, + stdio: ["pipe", "pipe", "ignore", "pipe"], + detached: true, + }, + ); child.once("exit", () => { try { signalProcessGroup(child.pid ?? 0, "SIGKILL"); } catch {} }); - return child; + // Node's typings make stdin and stdout nullable once stdio has a fourth entry; both are pipes. + return child as ClaudeSpawnedProcess; } export const ClaudeProviderCapabilitiesV2 = { @@ -780,7 +804,7 @@ export function loggedClaudeQueryOptions( }; } -export function makeClaudeAgentSdkProtocolLogger(input: { +function makeClaudeAgentSdkProtocolLogger(input: { readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; @@ -1250,6 +1274,24 @@ function claudeLiveSelectionFlagSettings( }; } +// A delegated child is its own SDK session, so Claude Code gives it the main-conversation cache +// TTL. When the user sets Claude's subagentPromptCacheTtl, the child follows it like a native +// subagent would. An explicit CLAUDE_CODE_PROMPT_CACHE_TTL on the instance still wins. +export function claudeQueryEnvironment( + environment: NodeJS.ProcessEnv, + appThread: Pick, + subagentPromptCacheTtl: "5m" | "1h" | undefined, +): NodeJS.ProcessEnv { + if ( + appThread.lineage.relationshipToParent !== "subagent" || + subagentPromptCacheTtl === undefined || + environment.CLAUDE_CODE_PROMPT_CACHE_TTL !== undefined + ) { + return environment; + } + return { ...environment, CLAUDE_CODE_PROMPT_CACHE_TTL: subagentPromptCacheTtl }; +} + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -2146,6 +2188,17 @@ const decodeClaudeBypassSettings = Schema.decodeUnknownEffect( fromLenientJson(ClaudeBypassSettings), ); +const ClaudePromptCacheTtl = Schema.Literals(["5m", "1h"]); +const decodeClaudePromptCacheTtl = Schema.decodeUnknownOption(ClaudePromptCacheTtl); +const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( + fromLenientJson( + Schema.StructWithRest( + Schema.Struct({ subagentPromptCacheTtl: Schema.optionalKey(ClaudePromptCacheTtl) }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), +); + function claudeBypassAvailability( settings: typeof ClaudeBypassSettings.Type, ): ClaudeBypassAvailability { @@ -2241,7 +2294,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 @@ -2840,6 +2893,45 @@ function parentToolUseIdFromSdkMessage(message: SDKMessage): string | null { : null; } +const CLAUDE_NO_STANDING_GRANT_OPTIONS = [ + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, +] satisfies ReadonlyArray; + +export function claudeApprovalPresentation(input: { + readonly permissionMode: PermissionMode; + readonly prompt: string; + readonly callbackOptions: Pick< + Parameters[2], + "decisionReason" | "matchedAskRule" | "suppressAlwaysAllowRule" + >; +}): { + readonly reason?: string; + readonly fullAccessOverride?: ProviderApprovalFullAccessOverride; + readonly options?: ReadonlyArray; +} { + // The reason can carry terminal escapes from the CLI's own prompt renderer. + const reason = NodeUtil.stripVTControlCharacters( + input.callbackOptions.decisionReason ?? "", + ).trim(); + // Bypass auto-allows everything it can, so any ask that still reaches the + // callback is one bypass refused. The SDK drops the CLI's decision_reason_type, + // leaving the matched ask rule as the only way to tell the two causes apart. + const fullAccessOverride: ProviderApprovalFullAccessOverride | undefined = + input.permissionMode !== "bypassPermissions" + ? undefined + : input.callbackOptions.matchedAskRule === undefined + ? "provider_check" + : "ask_rule"; + return { + ...(reason === "" || reason === input.prompt ? {} : { reason }), + ...(fullAccessOverride === undefined ? {} : { fullAccessOverride }), + ...(input.callbackOptions.suppressAlwaysAllowRule === true + ? { options: CLAUDE_NO_STANDING_GRANT_OPTIONS } + : {}), + }; +} + export function permissionResultFromDecision(input: { readonly toolName: string; readonly decision: ProviderApprovalDecision; @@ -3678,7 +3770,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") @@ -3846,7 +3938,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") @@ -3879,6 +3971,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( adapterOptions: ClaudeAdapterV2Options, ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; const subagentModelCatalog = scopeClaudeModelCatalog( BUNDLED_CLAUDE_MODEL_CATALOG, @@ -3931,6 +4024,30 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); + // Claude Code's own subagent TTL setting, in its precedence order. Unset or unreadable means + // Anthropic's default applies. + const resolveSubagentPromptCacheTtl = Effect.fn("ClaudeAdapterV2.resolveSubagentPromptCacheTtl")( + function* (cwd: string | null) { + const fromEnvironment = decodeClaudePromptCacheTtl( + adapterOptions.environment.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL, + ); + if (Option.isSome(fromEnvironment)) return fromEnvironment.value; + const resolved = yield* Effect.tryPromise(() => + resolveClaudeSettings({ + ...(cwd === null ? {} : { cwd }), + settingSources: customHome ? ["project", "local"] : ["user", "project", "local"], + }), + ).pipe(Effect.orElseSucceed(() => undefined)); + const home = customHome + ? yield* fileSystem.readFileString(path.join(homePath, "settings.json")).pipe( + Effect.flatMap(decodeClaudeSubagentCacheSettings), + Effect.orElseSucceed(() => ({ subagentPromptCacheTtl: undefined })), + ) + : undefined; + return resolved?.effective.subagentPromptCacheTtl ?? home?.subagentPromptCacheTtl; + }, + ); + const userInvocableSkills = (cwd: string | null) => discoverClaudeSkills( adapterOptions.settings, @@ -6414,6 +6531,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly nativeRequestId: string; readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; readonly prompt?: string; + readonly presentation?: ReturnType; readonly questions?: ReadonlyArray; }) { const createdAt = yield* DateTime.now; @@ -6498,6 +6616,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId, requestKind: input.requestKind as ProviderRequestKind, ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...input.presentation, } : { type: "user_input_request" as const, @@ -9120,16 +9239,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); - const prompt = combineApprovalPrompt( - [callbackOptions.title, callbackOptions.description, callbackOptions.decisionReason], - summarizeClaudeToolRequest(toolName, nativeToolInput), - ); + const prompt = + combineApprovalPrompt( + [callbackOptions.title, callbackOptions.description, callbackOptions.decisionReason], + summarizeClaudeToolRequest(toolName, nativeToolInput), + ) ?? toolName; const artifacts = yield* buildApprovalRequestArtifacts({ context, nativeItemId: nativeRequestId, nativeRequestId, requestKind, - ...(prompt === undefined ? {} : { prompt }), + prompt, + presentation: claudeApprovalPresentation({ + permissionMode: context.queryPolicy.permissionMode, + prompt, + callbackOptions, + }), }); const decision = yield* Deferred.make(); yield* Ref.update(pendingRuntimeRequests, (current) => { @@ -9200,6 +9325,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (request.dialogKind !== "resume_return") { return { behavior: "cancelled" as const }; } + const idleCompaction = Option.isSome(serverSettings) + ? yield* serverSettings.value.getSettings.pipe( + Effect.map((settings) => + resolveIdleCompaction(settings, adapterOptions.instanceId), + ), + Effect.orElseSucceed(() => adapterOptions.settings.idleCompaction), + ) + : adapterOptions.settings.idleCompaction; + if (idleCompaction === "off") { + return { behavior: "completed" as const, result: "continue" as const }; + } const ageMinutes = typeof request.payload.sessionAgeMinutes === "number" && Number.isFinite(request.payload.sessionAgeMinutes) @@ -9428,7 +9564,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, - environment: adapterOptions.environment, + environment: claudeQueryEnvironment( + adapterOptions.environment, + turnInput.appThread, + turnInput.appThread.lineage.relationshipToParent === "subagent" + ? yield* resolveSubagentPromptCacheTtl(turnInput.runtimePolicy.cwd) + : undefined, + ), tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, ...(mcpOverrides.allowedTools === undefined ? {} @@ -9992,6 +10134,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; @@ -10048,11 +10191,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({ @@ -10079,7 +10228,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 693e2cbab09f0..248e78a1796e1 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,15 @@ import { vi } from "vite-plus/test"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { - CLAUDE_AGENT_SDK_QUERY_PROTOCOL, ClaudeAgentSdkQueryRunner, layerQueryRunner, + spawnClaudeProcessGroup, } 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 +100,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 +110,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"); @@ -298,4 +168,38 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro yield* Effect.promise(() => leftoverStopped); }).pipe(Effect.provide(realQueryRunner)), ); + + // The CLI keeps running its turn after its pipes close, so a server that + // crashed would leave it working next to the session it resumes (#23370). + it.effect("stops the CLI when the server that started it goes away", () => + Effect.gen(function* () { + const cli = fakeClaudeCli(['dir=$(dirname "$0")', 'sleep 600 > "$dir/alive"'].join("\n")); + const dir = NodePath.dirname(cli); + NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]); + const child = spawnClaudeProcessGroup({ + command: cli, + args: [], + cwd: dir, + env: process.env, + signal: new AbortController().signal, + }) as NodeChildProcess.ChildProcess; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + try { + signalProcessGroup(child.pid ?? 0, "SIGKILL"); + } catch {} + NodeFS.rmSync(dir, { recursive: true, force: true }); + }), + ); + const alive = NodeFS.createReadStream(NodePath.join(dir, "alive")); + const turnStarted = new Promise((resolve) => alive.once("open", () => resolve())); + const turnStopped = new Promise((resolve) => alive.once("close", () => resolve())); + alive.resume(); + yield* Effect.promise(() => turnStarted); + + // A dying server's descriptors close; the kernel would do this for it. + for (const pipe of child.stdio) pipe?.destroy(); + yield* Effect.promise(() => turnStopped); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index bca0e4570549e..2a06d25c5030d 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.fork.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.fork.test.ts index 61240662da8af..cf0e23dee7e94 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.fork.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.fork.test.ts @@ -1,905 +1,2077 @@ +import { + DEFAULT_CODEX_SETTINGS, + CODEX_TEST_MODEL_SELECTION, + CODEX_TEST_RUNTIME_POLICY, + makeCodexTestTurnInput, + makeCodexReplayTurn, + codexReplayPreamble, + makeCodexReplayTranscript, +} from "./CodexAdapterV2.testHarness.ts"; + import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderInstanceId, RunAttemptId } from "@t3tools/contracts"; +import { + EnvironmentId, + NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + ThreadId, +} from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; + +import * as CodexClient from "effect-codex-app-server/client"; + +import * as CodexReplay from "effect-codex-app-server/replay"; + import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Exit from "effect/Exit"; +import * as Crypto from "effect/Crypto"; 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 Stream from "effect/Stream"; import { TestClock } from "effect/testing"; + import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; + import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; + +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 { - awaitUntil, - backgroundCommandItem, - backgroundExecTranscript, - BG_COMMAND_ITEM, - BG_NATIVE_THREAD, - BG_NATIVE_TURN, - BG_PROMPT, - codexReplayPreamble, - finalAnswerTranscript, - INTERRUPT_CHILD_COMMAND_ITEM, - INTERRUPT_NATIVE_THREAD, - INTERRUPT_PROMPT, - interruptSubagentCommandTranscript, - makeCodexReplayHarness, - makeCodexReplayTranscript, - makeCodexReplayTurn, - makeCodexTestTurnInput, - runSubagentWakeCase, -} from "./CodexAdapterV2.testHarness.ts"; -describe("CodexAdapterV2 native question mapping", () => { - it("preserves other-answer support without inventing multi-selection", () => { - const question = { - id: "choice", - header: "Choice", - question: "Choose a branch", - options: [{ label: "main", description: "Default branch" }], +import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; + +describe("CodexAdapterV2 file change approvals", () => { + it("keeps the reason, grant root, all 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", + }, }; - const [choice, other, freeText] = CodexAdapterV2.codexUserInputQuestions([ - question, - { ...question, isOther: true }, - { ...question, options: null, isOther: false }, - ]); - assert.equal(choice?.allowCustomAnswer, false); - assert.equal(other?.allowCustomAnswer, true); - assert.equal(freeText?.allowCustomAnswer, true); - assert.equal(choice?.multiSelect, false); - assert.deepEqual(choice?.options, question.options); + assert.equal( + CodexAdapterV2.codexFileChangeApprovalPrompt({ + reason: " Update configuration. ", + grantRoot: "/workspace", + fileChanges, + }), + "Update configuration.\n\nGrant root: /workspace\nadd /tmp/added.ts\nupdate /tmp/moved.ts -> /tmp/renamed.ts\ndelete /tmp/removed.md", + ); + 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 " }), + "Grant root: /workspace", + ); + assert.equal( + CodexAdapterV2.codexFileChangeApprovalPrompt({ fileChanges: {}, grantRoot: "/workspace" }), + "Grant root: /workspace", + ); + assert.isUndefined( + CodexAdapterV2.codexFileChangeApprovalPrompt({ + reason: " ", + grantRoot: " ", + fileChanges: {}, + }), + ); + }); + + it("keeps every file path in a large patch description", () => { + 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, 25); + assert.isTrue(detail?.startsWith("add /tmp/file-00.ts") ?? false); + assert.isTrue(detail?.endsWith("add /tmp/file-24.ts") ?? false); + }); + + it("uses item-start file changes when Codex omits the approval payload", () => { + assert.equal( + CodexAdapterV2.codexFileChangeApprovalPrompt({ + itemChanges: [ + { path: "/tmp/z.ts", kind: { type: "delete" } }, + { path: "/tmp/a.ts", kind: { type: "add" } }, + ], + }), + "add /tmp/a.ts\ndelete /tmp/z.ts", + ); }); }); -describe("CodexAdapterV2 runtime policy", () => { - it.effect("sends the user's model roles in either interaction mode", () => + +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, + }; +} + +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("adds the session MCP server without replacing launch-argument servers", () => Effect.gen(function* () { - const modelRoles = [ + 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, [ { - id: "checker", - name: "Checker", - description: "Read-only review", - targets: [ - { - providerInstanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - unavailableReason: null, + cwd: "/workspace", + model: "gpt-5.4", + config: { + "tools.update_plan.enabled": true, + "features.default_mode_request_user_input": 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" }, }, - ], - }, - ] as const; - for (const interactionMode of ["plan", "default"] as const) { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: `native-roles-${interactionMode}`, - codexInput: [{ type: "text", text: "delegate this" }], - runtimePolicy: { runtimeMode: "full-access", interactionMode, cwd: null }, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - hasT3Mcp: true, - modelRoles, - }); - assert.include( - params.additionalContext?.t3_code_model_roles?.value ?? "", - '- Checker: Read-only review. gpt-5.4 (codex). Target JSON: `{"providerInstanceId":"codex","model":"gpt-5.4"}`', - ); - } - }), + }, + }, + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); -}); -describe("CodexAdapterV2 post-settle continuation", () => { - it.effect("keeps partial proposals non-actionable and supersedes the prior finished plan", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "parity-plan-thread"; - const nativeTurnId = "parity-plan-turn"; - const item = (method: "item/started" | "item/completed", id: string, text: string) => ({ - type: "emit_inbound" as const, - frame: { - method, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622441000, - completedAtMs: 1782622442000, - item: { type: "plan", id, text }, + + it.effect("disables native subagents for repository-managed Codex sessions", () => + Effect.gen(function* () { + const scenario = "managed-native-subagents"; + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("environment-managed-codex"), + threadId: ThreadId.make(`thread-${scenario}`), + providerSessionId: `provider-session-${scenario}`, + providerInstanceId: ProviderInstanceId.make("codex"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer test-token", + browserToolsAvailable: false, + managedIssue: true, + }); + const requests: Array = []; + const entries = codexReplayPreamble({ + nativeThreadId: "managed-codex-thread", + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 5); + entries[3] = { + type: "expect_outbound", + label: "thread/start", + frame: { + id: 2, + method: "thread/start", + params: { + config: { + ...CodexAdapterV2.CODEX_THREAD_CONFIG, + "features.multi_agent": false, + "features.multi_agent_v2": false, }, }, - }); - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "proposal-parity", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Make a plan." }), - item("item/started", "first-plan", ""), + }, + }; + yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario, + entries, + }), + () => 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, + "features.default_mode_request_user_input": true, + mcp_optional_startup_grace_ms: 10_000, + "features.multi_agent": false, + "features.multi_agent_v2": false, + "mcp_servers.t3-code": { + url: "http://127.0.0.1:43123/mcp", + tool_timeout_sec: 3_900, + http_headers: { Authorization: "Bearer test-token" }, + }, + }, + }, + ]); + }).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] + : [], + ); + + 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/plan/delta", + method: "item/started", params: { + item: { + type: "agentMessage", + id: answer.id, + text: "", + ...phase, + memoryCitation: null, + }, threadId: nativeThreadId, turnId: nativeTurnId, - itemId: "first-plan", - delta: "# First", + startedAtMs: 1782622440500 + index, }, }, }, - item("item/completed", "first-plan", ""), - item("item/completed", "second-plan", "# Revised"), - item("item/started", "unfinished-plan", "# Incomplete"), { type: "emit_inbound", + label: `item/agentMessage/delta/${answer.id}`, frame: { - method: "turn/completed", + method: "item/agentMessage/delta", params: { threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), + turnId: nativeTurnId, + itemId: answer.id, + delta: answer.text, }, }, }, - ], - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("proposal-parity"), - text: "Make a plan.", - }), - ); - yield* harness.firstTerminal; - const plans = harness.events.flatMap((event) => - event.type === "plan.updated" ? [event.plan] : [], - ); - assert.deepEqual( - plans.map((plan) => plan.status), - // The delta flushes with the completion, so it adds no draft of its own. - ["draft", "active", "superseded", "active", "draft", "superseded"], - ); - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" - ? [event.turnItem] - : [], - ); - assert.equal( - items.find((item) => item.markdown === "# First" && !item.streaming)?.status, - "completed", - ); - assert.equal(items.at(-1)?.streaming, false); - assert.equal(items.at(-1)?.status, "interrupted"); - assert.equal(items.at(-1)?.markdown, "# Incomplete"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + completed, + ]; + }, ), - ), - ), - ); + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + }; - it.effect.each(["failed", "interrupted"] as const)( - "ends a plan that started without text when its turn is %s", - (status) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = `empty-plan-thread-${status}`; - const nativeTurnId = `empty-plan-turn-${status}`; - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: `empty-plan-${status}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Make a plan." }), - { - type: "emit_inbound", - frame: { - method: "item/started", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622441000, - item: { type: "plan", id: "empty-plan", text: "" }, - }, - }, + const BG_SCENARIO = "codex-bg-exec-wake"; + const BG_NATIVE_THREAD = "native-codex-bg-thread"; + const BG_NATIVE_TURN = "native-codex-bg-turn"; + const BG_COMMAND_ITEM = "call-codex-bg-command"; + const BG_COMMAND = "sleep 20 && echo CODEX_BG_WAKE_DONE"; + const BG_PROMPT = "Start the sleep in the background and reply STARTED."; + + const backgroundCommandItem = (status: "inProgress" | "completed"): Record => ({ + type: "commandExecution", + id: BG_COMMAND_ITEM, + command: BG_COMMAND, + cwd: "/workspace", + processId: "4242", + source: "unifiedExecStartup", + status, + commandActions: [{ type: "unknown", command: BG_COMMAND }], + aggregatedOutput: status === "completed" ? "CODEX_BG_WAKE_DONE\n" : null, + exitCode: status === "completed" ? 0 : null, + durationMs: status === "completed" ? 25_000 : null, + }); + + const backgroundExecTranscript = makeCodexReplayTranscript({ + scenario: BG_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: BG_NATIVE_THREAD, + nativeTurnId: BG_NATIVE_TURN, + prompt: BG_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/command", + frame: { + method: "item/started", + params: { + item: backgroundCommandItem("inProgress"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/root-answer", + frame: { + method: "item/completed", + params: { + item: { + type: "agentMessage", + id: "root-answer-bg", + text: "STARTED", + phase: "final_answer", + memoryCitation: null, + }, + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: BG_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: BG_NATIVE_TURN, status: "completed" }), + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/command-late", + afterMs: 30_000, + frame: { + method: "item/completed", + params: { + item: backgroundCommandItem("completed"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622465500, + }, + }, + }, + ], + }); + + 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", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status }), - }, - }, + }, + { + type: "emit_inbound" as const, + frame: { + id: 5, + result: { data: stillRunning ? [{ processId: "4242" }] : [], nextCursor: null }, }, - ], - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`empty-plan-${status}`), - text: "Make a plan.", - }), - ); - yield* harness.firstTerminal; - const plans = harness.events.flatMap((event) => - event.type === "plan.updated" ? [event.plan] : [], - ); - assert.deepEqual( - plans.map((plan) => plan.status), - ["draft", "superseded"], - ); - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" - ? [event.turnItem] - : [], - ); - assert.equal(items.at(-1)?.streaming, false); - assert.equal(items.at(-1)?.status, "interrupted"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("includes the native command and reason in a command approval request", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "command-approval-thread"; - const nativeTurnId = "command-approval-turn"; - const itemId = "command-approval-item"; - const reason = "Run the requested inspection."; - const command = "git status --porcelain=v1"; - const requestRaised = yield* Deferred.make(); - const transcript = makeCodexReplayTranscript({ - scenario: "command-approval-prompt", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Inspect status." }), - { - type: "emit_inbound", - label: "command approval", - frame: { - id: 4, - method: "item/commandExecution/requestApproval", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId, - approvalId: "command-approval-id", - startedAtMs: 1782622445000, - reason, - command, + }, + ] + : []), + ...(stillRunning + ? [ + { + type: "expect_outbound" as const, + frame: { + id: 6, + method: "thread/backgroundTerminals/terminate", + params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, }, }, - }, - { - type: "expect_outbound", - label: "command approval response", - frame: { id: 4, result: { decision: "accept" } }, - }, - { - type: "emit_inbound", - label: "turn completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + { + 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)!, + ], + }); + }; + + 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, }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "runtime_request.updated" - ? Deferred.succeed(requestRaised, undefined) - : Effect.void, - ); + 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" }), + }, + }, + }, + ], + }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("command-approval-attempt"), - text: "Inspect status.", - }), - ); - yield* Deferred.await(requestRaised); - const requestEvent = harness.events.findLast( - (event) => event.type === "runtime_request.updated", - ); - assert.equal(requestEvent?.type, "runtime_request.updated"); - if (requestEvent?.type !== "runtime_request.updated") return; + 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 approvalItem = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "approval_request" && - event.turnItem.requestId === requestEvent.runtimeRequest.id, - ); - assert.equal( - approvalItem?.type === "turn_item.updated" && - approvalItem.turnItem.type === "approval_request" - ? approvalItem.turnItem.prompt - : undefined, - reason + "\n\n" + command, - ); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: requestEvent.runtimeRequest.id, - decision: "accept", - }); - yield* harness.firstTerminal; - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); + 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, + }); - it.effect("uses started file changes in a V2 file-change approval request", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "file-change-approval-thread"; - const nativeTurnId = "file-change-approval-turn"; - const itemId = "file-change-approval-item"; - const reason = "Update the requested files."; - const requestRaised = yield* Deferred.make(); - const transcript = makeCodexReplayTranscript({ - scenario: "file-change-approval-prompt", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Update files." }), - { - type: "emit_inbound", - label: "file change started", - frame: { - method: "item/started", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622445000, - item: { - type: "fileChange", - id: itemId, - status: "inProgress", - changes: [ - { - path: "/workspace/removed.md", - kind: { type: "delete" }, - diff: "--- a/workspace/removed.md", - }, - { - path: "/workspace/old.ts", - kind: { type: "update", move_path: "/workspace/new.ts" }, - diff: "--- a/workspace/old.ts", - }, - { - path: "/workspace/added.ts", - kind: { type: "add" }, - diff: "+++ b/workspace/added.ts", - }, - ], - }, - }, - }, + 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 }], }, - { - type: "emit_inbound", - label: "file change approval", - frame: { - id: 4, - method: "item/fileChange/requestApproval", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId, - startedAtMs: 1782622445000, - reason, - grantRoot: "/workspace", - }, - }, + 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 interruptSubagentCommandTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-subagent-command", + entries: [ + ...codexReplayPreamble({ + nativeThreadId: INTERRUPT_NATIVE_THREAD, + nativeTurnId: INTERRUPT_NATIVE_TURN, + prompt: INTERRUPT_PROMPT, + }), + { + type: "emit_inbound", + label: "item/completed/subAgentActivity-started", + frame: { + method: "item/completed", + params: { + item: { + type: "subAgentActivity", + id: "call-codex-interrupt-subagent", + kind: "started", + agentThreadId: INTERRUPT_CHILD_NATIVE_THREAD, + agentPath: "/root/stop_hold", }, - { - type: "expect_outbound", - label: "file change approval response", - frame: { id: 4, result: { decision: "accept" } }, + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/started/child", + frame: { + method: "turn/started", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_CHILD_NATIVE_TURN, + status: "inProgress", + }), + }, + }, + }, + { + type: "emit_inbound", + label: "item/started/child-command", + frame: { + method: "item/started", + params: { + item: { + ...interruptCommandItem("inProgress"), + id: INTERRUPT_CHILD_COMMAND_ITEM, + processId: "57682", }, - { - type: "emit_inbound", - label: "turn completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_CHILD_NATIVE_TURN, + startedAtMs: 1782622441500, + }, + }, + }, + { + type: "expect_outbound", + label: "turn/interrupt/root", + frame: { + id: 4, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt/root", + frame: { id: 4, result: {} }, + }, + { + type: "expect_outbound", + label: "turn/interrupt/child", + frame: { + id: 5, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_CHILD_NATIVE_TURN, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt/child", + frame: { id: 5, result: {} }, + }, + { + type: "expect_outbound", + label: "thread/backgroundTerminals/terminate/child", + frame: { + id: 6, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_CHILD_NATIVE_THREAD, processId: "57682" }, + }, + }, + { + type: "emit_inbound", + label: "thread/backgroundTerminals/terminate/child", + frame: { id: 6, result: { terminated: true } }, + }, + { + type: "emit_inbound", + label: "turn/completed/root", + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed/child-completed-race", + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_CHILD_NATIVE_TURN, + status: "completed", + }), + }, + }, + }, + { type: "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", + ); + }; + + const childInterruptResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "turn/interrupt/child", + ); + const rootInterruptResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "turn/interrupt/root", + ); + const interruptSubagentRequestFailureTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-subagent-request-failure", + entries: [ + ...interruptSubagentCommandTranscript.entries.slice(0, rootInterruptResponseIndex + 1), + { + type: "emit_inbound", + label: "turn/completed/root-before-child-interrupt-failure", + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), + }, + }, + }, + ...interruptSubagentCommandTranscript.entries.slice( + rootInterruptResponseIndex + 1, + childInterruptResponseIndex, + ), + { + type: "emit_inbound", + label: "turn/interrupt/child", + frame: { + id: 5, + error: { code: -32_000, message: "child interrupt request failed" }, + }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); + + const 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, }, }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "runtime_request.updated" - ? Deferred.succeed(requestRaised, undefined) - : Effect.void, - ); + }, + }, + }, + { + 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" }, + ], + }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("file-change-approval-attempt"), - text: "Update files.", - }), - ); - yield* Deferred.await(requestRaised); - const requestEvent = harness.events.findLast( - (event) => event.type === "runtime_request.updated", - ); - assert.equal(requestEvent?.type, "runtime_request.updated"); - if (requestEvent?.type !== "runtime_request.updated") return; + 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, + }, + }, + }, + ], + }); - const approvalItem = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "approval_request" && - event.turnItem.requestId === requestEvent.runtimeRequest.id, - ); - assert.equal( - approvalItem?.type === "turn_item.updated" && - approvalItem.turnItem.type === "approval_request" - ? approvalItem.turnItem.prompt - : undefined, - `${reason}\n\nGrant root: /workspace\nadd /workspace/added.ts\nupdate /workspace/old.ts -> /workspace/new.ts\ndelete /workspace/removed.md`, - ); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: requestEvent.runtimeRequest.id, - decision: "accept", - }); - yield* harness.firstTerminal; - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, 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", + entries: [ + ...interruptSubagentCommandTranscript.entries.slice(0, rootCompletionIndex), + { + type: "emit_inbound", + label: "turn/started/late-child", + frame: { + method: "turn/started", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_NATIVE_TURN, + status: "inProgress", + }), + }, + }, + }, + ...interruptSubagentCommandTranscript.entries.slice(rootCompletionIndex, -1), + { + type: "expect_outbound", + label: "turn/interrupt/late-child", + frame: { + id: 7, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt/late-child", + frame: { id: 7, result: {} }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); - it.effect("shows the whole requested permission profile in a permissions approval", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "permissions-approval-thread"; - const nativeTurnId = "permissions-approval-turn"; - const reason = "Run the requested inspection."; - const permissions = { - fileSystem: { - read: ["/repo/docs"], - write: ["/tmp/approved-target"], - entries: [{ access: "deny", path: { type: "glob_pattern", pattern: "/repo/**/.env" } }], + const interruptRescanLateSubagentTurnTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-rescan-late-subagent-turn", + entries: [ + ...interruptSubagentCommandTranscript.entries.slice(0, childTerminationResponseIndex + 1), + { + type: "emit_inbound", + label: "turn/started/late-child-1", + frame: { + method: "turn/started", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_NATIVE_TURN, + status: "inProgress", + }), }, - network: { enabled: true }, - }; - const requestRaised = yield* Deferred.make(); - const transcript = makeCodexReplayTranscript({ - scenario: "permissions-approval-prompt", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Inspect." }), - { - type: "emit_inbound", - label: "permissions approval", - frame: { - id: 4, - method: "item/permissions/requestApproval", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId: "permissions-approval-item", - startedAtMs: 1782622445000, - cwd: "/repo", - reason, - permissions, - }, - }, + }, + }, + { + type: "expect_outbound", + label: "turn/interrupt/late-child-1", + frame: { + id: 7, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/started/late-child-2", + frame: { + method: "turn/started", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, + status: "inProgress", + }), + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt/late-child-1", + frame: { id: 7, result: {} }, + }, + { + type: "expect_outbound", + label: "turn/interrupt/late-child-2", + frame: { + id: 8, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt/late-child-2", + frame: { id: 8, result: {} }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); + + const interruptTimeoutTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-timeout", + entries: [ + ...codexReplayPreamble({ + nativeThreadId: INTERRUPT_NATIVE_THREAD, + nativeTurnId: INTERRUPT_NATIVE_TURN, + prompt: INTERRUPT_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/command", + frame: { + method: "item/started", + params: { + item: interruptCommandItem("inProgress"), + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + type: "expect_outbound", + label: "turn/interrupt", + frame: { + id: 4, + method: "turn/interrupt", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/interrupt", + frame: { id: 4, result: {} }, + }, + { + type: "emit_inbound", + label: "item/started/command-two-after-interrupt-response", + frame: { + method: "item/started", + params: { + item: { + ...interruptCommandItem("inProgress"), + id: INTERRUPT_COMMAND_ITEM_TWO, + command: INTERRUPT_COMMAND_TWO, + processId: "57681", + commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND_TWO }], }, - { - type: "expect_outbound", - label: "permissions approval response", - frame: { id: 4, result: { permissions, scope: "turn" } }, + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + startedAtMs: 1782622440600, + }, + }, + }, + { + type: "expect_outbound", + label: "thread/backgroundTerminals/terminate/one", + frame: { + id: 5, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, + }, + }, + { + type: "emit_inbound", + label: "thread/backgroundTerminals/terminate/one", + frame: { id: 5, result: { terminated: true } }, + }, + { + type: "emit_inbound", + label: "item/started/command-at-timeout-boundary", + afterMs: 9_999, + frame: { + method: "item/started", + params: { + item: { + ...interruptCommandItem("inProgress"), + id: INTERRUPT_TIMEOUT_BOUNDARY_ITEM, + command: "echo TIMEOUT_BOUNDARY", + processId: null, + commandActions: [{ type: "unknown", command: "echo TIMEOUT_BOUNDARY" }], }, - { - type: "emit_inbound", - label: "turn completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + startedAtMs: 1782622450500, + }, + }, + }, + { + type: "expect_outbound", + label: "thread/backgroundTerminals/terminate/two", + frame: { + id: 6, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, + }, + }, + { + type: "emit_inbound", + label: "thread/backgroundTerminals/terminate/two", + frame: { id: 6, result: { terminated: true } }, + }, + { + type: "emit_inbound", + label: "turn/completed/late", + afterMs: 20_000, + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_NATIVE_TURN, + status: "interrupted", + }), + }, + }, + }, + { + type: "emit_inbound", + label: "item/started/command-after-timeout", + frame: { + method: "item/started", + params: { + item: { + ...interruptCommandItem("inProgress"), + id: INTERRUPT_TIMEOUT_LATE_ITEM, + command: "echo LATE_AFTER_TIMEOUT", + processId: null, + commandActions: [{ type: "unknown", command: "echo LATE_AFTER_TIMEOUT" }], }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "runtime_request.updated" - ? Deferred.succeed(requestRaised, undefined) - : Effect.void, - ); + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + startedAtMs: 1782622470500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/command-late", + frame: { + method: "item/completed", + params: { + item: interruptCommandItem("completed"), + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + completedAtMs: 1782622465500, + }, + }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("permissions-approval-attempt"), - text: "Inspect.", - }), - ); - yield* Deferred.await(requestRaised); - const requestEvent = harness.events.findLast( - (event) => event.type === "runtime_request.updated", - ); - if (requestEvent?.type !== "runtime_request.updated") return assert.fail("no request"); - const approvalItem = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "approval_request" && - event.turnItem.requestId === requestEvent.runtimeRequest.id, - ); - assert.equal( - approvalItem?.type === "turn_item.updated" && - approvalItem.turnItem.type === "approval_request" - ? approvalItem.turnItem.prompt - : undefined, - `${reason}\n\nWorking directory: /repo\nread /repo/docs\nwrite /tmp/approved-target\ndeny /repo/**/.env\nnetwork enabled`, - ); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: requestEvent.runtimeRequest.id, - decision: "accept", - }); - yield* harness.firstTerminal; - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + const interruptTerminationFailureTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-termination-failure", + entries: [ + ...interruptMidCommandTranscript.entries + .filter( + (entry) => entry.type === "runtime_exit" || entry.label !== "item/completed/command-late", + ) + .map((entry) => + entry.type === "emit_inbound" && + entry.label === "thread/backgroundTerminals/list/after-false" + ? { + ...entry, + frame: { + id: 6, + result: { + data: [{ processId: "57680" }], + nextCursor: null, + }, + }, + } + : entry, ), - ), - ), - ); + { + type: "expect_outbound", + label: "thread/backgroundTerminals/terminate/one-retry", + frame: { + id: 8, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, + }, + }, + { + type: "emit_inbound", + label: "thread/backgroundTerminals/terminate/one-retry", + frame: { id: 8, result: { terminated: false } }, + }, + { + type: "expect_outbound", + label: "thread/backgroundTerminals/list/after-false-retry", + frame: { + id: 9, + method: "thread/backgroundTerminals/list", + params: { threadId: INTERRUPT_NATIVE_THREAD }, + }, + }, + { + type: "emit_inbound", + label: "thread/backgroundTerminals/list/after-false-retry", + frame: { + id: 9, + result: { data: [{ processId: "57680" }], nextCursor: null }, + }, + }, + { type: "runtime_exit", status: "success" }, + ], + }); - it.effect("stamps each agent message's phase on its turn item", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-message-phases", [ - { id: "answer-commentary", text: "Working on it.", phase: "commentary" }, - { id: "answer-final", text: "Fixed." }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; + const FAILED_SCENARIO = "codex-failed-mid-command"; + const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; + const FAILED_NATIVE_TURN = "native-codex-failed-turn"; + const FAILED_COMMAND_ITEM = "exec-codex-failed-command"; + const FAILED_COMMAND = "sleep 30"; + const FAILED_PROMPT = "Run a command that will be abandoned when the turn fails."; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-message-phases"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); + const failedMidCommandTranscript = makeCodexReplayTranscript({ + scenario: FAILED_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: FAILED_NATIVE_THREAD, + nativeTurnId: FAILED_NATIVE_TURN, + prompt: FAILED_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/command", + frame: { + method: "item/started", + params: { + item: { + type: "commandExecution", + id: FAILED_COMMAND_ITEM, + command: FAILED_COMMAND, + cwd: "/workspace", + processId: "99", + source: "unifiedExecStartup", + status: "inProgress", + commandActions: [{ type: "unknown", command: FAILED_COMMAND }], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + threadId: FAILED_NATIVE_THREAD, + turnId: FAILED_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: FAILED_NATIVE_THREAD, + turn: { + ...makeCodexReplayTurn({ + id: FAILED_NATIVE_TURN, + status: "failed", + }), + error: { message: "provider failed mid-command" }, + }, + }, + }, + }, + ], + }); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - !event.turnItem.streaming - ? [[event.turnItem.text, event.turnItem.phase]] - : [], - ), - [ - ["Working on it.", "commentary"], - ["Fixed.", "final_answer"], - ], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); + const MCP_APP_SCENARIO = "codex-mcp-app-capture"; + const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread"; + const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn"; + const MCP_APP_ITEM = "mcp-weather-call"; + const MCP_APP_PROMPT = "Show the weather in Oslo."; + const MCP_APP_RESOURCE = "ui://weather/dashboard"; + const MCP_APP_HTML = "

Weather

"; + const mcpAppToolItem = (status: "inProgress" | "completed") => ({ + type: "mcpToolCall", + id: MCP_APP_ITEM, + server: "weather", + tool: "get_weather", + status, + arguments: { city: "Oslo" }, + mcpAppResourceUri: MCP_APP_RESOURCE, + ...(status === "completed" + ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } + : {}), + }); - it.effect("stops one background command by its item id through its process", () => - Effect.scoped( - Effect.gen(function* () { - const stopped = yield* Deferred.make(); - const outputCaptured = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "codex-bg-stop-one", - 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: { id: 4, result: { terminated: true } }, - }, + const mcpAppTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: MCP_APP_NATIVE_THREAD, + nativeTurnId: MCP_APP_NATIVE_TURN, + prompt: MCP_APP_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/app-tool", + frame: { + method: "item/started", + params: { + item: mcpAppToolItem("inProgress"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + 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: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: MCP_APP_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), + }, + }, + }, + { + type: "expect_outbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + method: "mcpServer/resource/read", + params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, + }, + }, + { + type: "emit_inbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + result: { + contents: [ { - type: "emit_inbound", - frame: { - method: "item/completed", - params: { - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - item: { - ...backgroundCommandItem("completed"), - status: "failed", - exitCode: 130, - }, - }, - }, + uri: MCP_APP_RESOURCE, + mimeType: "text/html;profile=mcp-app", + text: MCP_APP_HTML, + _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, }, ], - }), - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted" - ? Deferred.succeed(event.turnItem.output ? outputCaptured : stopped, undefined).pipe( - Effect.asVoid, - ) - : Effect.void, - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-bg-stop-one"), - text: BG_PROMPT, - }), - ); - yield* harness.firstTerminal; - const stop = harness.runtime.stopBackgroundTask; - if (stop === undefined) return yield* Effect.die("Codex must stop background commands."); - // An id Codex is not running is refused before anything reaches Codex. - const unknown = yield* Effect.exit( - stop({ providerThread: harness.providerThread, taskId: "call-not-running" }), - ); - assert.isTrue(Exit.isFailure(unknown)); - // The replay only accepts terminate for the command's own process id. - yield* stop({ providerThread: harness.providerThread, taskId: BG_COMMAND_ITEM }); - yield* Deferred.await(stopped); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - yield* Deferred.await(outputCaptured); - const completed = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.output !== undefined, - ); - assert.equal(completed?.type, "turn_item.updated"); - if ( - completed?.type !== "turn_item.updated" || - completed.turnItem.type !== "command_execution" - ) - return yield* Effect.die("Expected stopped command output."); - assert.equal(completed.turnItem.status, "interrupted"); - assert.equal(completed.turnItem.output, "CODEX_BG_WAKE_DONE\n"); - assert.equal(completed.turnItem.exitCode, 130); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); + }, + }, + }, + ], + }); + + 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" }, + }, + }, + }, + }, + ], + }); + + 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"; + const ORPHAN_WAIT_ITEM = "exec-4669f3bb-78c9-4af1-b44e-daa340d2c538"; + const PERSISTENT_MONITOR_ITEM = "exec-persistent-monitor"; + const COMPLETED_WAIT_ITEM = "exec-completed-wait"; + const ORPHAN_WAIT_PROMPT = "Wait on two nested tasks, then finish."; - it.effect( - "keeps a stopped command terminal after the root settles while its sibling completes normally", - () => - Effect.scoped( - Effect.gen(function* () { - const ready = yield* Deferred.make(); - const lateFramesDrained = yield* Deferred.make(); - const commandFrame = ( - method: "item/started" | "item/completed", - item: Record, - ) => ({ - type: "emit_inbound" as const, - frame: { - method, - params: { - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - startedAtMs: 1782622440500, - completedAtMs: 1782622465500, - item, + const orphanedDynamicToolTranscript = makeCodexReplayTranscript({ + scenario: ORPHAN_WAIT_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: ORPHAN_WAIT_NATIVE_THREAD, + nativeTurnId: ORPHAN_WAIT_NATIVE_TURN, + prompt: ORPHAN_WAIT_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/completed-wait", + frame: { + method: "item/started", + params: { + item: { + type: "mcpToolCall", + id: COMPLETED_WAIT_ITEM, + server: "t3-code", + tool: "t3_thread_wait", + status: "inProgress", + arguments: { threadId: "thread:completed-wait", timeoutMs: 30000 }, + }, + threadId: ORPHAN_WAIT_NATIVE_THREAD, + turnId: ORPHAN_WAIT_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/completed-wait", + frame: { + method: "item/completed", + params: { + item: { + type: "mcpToolCall", + id: COMPLETED_WAIT_ITEM, + server: "t3-code", + tool: "t3_thread_wait", + status: "completed", + arguments: { threadId: "thread:completed-wait", timeoutMs: 30000 }, + result: { content: [{ type: "text", text: "idle" }] }, + }, + threadId: ORPHAN_WAIT_NATIVE_THREAD, + turnId: ORPHAN_WAIT_NATIVE_TURN, + completedAtMs: 1782622441500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/started/orphan-wait", + frame: { + method: "item/started", + params: { + item: { + type: "mcpToolCall", + id: ORPHAN_WAIT_ITEM, + server: "t3-code", + tool: "t3_thread_wait", + status: "inProgress", + arguments: { + threadId: + "thread:delegated-task:command%3Amcp%3Aaafffab1-e811-458a-ae83-558e542c61ff%3Adelegate-task%3Areview-mobile-reconnect-opus-20260815", + timeoutMs: 30000, }, }, - }); - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "codex-bg-stop-late", - entries: [ - ...codexReplayPreamble({ - nativeThreadId: BG_NATIVE_THREAD, - nativeTurnId: BG_NATIVE_TURN, - prompt: BG_PROMPT, - }), - commandFrame("item/started", backgroundCommandItem("inProgress")), - commandFrame("item/started", { - ...backgroundCommandItem("inProgress"), - id: "sibling-command", - processId: "4243", - }), - { - type: "expect_outbound", - frame: { - id: 4, - method: "thread/backgroundTerminals/terminate", - params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, - }, - }, - { type: "emit_inbound", frame: { id: 4, result: { terminated: true } } }, - { - type: "emit_inbound", - frame: { - method: "turn/completed", - params: { - threadId: BG_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: BG_NATIVE_TURN, status: "completed" }), - }, - }, - }, - commandFrame("item/started", backgroundCommandItem("inProgress")), - commandFrame("item/completed", { - ...backgroundCommandItem("completed"), - status: "failed", - exitCode: 130, - }), - commandFrame("item/completed", { - ...backgroundCommandItem("completed"), - id: "sibling-command", - processId: "4243", - }), - ], - }), - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === "sibling-command" - ? Deferred.succeed( - event.turnItem.status === "completed" ? lateFramesDrained : ready, - undefined, - ).pipe(Effect.asVoid) - : Effect.void, - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("stop-late"), - text: BG_PROMPT, + threadId: ORPHAN_WAIT_NATIVE_THREAD, + turnId: ORPHAN_WAIT_NATIVE_TURN, + startedAtMs: 1782622442500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/started/persistent-monitor", + frame: { + method: "item/started", + params: { + item: { + type: "dynamicToolCall", + id: PERSISTENT_MONITOR_ITEM, + namespace: "grok", + tool: "monitor", + status: "inProgress", + arguments: { persistent: true, command: "tail -f" }, + }, + threadId: ORPHAN_WAIT_NATIVE_THREAD, + turnId: ORPHAN_WAIT_NATIVE_TURN, + startedAtMs: 1782622443500, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: ORPHAN_WAIT_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: ORPHAN_WAIT_NATIVE_TURN, + status: "completed", }), - ); - yield* Deferred.await(ready); - yield* harness.runtime.stopBackgroundTask!({ - providerThread: harness.providerThread, - taskId: BG_COMMAND_ITEM, - }); - yield* harness.firstTerminal; - yield* Deferred.await(lateFramesDrained); - const commands = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution" - ? [event.turnItem] - : [], - ); - const stoppedCommand = commands.findLast( - (item) => item.nativeItemRef?.nativeId === BG_COMMAND_ITEM, - ); - assert.equal(stoppedCommand?.status, "interrupted"); - assert.equal(stoppedCommand?.output, "CODEX_BG_WAKE_DONE\n"); - assert.equal(stoppedCommand?.exitCode, 130); - assert.equal( - commands.findLast((item) => item.nativeItemRef?.nativeId === "sibling-command")?.status, - "completed", - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/persistent-monitor", + afterMs: 30_000, + frame: { + method: "item/completed", + params: { + item: { + type: "dynamicToolCall", + id: PERSISTENT_MONITOR_ITEM, + namespace: "grok", + tool: "monitor", + status: "completed", + arguments: { persistent: true, command: "tail -f" }, + result: { content: [{ type: "text", text: "stopped" }] }, + }, + threadId: ORPHAN_WAIT_NATIVE_THREAD, + turnId: ORPHAN_WAIT_NATIVE_TURN, + completedAtMs: 1782622473500, + }, + }, + }, + ], + }); + + const RESUME_SCENARIO = "codex-resume-subagent"; + const RESUME_NATIVE_THREAD = "native-codex-resume-thread"; + const RESUME_NATIVE_TURN = "native-codex-resume-root-turn"; + const RESUME_CHILD_THREAD = "native-codex-resume-child-thread"; + const RESUME_CHILD_TURN_1 = "native-codex-resume-child-turn-1"; + const RESUME_CHILD_TURN_2 = "native-codex-resume-child-turn-2"; + const RESUME_PROMPT = "Spawn a sub-agent, nudge it, and reply NUDGED."; + + const childAgentMessage = (input: { + readonly id: string; + readonly text: string; + readonly turnId: string; + readonly completedAtMs: number; + readonly afterMs?: number; + readonly omitPhase?: boolean; + }): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `item/completed/${input.id}`, + ...(input.afterMs === undefined ? {} : { afterMs: input.afterMs }), + frame: { + method: "item/completed", + params: { + item: { + type: "agentMessage", + id: input.id, + text: input.text, + ...(input.omitPhase ? {} : { phase: "final_answer" as const }), + memoryCitation: null, + }, + threadId: RESUME_CHILD_THREAD, + turnId: input.turnId, + completedAtMs: input.completedAtMs, + }, + }, + }); + + const childTurnStarted = ( + turnId: string, + afterMs?: number, + ): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `turn/started/${turnId}`, + ...(afterMs === undefined ? {} : { afterMs }), + frame: { + method: "turn/started", + params: { + threadId: RESUME_CHILD_THREAD, + turn: { + ...makeCodexReplayTurn({ id: turnId, status: "inProgress" }), + startedAt: turnId === RESUME_CHILD_TURN_2 ? 1782622470 : 1782622440, + }, + }, + }, + }); + + const childTurnCompleted = ( + turnId: string, + afterMs?: number, + ): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `turn/completed/${turnId}`, + ...(afterMs === undefined ? {} : { afterMs }), + frame: { + method: "turn/completed", + params: { + threadId: RESUME_CHILD_THREAD, + turn: makeCodexReplayTurn({ id: turnId, status: "completed" }), + }, + }, + }); + + const resumeSubagentTranscript = makeCodexReplayTranscript({ + scenario: RESUME_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: RESUME_NATIVE_THREAD, + nativeTurnId: RESUME_NATIVE_TURN, + prompt: RESUME_PROMPT, + }), + { + type: "emit_inbound", + label: "item/completed/subAgentActivity-started", + frame: { + method: "item/completed", + params: { + item: { + type: "subAgentActivity", + id: "call-codex-resume-spawn", + kind: "started", + agentThreadId: RESUME_CHILD_THREAD, + agentPath: "/root/resume_agent", + }, + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + }, + childTurnStarted(RESUME_CHILD_TURN_1), + childAgentMessage({ + id: "child-first-answer", + text: "CODEX_FIRST_DONE", + turnId: RESUME_CHILD_TURN_1, + completedAtMs: 1782622442000, + }), + childAgentMessage({ + id: "child-first-answer-empty", + text: "", + turnId: RESUME_CHILD_TURN_1, + completedAtMs: 1782622442001, + omitPhase: true, + }), + childAgentMessage({ + id: "child-first-answer-duplicate", + text: "CODEX_FIRST_DONE", + turnId: RESUME_CHILD_TURN_1, + completedAtMs: 1782622442002, + }), + childTurnCompleted(RESUME_CHILD_TURN_1, 100), + { + type: "emit_inbound", + label: "item/completed/root-answer", + frame: { + method: "item/completed", + params: { + item: { + type: "agentMessage", + id: "root-answer-resume", + text: "NUDGED", + phase: "final_answer", + memoryCitation: null, + }, + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + completedAtMs: 1782622443000, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed/root", + frame: { + method: "turn/completed", + params: { + threadId: RESUME_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: RESUME_NATIVE_TURN, status: "completed" }), + }, + }, + }, + childTurnStarted(RESUME_CHILD_TURN_2, 30_000), + childAgentMessage({ + id: "child-resume-answer", + text: "CODEX_RESUME_DONE", + turnId: RESUME_CHILD_TURN_2, + completedAtMs: 1782622480000, + afterMs: 30_000, + }), + childTurnCompleted(RESUME_CHILD_TURN_2), + ], + }); - it.effect("stops only a native Codex child and ignores a stale Stop", () => + it.effect("gives a worker subagent's thread the Implementor role its child thread reports", () => Effect.scoped( Effect.gen(function* () { - const entries = interruptSubagentCommandTranscript.entries - .filter( - (entry) => - entry.type !== "runtime_exit" && - !( - "label" in entry && - ["turn/interrupt/root", "turn/completed/root"].includes(entry.label ?? "") - ), - ) - .map((entry) => { - if (entry.type === "expect_outbound" || entry.type === "emit_inbound") { - const frame = entry.frame as Record; - return typeof frame.id === "number" && frame.id > 4 - ? { ...entry, frame: { ...frame, id: frame.id - 1 } } - : entry; - } - return entry; - }); - const childReady = yield* Deferred.make(); + const roleReported = yield* Deferred.make(); const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "codex-stop-one-child", - entries, - }), + resumeSubagentTranscript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM - ? Deferred.succeed(childReady, undefined).pipe(Effect.asVoid) + event.type === "app_thread.created" && event.appThread.role !== undefined + ? Deferred.succeed(roleReported, undefined) : Effect.void, + undefined, + (threadId) => + Effect.succeed({ thread: { id: threadId, model: "gpt-6-sol", agentRole: "worker" } }), ); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("stop-one-child"), - text: INTERRUPT_PROMPT, + attemptId: RunAttemptId.make("attempt-child-role"), + text: RESUME_PROMPT, }), ); - yield* Deferred.await(childReady); - const child = harness.events.findLast((event) => event.type === "subagent.updated"); - assert.equal(child?.type, "subagent.updated"); - if (child?.type !== "subagent.updated") return; - const stop = harness.runtime.stopSubagent!; - const unknown = yield* Effect.exit( - stop({ providerThread: harness.providerThread, nativeTaskId: "unknown" }), - ); - assert.isTrue(Exit.isFailure(unknown)); - yield* stop({ - providerThread: harness.providerThread, - nativeTaskId: child.subagent.nativeTaskRef!.nativeId!, - requestedAt: DateTime.makeUnsafe(0), - }); - assert.isFalse( - harness.events.some( - (event) => event.type === "subagent.updated" && event.subagent.status === "interrupted", - ), + yield* Deferred.await(roleReported); + yield* TestClock.adjust("100 millis"); + yield* harness.firstTerminal; + const childThreads = harness.events.flatMap((event) => + event.type === "app_thread.created" ? [event.appThread] : [], ); - yield* stop({ - providerThread: harness.providerThread, - nativeTaskId: child.subagent.nativeTaskRef!.nativeId!, - }); - assert.equal( - harness.events.findLast((event) => event.type === "subagent.updated")?.subagent.status, - "interrupted", + assert.deepEqual( + childThreads.map((thread) => thread.role), + [undefined, "implementor"], ); - assert.lengthOf(harness.terminalEvents(), 0, "the parent must keep running"); + assert.equal(childThreads[1]?.id, childThreads[0]?.id); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -908,115 +2080,79 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("fails active turns and drains terminal events when a shared request times out", () => + it.effect("preserves a subagent result across a trailing empty final and resume", () => Effect.scoped( Effect.gen(function* () { - const childStarted = yield* Deferred.make(); - const unsubscribeSent = yield* Deferred.make(); - const interruptIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => entry.type === "expect_outbound" && entry.label === "turn/interrupt/root", - ); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-stalled-shared-request", - entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, interruptIndex), - { - type: "expect_outbound", - frame: { - id: 4, - method: "thread/unsubscribe", - params: { threadId: INTERRUPT_NATIVE_THREAD }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness( - transcript, - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running" - ? Deferred.succeed(childStarted, undefined) - : Effect.void, - (method) => - method === "thread/unsubscribe" - ? Deferred.succeed(unsubscribeSent, undefined).pipe(Effect.asVoid) - : Effect.void, - ); + const harness = yield* makeCodexReplayHarness(resumeSubagentTranscript); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-stalled-shared-request"), - text: INTERRUPT_PROMPT, + now, + attemptId: RunAttemptId.make("attempt-codex-resume"), + text: RESUME_PROMPT, }), ); - yield* Deferred.await(childStarted); - const detach = yield* harness.runtime.unloadThread!({ - providerThread: harness.providerThread, - }).pipe(Effect.forkScoped); - yield* Deferred.await(unsubscribeSent); - yield* TestClock.adjust("60 seconds"); - const detachExit = yield* Fiber.await(detach); - assert.isTrue(Exit.isFailure(detachExit)); - yield* harness.firstTerminal; - const streamExit = yield* harness.eventStreamExit; - assert.isTrue(Exit.isFailure(streamExit)); - if (Exit.isFailure(streamExit)) assert.include(Cause.pretty(streamExit.cause), "timed out"); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - const childTerminal = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.threadId !== harness.threadId && - event.providerTurn.status === "failed", + yield* awaitUntil( + () => + harness.subagentUpdates().some((event) => event.subagent.result === "CODEX_FIRST_DONE"), + "first subagent result", ); - assert.isAtLeast(childTerminal, 0); - const rootTerminal = harness.terminalEvents()[0]; - assert.isTrue( - rootTerminal?.status === "failed" && rootTerminal.childTurnsContinue === true, + 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); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); + const settledUpdateCount = settledUpdates.length; - it.effect("wakes a settled parent once when its subagent finishes", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* runSubagentWakeCase("codex-subagent-wake-settled", [ - { name: "alpha", endsInTurn: false }, - ]); - // The parent's subagent item is still running when its root turn settles, - // which is what lists it as pending work and shows the parent as waiting. - const settledAt = harness.events.findIndex((event) => event.type === "turn.terminal"); - const itemAtSettle = harness.events - .slice(0, settledAt) - .findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.threadId === harness.threadId, - ); - assert.equal( - itemAtSettle?.type === "turn_item.updated" ? itemAtSettle.turnItem.status : undefined, - "running", + 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, 1); - const request = harness.continuationRequests[0]!; - assert.equal(request.threadId, harness.threadId); - assert.equal(request.providerThreadId, harness.providerThread.id); + assert.deepEqual(harness.continuationRequests[0]?.notification, { + source: { + kind: "subagent", + childThreadId: ThreadId.make( + `thread:provider:codex:native-thread:${RESUME_CHILD_THREAD}`, + ), + }, + outcome: "completed", + summary: 'Subagent "/root/resume_agent" finished', + }); assert.equal( - request.detail, - "Subagent /root/alpha finished. Its in this thread has the full result.\n\n" + - "Result tail:\nALPHA_DONE", + harness.continuationRequests[0]?.detail, + "Subagent /root/resume_agent finished. Its in this thread has the full result." + + "\n\nResult tail:\nCODEX_RESUME_DONE", ); - assert.equal(request.notification?.outcome, "completed"); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -1025,42 +2161,67 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("does not wake the parent for a subagent that finishes during its turn", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* runSubagentWakeCase("codex-subagent-wake-in-turn", [ - { name: "alpha", endsInTurn: true }, - { name: "beta", endsInTurn: false }, - ]); - // Only beta, which outlived the root turn, wakes it. - assert.deepEqual( - harness.continuationRequests.map((request) => request.detail?.split(".")[0]), - ["Subagent /root/beta finished"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); + 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", + }); - it.effect("queues one wake per subagent when several finish together", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* runSubagentWakeCase("codex-subagent-wake-together", [ - { name: "alpha", endsInTurn: false }, - { name: "beta", endsInTurn: false }, - ]); - assert.deepEqual( - harness.continuationRequests.map((request) => request.detail?.split(".")[0]), - ["Subagent /root/alpha finished", "Subagent /root/beta finished"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); + 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, + }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 183b215b5a35f..6cf99c162265a 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,112 +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("keeps the reason, grant root, all 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. ", - grantRoot: "/workspace", - fileChanges, - }), - "Update configuration.\n\nGrant root: /workspace\nadd /tmp/added.ts\nupdate /tmp/moved.ts -> /tmp/renamed.ts\ndelete /tmp/removed.md", - ); - 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 " }), - "Grant root: /workspace", - ); - assert.equal( - CodexAdapterV2.codexFileChangeApprovalPrompt({ fileChanges: {}, grantRoot: "/workspace" }), - "Grant root: /workspace", - ); - assert.isUndefined( - CodexAdapterV2.codexFileChangeApprovalPrompt({ - reason: " ", - grantRoot: " ", - fileChanges: {}, - }), - ); - }); - - it("keeps every file path in a large patch description", () => { - 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, 25); - assert.isTrue(detail?.startsWith("add /tmp/file-00.ts") ?? false); - assert.isTrue(detail?.endsWith("add /tmp/file-24.ts") ?? false); - }); - - it("uses item-start file changes when Codex omits the approval payload", () => { - assert.equal( - CodexAdapterV2.codexFileChangeApprovalPrompt({ - itemChanges: [ - { path: "/tmp/z.ts", kind: { type: "delete" } }, - { path: "/tmp/a.ts", kind: { type: "add" } }, - ], - }), - "add /tmp/a.ts\ndelete /tmp/z.ts", - ); - }); -}); - describe("CodexAdapterV2 context usage", () => { it("uses the current context rather than cumulative processed tokens", () => { const usage = CodexAdapterV2.codexProviderTurnTokenUsage( @@ -209,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>([]); @@ -294,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", () => { @@ -449,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; @@ -504,5210 +203,1975 @@ 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)), - ); + 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(["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 boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }); - 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)), + assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); + }), ); +}); - 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)), - ); +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("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)), - ); +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, + }; +} - 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 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", () => +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 }; + }); + + 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 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 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 numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({ - providerThread, - target: { - type: "provider_turn", - checkpointId: CheckpointId.make("checkpoint-first"), - appRunOrdinal: 1, - providerTurn: firstTurn, + 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)), }, - 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, - }); - - it.effect("resolves the selected provider turn to an inclusive native fork boundary", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); - const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); - - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: firstTurn.id, - targetThreadId: ThreadId.make("thread-codex-fork-target"), + const threadId = ThreadId.make(`thread-${transcript.scenario}`); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }); - - assert.deepEqual(boundary, { - lastTurnId: "native-turn-first", - rollbackTurnCount: 0, + const providerThread = yield* runtime.ensureThread({ + threadId, + 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 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), + }; + }); - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: secondTurn.id, - targetThreadId: ThreadId.make("thread-codex-fork-target"), - }); - - assert.deepEqual(boundary, { - lastTurnId: "native-turn-second", - rollbackTurnCount: 0, - }); - }), + it.effect.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, + "features.default_mode_request_user_input": 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"), + 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" }), + }, + }, + }, + ], }); - - 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"), + 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, - "features.default_mode_request_user_input": 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("disables native subagents for repository-managed Codex sessions", () => - Effect.gen(function* () { - const scenario = "managed-native-subagents"; - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("environment-managed-codex"), - threadId: ThreadId.make(`thread-${scenario}`), - providerSessionId: `provider-session-${scenario}`, - providerInstanceId: ProviderInstanceId.make("codex"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer test-token", - browserToolsAvailable: false, - managedIssue: true, - }); - const requests: Array = []; - const entries = codexReplayPreamble({ - nativeThreadId: "managed-codex-thread", - nativeTurnId: "unused", - prompt: "unused", - }).slice(0, 5); - entries[3] = { - type: "expect_outbound", - label: "thread/start", - frame: { - id: 2, - method: "thread/start", - params: { - config: { - ...CodexAdapterV2.CODEX_THREAD_CONFIG, - "features.multi_agent": false, - "features.multi_agent_v2": false, - }, - }, - }, - }; - yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario, - entries, - }), - () => 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, - "features.default_mode_request_user_input": true, - mcp_optional_startup_grace_ms: 10_000, - "features.multi_agent": false, - "features.multi_agent_v2": false, - "mcp_servers.t3-code": { - url: "http://127.0.0.1:43123/mcp", - tool_timeout_sec: 3_900, - http_headers: { Authorization: "Bearer test-token" }, - }, - }, - }, - ]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("replays historical and dotted MCP config while enforcing other thread config", () => - Effect.gen(function* () { - const recordedServer = { - url: "http://127.0.0.1:41000/mcp", - http_headers: { Authorization: "Bearer recorded-token" }, - }; - const currentConfig = { - "tools.update_plan.enabled": true, - "mcp_servers.t3-code": { - url: "http://127.0.0.1:42000/mcp", - http_headers: { Authorization: "Bearer current-token" }, - }, - "mcp_servers.extra.command": "cat", - }; - for (const recordedMcp of [ - { mcp_servers: { "t3-code": recordedServer } }, - { "mcp_servers.t3-code": recordedServer, "mcp_servers.extra.command": "echo" }, - ]) { - const entries = codexReplayPreamble({ - nativeThreadId: "replayed-mcp-thread", - nativeTurnId: "unused", - prompt: "unused", - }) - .slice(3, 5) - .map((entry) => withReplayRequestId(entry, 1)); - entries[0] = { - type: "expect_outbound", - frame: { - id: 1, - method: "thread/start", - params: { config: { "tools.update_plan.enabled": true, ...recordedMcp } }, - }, - }; - entries.push({ - ...entries[0], - frame: { - id: 2, - method: "thread/start", - params: { config: { "tools.update_plan.enabled": true, ...recordedMcp } }, - }, - }); - const driver = yield* CodexReplay.makeReplayDriver( - makeCodexReplayTranscript({ - scenario: "mcp-config-normalization", - entries, - }), - ); - yield* Effect.gen(function* () { - const client = yield* CodexClient.CodexAppServerClient; - const response = yield* client.request("thread/start", { config: currentConfig }); - assert.equal(response.thread.id, "replayed-mcp-thread"); - assert.isNull((yield* Ref.get(driver.state)).failure); - yield* client - .request("thread/start", { - config: { ...currentConfig, "tools.update_plan.enabled": false }, - }) - .pipe(Effect.flip); - assert.instanceOf( - (yield* Ref.get(driver.state)).failure, - CodexReplay.CodexAppServerReplayFrameMismatchError, - ); - }).pipe(Effect.provide(CodexReplay.layerReplayWithDriver(driver))); - } - }).pipe(Effect.scoped), - ); - - it.effect("identifies sessions to Codex with the same client info as main", () => - Effect.gen(function* () { - const transcript = makeCodexReplayTranscript({ - scenario: "initialize-client-info", - entries: codexReplayPreamble({ - nativeThreadId: "client-info-thread", - nativeTurnId: "unused", - prompt: "unused", - }).slice(0, 5), - }); - const initializeParams: Array = []; - yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method, params) => - Effect.sync(() => { - if (method === "initialize") initializeParams.push(params); - }), - ); - assert.deepEqual(initializeParams, [ - { - clientInfo: { name: "T3 Code", title: "T3 Code", version: packageJson.version }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: ["turn/diff/updated"], - extensions: { - "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, - }, - }, - }, - ]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("unsubscribes from the native thread when it is unloaded", () => - Effect.gen(function* () { - const nativeThreadId = "unload-thread"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused", - prompt: "unused", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "unload-thread", - entries: [ - ...preamble.slice(0, 5), - { - type: "expect_outbound", - label: "thread/unsubscribe", - frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } }, - }, - { - type: "emit_inbound", - label: "thread/unsubscribe", - frame: { id: 3, result: { status: "unsubscribed" } }, - }, - ], - }); - const requests: Array = []; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => Effect.sync(() => requests.push(method)), - ); - assert.isDefined(harness.runtime.unloadThread); - yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); - assert.deepEqual(requests, ["initialize", "thread/start", "thread/unsubscribe"]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("keeps the app-server failure as the cause when an unload is rejected", () => - Effect.gen(function* () { - const nativeThreadId = "unload-thread-rejected"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused", - prompt: "unused", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "unload-thread-rejected", - entries: [ - ...preamble.slice(0, 5), - { - type: "expect_outbound", - label: "thread/unsubscribe", - frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } }, - }, - { - type: "emit_inbound", - label: "thread/unsubscribe", - frame: { id: 3, error: { code: -32600, message: "invalid thread id" } }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const error = yield* harness.runtime.unloadThread!({ - providerThread: harness.providerThread, - }).pipe(Effect.flip); - assert.equal(error._tag, "ProviderAdapterProtocolError"); - const cause = error._tag === "ProviderAdapterProtocolError" ? error.cause : undefined; - assert.equal((cause as { _tag?: string } | undefined)?._tag, "CodexAppServerRequestError"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("waits for native start before interrupting an acknowledged queued turn", () => - Effect.gen(function* () { - const nativeThreadId = "early-stop-thread"; - const nativeTurnId = "early-stop-turn"; - const prompt = "Run a command."; - const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); - const transcript = makeCodexReplayTranscript({ - scenario: "early-stop-await-native-start", - entries: [ - ...preamble.slice(0, -2), - { - type: "emit_inbound", - label: "turn/start/queued", - frame: { - id: 3, - result: { - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - startedAt: null, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/started", - afterMs: 1000, - frame: { - method: "turn/started", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt", - frame: { - id: 4, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId }, - }, - }, - { type: "emit_inbound", label: "turn/interrupt", frame: { id: 4, result: {} } }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), - }, - }, - }, - ], - }); - let interruptSent = false; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - if (method === "turn/interrupt") interruptSent = true; - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("early-stop-attempt"), - text: prompt, - }), - ); - const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("500 millis"); - assert.isFalse(interruptSent, "Stop must not reach Codex before native turn/started"); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(interrupt); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("settles Stop when a queued native turn fails before starting", () => - Effect.gen(function* () { - const nativeThreadId = "early-stop-thread"; - const nativeTurnId = "early-stop-turn"; - const prompt = "Run a command."; - const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); - const transcript = makeCodexReplayTranscript({ - scenario: "early-stop-failed-before-native-start", - entries: [ - ...preamble.slice(0, -2), - { - type: "emit_inbound", - label: "turn/start/queued", - frame: { - id: 3, - result: { - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - startedAt: null, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/failed", - afterMs: 1000, - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - startedAt: null, - error: { - message: "Failed before native start", - codexErrorInfo: null, - additionalDetails: null, - }, - }, - }, - }, - }, - ], - }); - let interruptSent = false; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - if (method === "turn/interrupt") interruptSent = true; - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("early-stop-attempt"), - text: prompt, - }), - ); - const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("500 millis"); - assert.isFalse(interruptSent, "Stop must not reach Codex before native turn/started"); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(interrupt); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(interruptSent, "A terminal native turn must not receive turn/interrupt"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("bounds Stop when a queued native turn never starts", () => - Effect.gen(function* () { - const nativeThreadId = "early-stop-thread"; - const nativeTurnId = "early-stop-turn"; - const prompt = "Run a command."; - const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); - const transcript = makeCodexReplayTranscript({ - scenario: "early-stop-never-starts", - entries: [ - ...preamble.slice(0, -2), - { - type: "emit_inbound", - label: "turn/start/queued", - frame: { - id: 3, - result: { - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - startedAt: null, - }, - }, - }, - }, - ], - }); - let interruptSent = false; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - if (method === "turn/interrupt") interruptSent = true; - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("early-stop-attempt"), - text: prompt, - }), - ); - const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.exit, Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - assert.equal((yield* Fiber.join(interrupt))._tag, "Failure"); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(interruptSent, "An unstarted native turn must not receive turn/interrupt"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("sends currency-sigil skill mentions to Codex as $ mentions", () => - Effect.gen(function* () { - const nativeThreadId = "skill-sigil-thread"; - const nativeTurnId = "skill-sigil-turn"; - const transcript = makeCodexReplayTranscript({ - scenario: "skill-sigil-canonicalized", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "€review do it", - sentPrompt: "$review do it", - }), - { - type: "expect_outbound", - label: "turn/steer", - frame: { - id: 4, - method: "turn/steer", - params: { - expectedTurnId: nativeTurnId, - input: [{ type: "text", text: "then $ship it" }], - threadId: nativeThreadId, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/steer", - frame: { id: 4, result: { turnId: nativeTurnId } }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const turnInput = makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("skill-sigil-attempt"), - text: "€review do it", - }); - yield* harness.runtime.startTurn(turnInput); - yield* harness.runtime.steerTurn({ - threadId: harness.threadId, - runId: turnInput.runId, - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }), - message: { - ...turnInput.message, - messageId: MessageId.make("message-skill-sigil-steer"), - text: "then £ship it", - }, - }); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - const assistantMessages = (events: ReadonlyArray) => - events.filter( - ( - event, - ): event is Extract => - event.type === "message.updated" && event.message.role === "assistant", - ); - const assistantTurnItems = (events: ReadonlyArray) => - events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" - ? [event.turnItem] - : [], - ); - - it.effect("keeps an asynchronous Codex question actionable after the turn completes", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "async-question-thread"; - const nativeTurnId = "async-question-turn"; - const usage = { - totalTokens: 15, - inputTokens: 10, - cachedInputTokens: 2, - outputTokens: 5, - reasoningOutputTokens: 1, - }; - const transcript = makeCodexReplayTranscript({ - scenario: "async-question-and-billed-usage", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Continue while I decide.", - }), - { - type: "emit_inbound", - label: "question", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "agentMessage", - id: "async-question-item", - text: "Which branch?", - delivery: "async", - questions: [{ title: "Which branch?", options: ["main", "dev"] }], - }, - }, - }, - }, - { - type: "emit_inbound", - label: "usage", - frame: { - method: "thread/tokenUsage/updated", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, - }, - }, - }, - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("async-question-attempt"), - text: "Continue while I decide.", - }), - ); - yield* harness.firstTerminal; - const requests = harness.events.flatMap((event) => - event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], - ); - assert.lengthOf(requests, 1); - assert.equal(requests[0]?.status, "pending"); - assert.deepEqual(requests[0]?.responseCapability, { type: "message" }); - const questionItem = harness.events.find( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", - ); - assert.equal(questionItem?.type, "turn_item.updated"); - if ( - questionItem?.type === "turn_item.updated" && - questionItem.turnItem.type === "user_input_request" - ) { - assert.deepEqual( - questionItem.turnItem.questions[0]?.options.map((option) => option.label), - ["main", "dev"], - ); - assert.equal(questionItem.turnItem.responseMode, "message"); - } - const questionNode = harness.events.find( - (event) => event.type === "node.updated" && event.node.id === requests[0]?.nodeId, - ); - assert.equal( - questionNode?.type === "node.updated" && questionNode.node.countsForRun, - false, - ); - const contextReport = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, - ); - assert.equal(contextReport?.type, "provider_turn.updated"); - if (contextReport?.type === "provider_turn.updated") { - assert.equal( - contextReport.providerTurn.runAttemptId, - RunAttemptId.make("async-question-attempt"), - ); - assert.equal(contextReport.providerTurn.providerThreadId, harness.providerThread.id); - assert.equal(contextReport.providerTurn.tokenUsage?.usedTokens, 15); - assert.equal(contextReport.providerTurn.tokenUsage?.maxTokens, 200_000); - } - const completed = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "completed", - ); - assert.equal(completed?.type, "provider_turn.updated"); - if (completed?.type === "provider_turn.updated") { - assert.deepEqual(completed.providerTurn.turnTokenUsage, { - usageStatus: "complete", - usageScope: "main_agent", - hasSubagents: false, - inputTokens: 10, - cachedInputTokens: 2, - outputTokens: 5, - reasoningTokens: 1, - }); - } - assert.isEmpty(assistantMessages(harness.events)); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("stops asking for commands the user allowed for the session", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "session-approval-thread"; - const turnIds = [ - "session-approval-turn-1", - "session-approval-turn-2", - "session-approval-turn-3", - ]; - const turnPolicies = { - "approval-required": { - approvalPolicy: "untrusted", - approvalsReviewer: "user", - sandboxPolicy: { type: "readOnly" }, - }, - "full-access": { - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - }, - } as const; - const turnModes = ["approval-required", "full-access", "approval-required"] as const; - const turnText = (index: number) => `Review round ${index + 1}.`; - const startTurn = (index: number): ReadonlyArray => [ - { - type: "expect_outbound", - label: `turn ${index + 1} turn/start`, - frame: { - id: 3 + index, - method: "turn/start", - params: { - threadId: nativeThreadId, - input: [{ type: "text", text: turnText(index) }], - cwd: "/workspace", - model: "gpt-5.4", - ...turnPolicies[turnModes[index]!], - summary: "detailed", - }, - }, - }, - { - type: "emit_inbound", - label: `turn ${index + 1} turn/start`, - frame: { - id: 3 + index, - result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) }, - }, - }, - turnEvent("turn/started", turnIds[index]!), - ]; - const approval = (input: { - readonly id: number; - readonly turnId: string; - readonly kind: "commandExecution" | "fileChange"; - readonly itemId: string; - readonly decision: string; - }): ReadonlyArray => [ - { - type: "emit_inbound", - label: `${input.itemId} approval`, - frame: { - id: input.id, - method: `item/${input.kind}/requestApproval`, - params: { - threadId: nativeThreadId, - turnId: input.turnId, - itemId: input.itemId, - startedAtMs: 1782622441000, - ...(input.kind === "commandExecution" ? { command: `cat ${input.itemId}` } : {}), - }, - }, - }, - { - type: "expect_outbound", - label: `${input.itemId} decision`, - frame: { id: input.id, result: { decision: input.decision } }, - }, - ]; - const turnEvent = ( - method: "turn/started" | "turn/completed", - turnId: string, - ): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `${turnId} ${method}`, - frame: { - method, - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ - id: turnId, - status: method === "turn/started" ? "inProgress" : "completed", - }), - }, - }, - }); - const [patchRequest, patchDecision] = approval({ - id: 102, - turnId: turnIds[0]!, - kind: "fileChange", - itemId: "first-patch", - decision: "acceptForSession", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-session-approvals", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: turnIds[0]!, - prompt: turnText(0), - }).slice(0, -3), - ...startTurn(0), - ...approval({ - id: 100, - turnId: turnIds[0]!, - kind: "commandExecution", - itemId: "first-command", - decision: "acceptForSession", - }), - patchRequest!, - ...approval({ - id: 101, - turnId: turnIds[0]!, - kind: "commandExecution", - itemId: "second-command", - decision: "acceptForSession", - }), - turnEvent("turn/completed", turnIds[0]!), - ...startTurn(1), - patchDecision!, - turnEvent("turn/completed", turnIds[1]!), - ...startTurn(2), - ...approval({ - id: 103, - turnId: turnIds[2]!, - kind: "commandExecution", - itemId: "third-command", - decision: "accept", - }), - ...approval({ - id: 104, - turnId: turnIds[2]!, - kind: "fileChange", - itemId: "second-patch", - decision: "accept", - }), - turnEvent("turn/completed", turnIds[2]!), - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const requests = () => [ - ...new Map( - harness.events.flatMap((event) => - event.type === "runtime_request.updated" - ? [[event.runtimeRequest.id, event.runtimeRequest] as const] - : [], - ), - ).values(), - ]; - const answer = ( - count: number, - kind: "command" | "file-change", - decision: ProviderApprovalDecision, - ) => - Effect.gen(function* () { - yield* awaitUntil(() => requests().length === count, `approval request ${count}`); - const request = requests()[count - 1]!; - assert.equal(request.kind, kind); - yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); - }); - - const runTurn = (index: number) => - Effect.gen(function* () { - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`session-approvals-${index + 1}`), - text: turnText(index), - }), - runOrdinal: index + 1, - providerTurnOrdinal: index + 1, - runtimePolicy: { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode: turnModes[index]! }, - }); - }); - - yield* runTurn(0); - yield* answer(1, "command", "acceptForSession"); - yield* awaitUntil(() => requests().length === 2, "file change request"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn 1"); - yield* runTurn(1); - yield* answer(2, "file-change", "acceptForSession"); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "turn 2"); - yield* runTurn(2); - yield* answer(3, "command", "accept"); - yield* answer(4, "file-change", "accept"); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "turn 3"); - assert.lengthOf(requests(), 4); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("preserves T3 context on the wire and restores it after compaction", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "context-thread"; - const nativeTurnId = "context-turn"; - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId, - codexInput: [{ type: "text", text: "work" }], - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - modelSelection: CODEX_TEST_MODEL_SELECTION, - hasT3Mcp: true, - }); - assert.include( - params.additionalContext?.t3_code_orchestration_01?.value ?? "", - "delegate_task", - ); - const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); - const transcript = makeCodexReplayTranscript({ - scenario: "restore-context", - entries: [ - ...entries.slice(0, 5), - { - type: "expect_outbound", - label: "context turn", - frame: { id: 3, method: "turn/start", params }, - }, - ...entries.slice(6), - { - type: "emit_inbound", - label: "compacted", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { type: "contextCompaction", id: "compact-context" }, - }, - }, - }, - { - type: "expect_outbound", - label: "restore context", - frame: { - id: 4, - method: "thread/inject_items", - params: { - threadId: nativeThreadId, - items: Object.entries(params.additionalContext ?? {}).map(([key, entry]) => ({ - type: "message", - role: "developer", - content: [{ type: "input_text", text: `<${key}>${entry.value}` }], - })), - }, - }, - }, - { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, - { - type: "emit_inbound", - label: "done", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("test"), - threadId: harness.threadId, - providerSessionId: "context-session", - providerInstanceId: ProviderInstanceId.make("codex"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer test", - browserToolsAvailable: true, - }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("context-attempt"), - text: "work", - }), - ); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("compacts Codex with the native RPC and completes the compaction turn", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "compact-thread"; - const nativeTurnId = "compact-turn"; - const item = { type: "contextCompaction", id: "compact-item" }; - const transcript = makeCodexReplayTranscript({ - scenario: "native-compaction", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice(0, 5), - { - type: "expect_outbound", - label: "compact", - frame: { - id: 3, - method: "thread/compact/start", - params: { threadId: nativeThreadId }, - }, - }, - { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, - { - type: "emit_inbound", - label: "start", - frame: { - method: "turn/started", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - }, - }, - }, - ...(["item/started", "item/completed"] as const).map((method) => ({ - type: "emit_inbound" as const, - label: method, - frame: { method, params: { threadId: nativeThreadId, turnId: nativeTurnId, item } }, - })), - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - assert.isDefined(harness.runtime.compactThread); - yield* harness.runtime.compactThread!( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("compact-attempt"), - text: "/compact", - }), - ); - yield* harness.firstTerminal; - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "compaction" - ? [event.turnItem] - : [], - ); - assert.deepEqual( - items.map((entry) => entry.status), - ["running", "completed"], - ); - assert.equal(items[0]?.id, items[1]?.id); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each(["failed", "interrupted", "Stop"] as const)( - "settles unfinished compaction on %s without changing completed compaction", - (outcome) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "compact-thread"; - const nativeTurnId = "compact-turn"; - const error = { - message: "Remote compaction stream disconnected", - codexErrorInfo: "other", - additionalDetails: null, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `automatic-compaction-${outcome}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...( - [ - { method: "item/started", id: "completed-item", startedAtMs: 1782622441000 }, - { method: "item/completed", id: "completed-item", completedAtMs: 1782622442000 }, - { method: "item/started", id: "unfinished-item", startedAtMs: 1782622443000 }, - ] as const - ).map(({ method, id, ...timestamps }) => ({ - type: "emit_inbound" as const, - label: `${method}/${id}`, - frame: { - method, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { type: "contextCompaction", id }, - ...timestamps, - }, - }, - })), - ...(outcome === "Stop" - ? [ - { - type: "expect_outbound" as const, - label: "turn/interrupt", - frame: { - id: 4, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId }, - }, - }, - { - type: "emit_inbound" as const, - label: "turn/interrupt/failed", - frame: { id: 4, error: { code: -32_000, message: "Interrupt failed" } }, - }, - ] - : [ - { - type: "emit_inbound" as const, - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: outcome }), - error: outcome === "failed" ? error : null, - }, - }, - }, - }, - ]), - ], - }); - const compactionStarted = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === "unfinished-item" && - event.turnItem.status === "running" && - event.turnItem.providerTurnId !== null - ? Deferred.succeed(compactionStarted, event.turnItem.providerTurnId) - : Effect.void, - ); - yield* TestClock.setTime(1782622445000); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`compact-${outcome}-attempt`), - text: "Continue.", - }), - ); - if (outcome === "Stop") { - const providerTurnId = yield* Deferred.await(compactionStarted); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.exit); - assert.equal(interrupt._tag, "Failure"); - } - yield* harness.firstTerminal; - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "compaction" - ? [event.turnItem] - : [], - ); - const completed = items.filter( - (item) => item.nativeItemRef?.nativeId === "completed-item", - ); - const unfinished = items.filter( - (item) => item.nativeItemRef?.nativeId === "unfinished-item", - ); - const status = outcome === "failed" ? "failed" : "interrupted"; - assert.deepEqual( - unfinished.map((item) => item.status), - ["running", status], - ); - assert.equal(unfinished[0]?.id, unfinished[1]?.id); - assert.deepEqual(unfinished[1]?.startedAt, DateTime.makeUnsafe(1782622443000)); - assert.deepEqual( - unfinished[1]?.completedAt, - DateTime.makeUnsafe(outcome === "Stop" ? 1782622445000 : 1782622450000), - ); - assert.deepEqual( - completed.map((item) => item.status), - ["running", "completed"], - ); - assert.deepEqual(completed[1]?.startedAt, DateTime.makeUnsafe(1782622441000)); - assert.deepEqual(completed[1]?.completedAt, DateTime.makeUnsafe(1782622442000)); - assert.equal(harness.terminalEvents()[0]?.status, status); - if (outcome === "failed") { - assert.equal(harness.terminalEvents()[0]?.failure?.message, error.message); - } - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("resumes a provider thread without requesting or decoding its history", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-resume-metadata"; - const nativeThreadId = `native-${scenario}-thread`; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...preamble, - { - type: "expect_outbound", - label: "thread/resume", - frame: { - id: 3, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, - }, - }, - { - type: "emit_inbound", - label: "thread/resume", - frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const resumed = yield* harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - assert.equal(resumed.status, "idle"); - assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each( - ( - [ - ["recovers archived-session errors", "session saved-thread is archived", 0, ""], - [ - "recovers unarchive hints", - "Run `codex unarchive saved-thread` to unarchive it first.", - 0, - "", - ], - ["preserves missing-thread errors", "thread not found", 3, "thread not found"], - ["preserves missing-rollout errors", "no rollout found", 3, "no rollout found"], - ["preserves authentication errors", "authentication failed", 3, "authentication failed"], - [ - "preserves archived-workspace errors", - "workspace is archived", - 3, - "workspace is archived", - ], - [ - "preserves unrelated archive-path errors", - "permission denied reading archived_sessions/saved-thread", - 3, - "permission denied reading archived_sessions/saved-thread", - ], - [ - "propagates unarchive missing-thread errors", - "session saved-thread is archived", - 4, - "thread not found", - ], - [ - "propagates unarchive archived errors", - "session saved-thread is archived", - 4, - "session saved-thread is archived", - ], - [ - "propagates retry missing-thread errors", - "session saved-thread is archived", - 5, - "thread not found", - ], - [ - "does not retry archived errors twice", - "session saved-thread is archived", - 5, - "session saved-thread is archived", - ], - ] as const - ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), - )("$name", ({ name, resumeError, failAt, finalError }) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "saved-thread"; - const params = { - threadId: nativeThreadId, - excludeTurns: true, - cwd: CODEX_TEST_RUNTIME_POLICY.cwd, - model: CODEX_TEST_MODEL_SELECTION.model, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }; - const entries: Array = [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5), - { - type: "expect_outbound", - label: "resume archived thread", - frame: { id: 3, method: "thread/resume", params }, - }, - { - type: "emit_inbound", - label: "resume error", - frame: { id: 3, error: { code: -32600, message: resumeError } }, - }, - ]; - if (failAt !== 3) { - entries.push( - { - type: "expect_outbound", - label: "unarchive same thread", - frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, - }, - { - type: "emit_inbound", - label: "unarchive result", - frame: - failAt === 4 - ? { id: 4, error: { code: -32600, message: finalError } } - : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, - }, - ); - } - if (failAt === 0 || failAt === 5) { - entries.push( - { - type: "expect_outbound", - label: "retry identical resume", - frame: { id: 5, method: "thread/resume", params }, - }, - { - type: "emit_inbound", - label: "retry result", - frame: - failAt === 5 - ? { id: 5, error: { code: -32600, message: finalError } } - : { - id: 5, - result: { - thread: { - id: nativeThreadId, - updatedAt: 1782622450, - turns: [{ type: "unknown-history-item" }], - }, - }, - }, - }, - ); - } - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ scenario: name, entries }), - ); - const resume = harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - if (failAt !== 0) { - const error = yield* Effect.flip(resume); - assert.equal(error._tag, "ProviderAdapterResumeThreadError"); - assert.nestedPropertyVal(error, "cause.errorMessage", finalError); - assert.nestedPropertyVal( - error, - "cause.method", - failAt === 4 ? "thread/unarchive" : "thread/resume", - ); - assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); - return; - } - const resumed = yield* resume; - assert.equal(resumed.id, harness.providerThread.id); - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - assert.deepEqual( - resumed.nativeConversationHeadRef, - harness.providerThread.nativeConversationHeadRef, - ); - assert.equal(resumed.status, "idle"); - assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("continues an interrupted native thread with empty input and reasoning summaries", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-restart-promptless"; - const nativeThreadId = "native-restart-promptless"; - const nativeTurnId = "turn-restart-promptless"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "unused", - }).slice(0, 5); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...preamble, - { - type: "expect_outbound", - label: "resume", - frame: { - id: 3, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, - }, - }, - { - type: "emit_inbound", - label: "resume", - frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, - }, - { - type: "expect_outbound", - label: "continue", - frame: { - id: 4, - method: "turn/start", - params: { - threadId: nativeThreadId, - input: [], - cwd: "/workspace", - model: "gpt-5.4", - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - summary: "detailed", - }, - }, - }, - { - type: "emit_inbound", - label: "continue", - frame: { - id: 4, - result: { turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }) }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const resumed = yield* harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: resumed, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-restart-promptless"), - text: "Continue where you left off.", - }), - restartContinuationOfRunId: RunId.make("run-before-restart"), - }); - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("resolves retryable app-server errors on resumed provider activity", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-provider-api-retry"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Open github.com.", - }), - { - type: "emit_inbound", - label: "error/retry", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: true, - error: { - message: "Reconnecting... 2/5", - additionalDetails: "The response stream disconnected.", - codexErrorInfo: { - responseStreamDisconnected: { httpStatusCode: 529 }, - }, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "item/started/after-retry", - frame: { - method: "item/started", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622445000, - item: { - type: "commandExecution", - id: "command-after-provider-retry", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "inProgress", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/after-retry", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622445010, - item: { - type: "commandExecution", - id: "command-after-provider-retry", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: "/workspace\n", - exitCode: 0, - durationMs: 10, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), - text: "Open github.com.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "Codex retry recovery"); - - const retryItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.retry !== undefined - ? [event.turnItem] - : [], - ); - assert.lengthOf(retryItems, 2); - assert.equal(retryItems[0]?.status, "running"); - assert.equal(retryItems[0]?.failure.code, "responseStreamDisconnected"); - assert.deepEqual(retryItems[0]?.retry, { - attempt: 2, - maxAttempts: 5, - retryDelayMs: null, - }); - assert.equal(retryItems[1]?.id, retryItems[0]?.id); - assert.equal(retryItems[1]?.status, "completed"); - assert.equal(retryItems[1]?.title, "Provider recovered"); - - const recoveredIndex = harness.events.findIndex( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.status === "completed", - ); - const resumedCommandIndex = harness.events.findIndex( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.input === "pwd", - ); - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - assert.isAtLeast(recoveredIndex, 0); - assert.isAbove(resumedCommandIndex, recoveredIndex); - assert.isAbove(terminalIndex, resumedCommandIndex); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("stamps Codex items with their own start time, not the turn's", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-item-start-times"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const commandLifecycle = (id: string, startedAtMs: number) => - (["started", "completed"] as const).map((phase) => ({ - type: "emit_inbound" as const, - label: `item/${phase}/${id}`, - frame: { - method: `item/${phase}`, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - ...(phase === "started" ? { startedAtMs } : { completedAtMs: startedAtMs + 10 }), - item: { - type: "commandExecution", - id, - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: phase === "started" ? "inProgress" : "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: phase === "started" ? null : "/workspace\n", - exitCode: phase === "started" ? null : 0, - durationMs: phase === "started" ? null : 10, - }, - }, - }, - })); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Run two commands." }), - ...commandLifecycle("first-command", 1782622445000), - ...commandLifecycle("second-command", 1782622505000), - ...(["started", "completed"] as const).map((phase) => ({ - type: "emit_inbound" as const, - label: `item/${phase}/compaction`, - frame: { - method: `item/${phase}`, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - ...(phase === "started" - ? { startedAtMs: 1782622565000 } - : { completedAtMs: 1782622575000 }), - item: { type: "contextCompaction", id: "compaction" }, - }, - }, - })), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-item-start-times"), - text: "Run two commands.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "Codex item start times"); - - const startedAtByItem = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - (event.turnItem.type === "command_execution" || event.turnItem.type === "compaction") - ? [[event.turnItem.nativeItemRef?.nativeId, event.turnItem.startedAt] as const] - : [], - ); - assert.deepEqual( - startedAtByItem.map(([id, startedAt]) => [id, startedAt?.epochMilliseconds]), - [ - ["first-command", 1782622445000], - ["first-command", 1782622445000], - ["second-command", 1782622505000], - ["second-command", 1782622505000], - ["compaction", 1782622565000], - ["compaction", 1782622565000], - ], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each(["completed", "interrupted"] as const)( - "retains Codex reasoning parts when the turn is %s", - (terminalStatus) => - Effect.scoped( - Effect.gen(function* () { - const scenario = `codex-reasoning-${terminalStatus}`; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const prompt = "Explain the check."; - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...[ - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 0, delta: "Summary " }, - }, - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 0, delta: "one" }, - }, - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 1, delta: "Summary two" }, - }, - { - method: "item/reasoning/textDelta", - params: { itemId: "thought", contentIndex: 0, delta: "Raw trace" }, - }, - { - method: "item/completed", - params: { - item: { - type: "commandExecution", - id: "after-thought", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: "/workspace", - exitCode: 0, - durationMs: 1, - }, - }, - }, - ...(terminalStatus === "completed" - ? [ - { - method: "item/completed", - params: { - item: { - type: "reasoning", - id: "thought", - summary: ["Final summary one", "Summary two"], - content: ["Raw trace"], - }, - }, - }, - { - method: "item/completed", - params: { - item: { - type: "reasoning", - id: "completion-only", - summary: ["Completion without deltas"], - content: [], - }, - }, - }, - { - method: "item/reasoning/textDelta", - params: { - itemId: "delta-only", - contentIndex: 0, - delta: "Retained when completion omits content", - }, - }, - { - method: "item/completed", - params: { - item: { type: "reasoning", id: "delta-only", summary: [], content: [] }, - }, - }, - ] - : []), - ].map((event, index) => ({ - type: "emit_inbound" as const, - label: `reasoning-${index}`, - frame: { - method: event.method, - params: { threadId: nativeThreadId, turnId: nativeTurnId, ...event.params }, - }, - })), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: terminalStatus }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-${scenario}`), - text: prompt, - }), - ); - yield* harness.firstTerminal; - const latest = new Map( - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "reasoning" - ? [[event.turnItem.id, event.turnItem] as const] - : [], - ), - ); - assert.deepEqual( - [...latest.values()].map((item) => item.text), - terminalStatus === "completed" - ? [ - "Final summary one", - "Summary two", - "Raw trace", - "Completion without deltas", - "Retained when completion omits content", - ] - : ["Summary one", "Summary two", "Raw trace"], - ); - assert.isTrue([...latest.values()].every((item) => item.status === terminalStatus)); - assert.isTrue([...latest.values()].every((item) => item.streaming === false)); - assert.isTrue( - [...latest.values()].every( - (item) => item.runId !== null && item.providerTurnId !== null, - ), - ); - assert.equal(new Set([...latest.values()].map((item) => item.ordinal)).size, latest.size); - const command = harness.events.find( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ); - assert.isDefined(command); - if (command?.type === "turn_item.updated") { - assert.isTrue( - [...latest.values()] - .slice(0, 3) - .every((item) => item.ordinal < command.turnItem.ordinal), - ); - } - assert.deepEqual(assistantMessages(harness.events), []); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("streams a proposed plan a few times a second instead of once per delta", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-plan-delta-coalescing"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const prompt = "Plan the change."; - const deltas = Array.from({ length: 100 }, (_, index) => `step ${index}\n`); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...deltas.map((delta, index): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `item/plan/delta/${index}`, - afterMs: 20, - frame: { - method: "item/plan/delta", - params: { threadId: nativeThreadId, turnId: nativeTurnId, itemId: "plan", delta }, - }, - })), - { - type: "emit_inbound", - label: "item/completed/plan", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { type: "plan", id: "plan", text: "" }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-${scenario}`), - text: prompt, - }), - ); - for (let step = 0; step < 200 && harness.terminalEvents().length === 0; step += 1) { - yield* Effect.yieldNow; - yield* TestClock.adjust("20 millis"); - } - yield* harness.firstTerminal; - - const plans = harness.events.flatMap((event) => - event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], - ); - const planItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" - ? [event.turnItem] - : [], - ); - assert.isAtLeast(plans.length, 2); - assert.isAtMost(plans.length, 7); - assert.equal(planItems.length, plans.length); - assert.equal(plans.at(-1)?.markdown, deltas.join("")); - assert.deepEqual( - planItems.map((item) => item.streaming), - [...Array.from({ length: planItems.length - 1 }, () => true), false], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - const finalAnswerTranscript = ( - scenario: string, - answers: ReadonlyArray<{ - readonly id: string; - readonly text: string; - readonly phase?: "commentary" | "final_answer" | null; - readonly omitPhase?: boolean; - readonly streamed?: boolean; - readonly completionDelayMs?: number; - }>, - ) => { - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const prompt = "Reply with the requested recovery marker."; - return makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...answers.flatMap( - (answer, index): ReadonlyArray => { - const phase = answer.omitPhase - ? {} - : { phase: answer.phase === undefined ? ("final_answer" as const) : answer.phase }; - const completed: CodexReplay.CodexAppServerReplayEntry = { - type: "emit_inbound", - label: `item/completed/${answer.id}`, - ...(answer.completionDelayMs === undefined - ? {} - : { afterMs: answer.completionDelayMs }), - frame: { - method: "item/completed", - params: { - item: { - type: "agentMessage", - id: answer.id, - text: answer.text, - ...phase, - memoryCitation: null, - }, - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622441000 + index, - }, - }, - }; - if (!answer.streamed) { - return [completed]; - } - return [ - { - type: "emit_inbound", - label: `item/started/${answer.id}`, - frame: { - method: "item/started", - params: { - item: { - type: "agentMessage", - id: answer.id, - text: "", - ...phase, - memoryCitation: null, - }, - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622440500 + index, - }, - }, - }, - { - type: "emit_inbound", - label: `item/agentMessage/delta/${answer.id}`, - frame: { - method: "item/agentMessage/delta", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId: answer.id, - delta: answer.text, - }, - }, - }, - completed, - ]; - }, - ), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - }; - - it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-redundant-empty-final", [ - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("streams text on the turn item and sends the message once, when it completes", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-streamed-message-once", [ - { id: "answer", text: "CODEX_RECOVERY_OK", streamed: true, completionDelayMs: 100 }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-streamed-message-once"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* awaitUntil( - () => assistantTurnItems(harness.events).length === 1, - "streamed turn item", - ); - assert.deepEqual( - assistantTurnItems(harness.events).map(({ text, streaming }) => ({ text, streaming })), - [{ text: "CODEX_RECOVERY_OK", streaming: true }], - ); - assert.deepEqual(assistantMessages(harness.events), []); - - yield* TestClock.adjust("50 millis"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.deepEqual( - assistantMessages(harness.events).map(({ message }) => ({ - text: message.text, - streaming: message.streaming, - })), - [{ text: "CODEX_RECOVERY_OK", streaming: false }], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses a later streamed duplicate final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-streamed-duplicate-final", [ - { id: "answer-original", text: "CODEX_RECOVERY_OK" }, - { - id: "answer-duplicate", - text: "CODEX_RECOVERY_OK", - streamed: true, - completionDelayMs: 100, - }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-streamed-duplicate-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => assistantMessages(harness.events).length === 1, "original answer"); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - - yield* TestClock.adjust("50 millis"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("buffers an overlapping later final stream until duplicate detection", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-overlapping-duplicate-final"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const answerItem = (id: string, text: string) => ({ - type: "agentMessage" as const, - id, - text, - phase: "final_answer" as const, - memoryCitation: null, - }); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Reply with the requested recovery marker.", - }), - ...["answer-overlap-original", "answer-overlap-duplicate"].flatMap( - (itemId, index): ReadonlyArray => [ - { - type: "emit_inbound", - label: `item/started/${itemId}`, - frame: { - method: "item/started", - params: { - item: answerItem(itemId, ""), - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622440500 + index, - }, - }, - }, - { - type: "emit_inbound", - label: `item/agentMessage/delta/${itemId}`, - frame: { - method: "item/agentMessage/delta", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId, - delta: "CODEX_RECOVERY_OK", - }, - }, - }, - ], - ), - { - type: "emit_inbound", - label: "item/completed/answer-overlap-original", - afterMs: 100, - frame: { - method: "item/completed", - params: { - item: answerItem("answer-overlap-original", "CODEX_RECOVERY_OK"), - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622441000, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/answer-overlap-duplicate", - frame: { - method: "item/completed", - params: { - item: answerItem("answer-overlap-duplicate", "CODEX_RECOVERY_OK"), - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622441001, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-overlapping-duplicate-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - - assert.equal(new Set(assistantTurnItems(harness.events).map((item) => item.id)).size, 1); - - yield* TestClock.adjust("50 millis"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.equal( - new Set(assistantMessages(harness.events).map((event) => event.message.id)).size, - 1, - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("preserves a sole empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-sole-empty-final", [ - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-sole-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - [""], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses a second empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-duplicate-empty-final", [ - { id: "answer-empty-original", text: "" }, - { id: "answer-empty-duplicate", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-duplicate-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - [""], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("preserves an empty final answer when only commentary preceded it", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-commentary-then-empty-final", [ - { id: "answer-commentary", text: "Working on it.", phase: "commentary" }, - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-commentary-then-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["Working on it.", ""], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses an empty final answer after a non-empty unknown-phase answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-unknown-non-empty-then-empty-final", [ - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK", phase: null }, - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-unknown-non-empty-then-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses a trailing empty answer with an omitted phase", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-final-then-empty-unknown", [ - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, - { id: "answer-empty", text: "", omitPhase: true }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-final-then-empty-unknown"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("keeps a later non-empty final answer after an initial empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-empty-then-non-empty-final", [ - { id: "answer-empty", text: "" }, - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-empty-then-non-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["", "CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - const BG_SCENARIO = "codex-bg-exec-wake"; - const BG_NATIVE_THREAD = "native-codex-bg-thread"; - const BG_NATIVE_TURN = "native-codex-bg-turn"; - const BG_COMMAND_ITEM = "call-codex-bg-command"; - const BG_COMMAND = "sleep 20 && echo CODEX_BG_WAKE_DONE"; - const BG_PROMPT = "Start the sleep in the background and reply STARTED."; - - const backgroundCommandItem = (status: "inProgress" | "completed"): Record => ({ - type: "commandExecution", - id: BG_COMMAND_ITEM, - command: BG_COMMAND, - cwd: "/workspace", - processId: "4242", - source: "unifiedExecStartup", - status, - commandActions: [{ type: "unknown", command: BG_COMMAND }], - aggregatedOutput: status === "completed" ? "CODEX_BG_WAKE_DONE\n" : null, - exitCode: status === "completed" ? 0 : null, - durationMs: status === "completed" ? 25_000 : null, - }); - - const backgroundExecTranscript = makeCodexReplayTranscript({ - scenario: BG_SCENARIO, - entries: [ - ...codexReplayPreamble({ - nativeThreadId: BG_NATIVE_THREAD, - nativeTurnId: BG_NATIVE_TURN, - prompt: BG_PROMPT, - }), - { - type: "emit_inbound", - label: "item/started/command", - frame: { - method: "item/started", - params: { - item: backgroundCommandItem("inProgress"), - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - startedAtMs: 1782622440500, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/root-answer", - frame: { - method: "item/completed", - params: { - item: { - type: "agentMessage", - id: "root-answer-bg", - text: "STARTED", - phase: "final_answer", - memoryCitation: null, - }, - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - completedAtMs: 1782622441000, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: BG_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: BG_NATIVE_TURN, status: "completed" }), - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/command-late", - afterMs: 30_000, - frame: { - method: "item/completed", - params: { - item: backgroundCommandItem("completed"), - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - completedAtMs: 1782622465500, - }, - }, - }, - ], - }); - - it.effect( - "projects a post-settle background command completion and requests a continuation", - () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(backgroundExecTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-wake"), - text: BG_PROMPT, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.isTrue( - yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), - ); - assert.lengthOf(harness.continuationRequests, 0); - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - - yield* TestClock.adjust("30 seconds"); - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation request", - ); - const request = harness.continuationRequests[0]; - assert.equal(request?.threadId, harness.threadId); - assert.equal(request?.providerThreadId, harness.providerThread.id); - assert.equal(request?.driver, CodexAdapterV2.CODEX_DRIVER_KIND); - assert.deepEqual(request?.notification, { - source: { kind: "command" }, - outcome: "completed", - summary: `Command "${BG_COMMAND}" finished (exit 0)`, - detail: BG_COMMAND, - }); - assert.equal( - request?.detail, - `Background command completed (exit 0): ${BG_COMMAND}\n\n` + - "Output tail:\nCODEX_BG_WAKE_DONE", - ); - - const lateCommandUpdateIndex = () => - harness.events.findIndex( - (event, index) => - index > terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "completed" && - event.turnItem.output === "CODEX_BG_WAKE_DONE\n" && - event.turnItem.exitCode === 0, - ); - yield* awaitUntil( - () => lateCommandUpdateIndex() > terminalIndex, - "post-settle command projection", - ); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([false, true])( - "keeps commands interrupted after native thread closure (persistent tool: %s)", - (retainContext) => + it.effect("keeps an asynchronous Codex question actionable after the turn completes", () => + Effect.scoped( 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 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: `codex-background-thread-closed-${retainContext}`, + scenario: "async-question-and-billed-usage", 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")] : []), - ]), + ...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 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* 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("async-question-attempt"), + text: "Continue while I decide.", }), ); 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, - }, + const requests = harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.isFalse( - yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), + 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.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" }, + 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: "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" }, - }, + "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" as const, - frame: { id: 8, result: { terminated: true } }, + }, + }, + { + 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}` } : {}), }, - ] - : []), - backgroundExecTranscript.entries.at(-1)!, - ], - }); - }; - - it.effect.each(backgroundStopCases)( - "stops a command after root completion when termination returns %s", - (terminated) => { - const stillRunning = terminated === "still_running"; - const transcript = makeBackgroundStopTranscript(terminated); - return Effect.scoped( - Effect.gen(function* () { - const stopped = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted" - ? Deferred.succeed(stopped, undefined) - : Effect.void, - ); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-stop"), - text: BG_PROMPT, + }, + }, + { + 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", }), - ); - 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"), + ...approval({ + id: 104, + turnId: turnIds[2]!, + kind: "fileChange", + itemId: "second-patch", + decision: "accept", }), - ); - if (stillRunning) { - const failed = yield* harness.runtime - .interruptTurn({ + turnEvent("turn/completed", turnIds[2]!), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const requests = () => [ + ...new Map( + harness.events.flatMap((event) => + event.type === "runtime_request.updated" + ? [[event.runtimeRequest.id, event.runtimeRequest] as const] + : [], + ), + ).values(), + ]; + const answer = ( + count: number, + kind: "command" | "file-change", + decision: ProviderApprovalDecision, + ) => + Effect.gen(function* () { + yield* harness.until(() => requests().length === count); + const request = requests()[count - 1]!; + assert.equal(request.kind, kind); + yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); + }); + + const runTurn = (index: number) => + Effect.gen(function* () { + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, providerThread: harness.providerThread, - 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, + 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* 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), - ), + + 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("stops a completed run's background command without a client-supplied run", () => { - const transcript = makeBackgroundStopTranscript(true); - return Effect.scoped( + + it.effect("preserves T3 context on the wire and restores it after compaction", () => + 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, + 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", ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:background-stop"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-background-stop"), - threadId, - projectId: ProjectId.make("project:background-stop"), - title: "Background stop", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const waiting = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "waiting", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-background-stop"), - threadId, - messageId: MessageId.make("message:background-stop"), - text: BG_PROMPT, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - assert.isNull((yield* Ref.get(replayDriver.state)).failure); - yield* Fiber.join(waiting); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - const run = projection.runs.at(-1)!; - assert.equal(run.status, "completed"); - assert.equal( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, - 1, - ); - const stopped = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "turn-item.updated" && - event.payload.type === "command_execution" && - event.payload.status === "interrupted", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "thread.stop", - commandId: CommandId.make("stop-background-command"), - threadId, - }); - yield* worker.drain(); - yield* Fiber.join(stopped); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "completed", - ); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], - ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ - transcript: localTranscript, - driver: replayDriver, - }), - { runEffectWorker: false }, - ), - ), + 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", + }), ); - }).pipe(Effect.provide(NodeServices.layer)), - ); - }); + yield* harness.firstTerminal; + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); - it.effect("Stop ends a background command no Codex process tracks any more", () => + it.effect("compacts Codex with the native RPC and completes the compaction turn", () => 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", + const nativeThreadId = "compact-thread"; + const nativeTurnId = "compact-turn"; + const item = { type: "contextCompaction", id: "compact-item" }; + const transcript = makeCodexReplayTranscript({ + scenario: "native-compaction", entries: [ - ...backgroundExecTranscript.entries.slice(0, -1), - { type: "runtime_exit", status: "success" }, + ...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 localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson(staleTranscript)).replaceAll( - yield* encodeStringJson("/workspace"), - yield* encodeStringJson(cwd), - ), + 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", + }), ); - 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", + 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 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 }), + 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* 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); + 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( - before?.pendingBackgroundTasks?.map((task) => task.kind), - ["command"], - "the thread still shows the command the gone process never finished", + unfinished.map((item) => item.status), + ["running", status], ); - 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.equal(unfinished[0]?.id, unfinished[1]?.id); + assert.deepEqual(unfinished[1]?.startedAt, DateTime.makeUnsafe(1782622443000)); assert.deepEqual( - projection.turnItems.flatMap((item) => - item.type === "command_execution" ? [item.status] : [], - ), - ["interrupted"], + unfinished[1]?.completedAt, + DateTime.makeUnsafe(outcome === "Stop" ? 1782622445000 : 1782622450000), ); - assert.equal(projection.runs.at(-1)?.status, "completed"); assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], + 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( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ transcript: localTranscript }), - { runEffectWorker: false }, - ), + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ); - }).pipe(Effect.provide(NodeServices.layer)), - ), + ), + ), ); - const PRE_SETTLE_SCENARIO = "codex-bg-exec-pre-settle"; - const PRE_SETTLE_NATIVE_THREAD = "native-codex-pre-settle-thread"; - const PRE_SETTLE_NATIVE_TURN = "native-codex-pre-settle-turn"; + 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), + ), + ), + ), + ); - 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, + // 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: "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: "resume error", + frame: { id: 3, error: { code: -32600, message: resumeError } }, }, - }, - }, - { - 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, + ]; + 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 }, }, - threadId: PRE_SETTLE_NATIVE_THREAD, - turnId: PRE_SETTLE_NATIVE_TURN, - completedAtMs: 1782622441500, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: PRE_SETTLE_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: PRE_SETTLE_NATIVE_TURN, status: "completed" }), - }, - }, - }, - ], - }); - - it.effect("does not request a continuation for a command that completes before settle", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(preSettleTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-pre-settle"), - text: BG_PROMPT, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "completed" && - event.turnItem.exitCode === 0, - ), - "pre-settle command projection", + { + 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 }), ); - - yield* TestClock.adjust("30 seconds"); - for (let attempt = 0; attempt < 100; attempt++) { - yield* Effect.yieldNow; + 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; } - assert.lengthOf(harness.continuationRequests, 0); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.terminalEvents(), 1); + 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), @@ -5716,467 +2180,434 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - 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 }], + 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" }), + }, + }, }, - 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, - }, - }, - }, - ], - }); - - it.effect("contains commands that start before and after the interrupt response", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); const now = yield* DateTime.now; - yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-mid-command"), - text: INTERRUPT_PROMPT, + attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), + text: "Open github.com.", }), ); + yield* harness.awaitTerminal(1); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", + 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.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] + : [], ), - "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.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.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", - ); - 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), + assert.deepEqual(assistantMessages(harness.events), []); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), ), - ), ); - const interruptSubagentCommandTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-subagent-command", - entries: [ - ...codexReplayPreamble({ - nativeThreadId: INTERRUPT_NATIVE_THREAD, - nativeTurnId: INTERRUPT_NATIVE_TURN, - prompt: INTERRUPT_PROMPT, - }), - { - type: "emit_inbound", - label: "item/completed/subAgentActivity-started", - frame: { - method: "item/completed", - params: { - item: { - type: "subAgentActivity", - id: "call-codex-interrupt-subagent", - kind: "started", - agentThreadId: INTERRUPT_CHILD_NATIVE_THREAD, - agentPath: "/root/stop_hold", - }, - 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", - }), + 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: "item/started/child-command", - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_CHILD_COMMAND_ITEM, - processId: "57682", + ), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), }, - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_CHILD_NATIVE_TURN, - startedAtMs: 1782622441500, - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/root", - frame: { - id: 4, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/root", - frame: { id: 4, result: {} }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/child", - frame: { - id: 5, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_CHILD_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/child", - frame: { id: 5, result: {} }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/child", - frame: { - id: 6, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_CHILD_NATIVE_THREAD, processId: "57682" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/child", - frame: { id: 6, result: { terminated: true } }, - }, - { - type: "emit_inbound", - label: "turn/completed/root", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed/child-completed-race", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_CHILD_NATIVE_TURN, - status: "completed", - }), }, }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - const assertChildProviderTerminalBeforeRoot = ( - events: ReadonlyArray, - rootThreadId: ThreadId, - ) => { - const terminalIndex = events.findIndex((event) => event.type === "turn.terminal"); - const childProviderTurnIndex = events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.threadId !== rootThreadId && - event.providerTurn.status === "interrupted", - ); - const childProviderThreadIndex = events.findIndex( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.appThreadId !== rootThreadId && - event.providerThread.status === "idle", - ); - assert.isAtLeast(childProviderTurnIndex, 0, "child provider turn must terminalize"); - assert.isAtLeast(childProviderThreadIndex, 0, "child provider thread must become idle"); - assert.isAbove( - terminalIndex, - childProviderTurnIndex, - "child provider turn must terminalize before the root run", - ); - assert.isAbove( - terminalIndex, - childProviderThreadIndex, - "child provider thread must become idle before the root run", - ); + ], + }); }; - it.effect("contains descendant commands and keeps Stop authoritative", () => + it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentCommandTranscript); + 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( @@ -6184,53 +2615,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-command"), - text: INTERRUPT_PROMPT, + attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), + text: "Reply with the requested recovery marker.", }), ); - 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* harness.awaitTerminal(1); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - const childCommandUpdates = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "turn_item.updated" } - > => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM, + assert.deepEqual( + assistantMessages(harness.events).map((event) => event.message.text), + ["CODEX_RECOVERY_OK"], ); - 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), @@ -6239,554 +2633,653 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - 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", + 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: [ - ...interruptSubagentCommandTranscript.entries.slice(0, rootInterruptResponseIndex + 1), + ...codexReplayPreamble({ + nativeThreadId: BG_NATIVE_THREAD, + nativeTurnId: BG_NATIVE_TURN, + prompt: BG_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/command", + frame: { + method: "item/started", + params: { + item: backgroundCommandItem("inProgress"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/root-answer", + frame: { + method: "item/completed", + params: { + item: { + type: "agentMessage", + id: "root-answer-bg", + text: "STARTED", + phase: "final_answer", + memoryCitation: null, + }, + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + }, { type: "emit_inbound", - label: "turn/completed/root-before-child-interrupt-failure", + 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" }), }, }, }, - ...interruptSubagentCommandTranscript.entries.slice( - rootInterruptResponseIndex + 1, - childInterruptResponseIndex, - ), { type: "emit_inbound", - label: "turn/interrupt/child", + label: "item/completed/command-late", + afterMs: 30_000, frame: { - id: 5, - error: { code: -32_000, message: "child interrupt request failed" }, + method: "item/completed", + params: { + item: backgroundCommandItem("completed"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622465500, + }, }, }, - { type: "runtime_exit", status: "success" }, ], }); - it.effect("terminalizes descendants before the root when an interrupt request fails", () => - Effect.scoped( + it.effect.each([false, true])( + "keeps commands interrupted after native thread closure (persistent tool: %s)", + (retainContext) => Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentRequestFailureTranscript); - const now = yield* DateTime.now; - + 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, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-request-failure"), - text: INTERRUPT_PROMPT, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-codex-thread-closed"), + text: BG_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", + 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] + : [], ), - "running child command item", + roots: harness.terminalEvents().map((event) => event.status), + continuations: harness.continuationRequests.length, + }, + { + items: ["running", "interrupted"], + nodes: ["running", "interrupted"], + roots: ["completed"], + continuations: 0, + }, ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptExit = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.exit); - - assert.equal(interruptExit._tag, "Failure"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.isFalse( + yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), + ); }).pipe( + Effect.scoped, 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, - }, - }, + 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: "turn/completed/child", - afterMs: 30_000, - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_CHILD_NATIVE_TURN, status: "completed" }), - }, + { + type: "emit_inbound", + label: "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 } }, }, - }, - { 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", - ); + ...(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)!, + ], + }); + }; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, + it.effect.each(backgroundStopCases)( + "stops a command after root completion when termination returns %s", + (terminated) => { + const stillRunning = terminated === "still_running"; + const transcript = makeBackgroundStopTranscript(terminated); + return Effect.scoped( + Effect.gen(function* () { + const stopped = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "interrupted" + ? Deferred.succeed(stopped, undefined) + : Effect.void, + ); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-bg-stop"), + text: BG_PROMPT, + }), + ); + yield* harness.firstTerminal; + const terminal = harness.terminalEvents()[0]!; + assert.equal(terminal.status, "completed"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + assert.isFalse( + yield* harness.runtime.hasPendingBackgroundWorkForThread!({ + ...harness.providerThread, + id: ProviderThreadId.make("unrelated-provider-thread"), + }), + ); + if (stillRunning) { + const failed = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: terminal.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.exit); + assert.equal(failed._tag, "Failure"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + } + yield* harness.runtime.interruptTurn({ providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-failed-root-live-subagent"), - text: INTERRUPT_PROMPT, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed root terminal"); - - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - assert.isTrue(terminal?.status === "failed" && terminal.childTurnsContinue === true); - assert.isFalse(childTurnCompleted(), "the subagent is still running"); - - yield* TestClock.adjust("30 seconds"); - yield* awaitUntil(childTurnCompleted, "subagent turn completion"); - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + 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 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, - }, - }, - }, - ], - }); - - it.effect("terminalizes timed-out descendants before the root", () => - 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(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-stop-workspace-" }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson(transcript)).replaceAll( + yield* encodeStringJson("/workspace"), + yield* encodeStringJson(cwd), + ), ); - 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 replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + assert.equal( + Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), + 0, ); - 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", + 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 }, + ), ), - "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), - ), - ), - ), - ); - - const rootCompletionIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "turn/completed/root", - ); - const interruptLateSubagentTurnTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-late-subagent-turn", - entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, rootCompletionIndex), - { - type: "emit_inbound", - label: "turn/started/late-child", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_NATIVE_TURN, - status: "inProgress", - }), - }, - }, - }, - ...interruptSubagentCommandTranscript.entries.slice(rootCompletionIndex, -1), - { - type: "expect_outbound", - label: "turn/interrupt/late-child", - frame: { - id: 7, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/late-child", - frame: { id: 7, result: {} }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - it.effect("interrupts descendants that start after the initial Stop snapshot", () => - 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, - }), + ), + 0, ); - 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", + 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", ), - "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( + 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 === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + event.type === "turn-item.updated" && + event.payload.type === "command_execution" && + event.payload.status === "interrupted", ), - "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, + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-background-command"), + threadId, + }); + yield* worker.drain(); + yield* Fiber.join(stopped); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "completed", + ); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, + [], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, + CodexAdapterV2Testkit.layer({ + transcript: localTranscript, + driver: replayDriver, + }), + { runEffectWorker: false }, + ), + ), ); - 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", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_NATIVE_TURN, - status: "inProgress", - }), - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/late-child-1", - frame: { - id: 7, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/started/late-child-2", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, - status: "inProgress", - }), - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/late-child-1", - frame: { id: 7, result: {} }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/late-child-2", - frame: { - id: 8, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/late-child-2", - frame: { id: 8, result: {} }, - }, - { type: "runtime_exit", status: "success" }, - ], + }).pipe(Effect.provide(NodeServices.layer)), + ); }); - it.effect("interrupts descendants discovered only by the final interrupt rescan", () => + it.effect("Stop ends a background command no Codex process tracks any more", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptRescanLateSubagentTurnTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-rescan-late-subagent-turn"), - text: INTERRUPT_PROMPT, - }), + 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 === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + 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] : [], ), - "late child 1 provider turn", - ); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); - - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - - const rootTerminalIndex = harness.events.findIndex( - (event) => event.type === "turn.terminal", - ); - const lateChild1InterruptedIndex = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN && - event.providerTurn.status === "interrupted", - ); - const lateChild2InterruptedIndex = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_2_NATIVE_TURN && - event.providerTurn.status === "interrupted", - ); - assert.isAtLeast( - lateChild1InterruptedIndex, - 0, - "late child 1 must terminalize interrupted", - ); - assert.isAtLeast( - lateChild2InterruptedIndex, - 0, - "late child 2 must terminalize interrupted", - ); - assert.isAbove( - rootTerminalIndex, - lateChild1InterruptedIndex, - "late child 1 must terminalize before the root run", - ); - assert.isAbove( - rootTerminalIndex, - lateChild2InterruptedIndex, - "late child 2 must terminalize before the root run", + ["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 }, + ), + ), ); - 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 interruptTimeoutTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-timeout", + 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: [ ...codexReplayPreamble({ nativeThreadId: INTERRUPT_NATIVE_THREAD, @@ -6842,6 +3335,20 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, + { + 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", @@ -6854,79 +3361,40 @@ describe("CodexAdapterV2 post-settle continuation", () => { { type: "emit_inbound", label: "thread/backgroundTerminals/terminate/one", - frame: { id: 5, result: { terminated: true } }, - }, - { - type: "emit_inbound", - label: "item/started/command-at-timeout-boundary", - afterMs: 9_999, - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_TIMEOUT_BOUNDARY_ITEM, - command: "echo TIMEOUT_BOUNDARY", - processId: null, - commandActions: [{ type: "unknown", command: "echo TIMEOUT_BOUNDARY" }], - }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622450500, - }, - }, + frame: { id: 5, result: { terminated: false } }, }, { type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/two", + label: "thread/backgroundTerminals/list/after-false", frame: { id: 6, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, + method: "thread/backgroundTerminals/list", + params: { threadId: INTERRUPT_NATIVE_THREAD }, }, }, { type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/two", - frame: { id: 6, result: { terminated: true } }, + label: "thread/backgroundTerminals/list/after-false", + frame: { id: 6, result: { data: [], nextCursor: null } }, }, { - type: "emit_inbound", - label: "turn/completed/late", - afterMs: 20_000, + type: "expect_outbound", + label: "thread/backgroundTerminals/terminate/two", frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_NATIVE_TURN, - status: "interrupted", - }), - }, + id: 7, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, }, }, { 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, - }, - }, + 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: { @@ -6937,14 +3405,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, - { type: "runtime_exit", status: "success" }, ], }); - it.effect("bounds interrupt settlement and drops late completion events", () => + it.effect("contains commands that start before and after the interrupt response", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptTimeoutTranscript); + const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -6952,190 +3419,20 @@ 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-mid-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", - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated", - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptFiber = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }) - .pipe(Effect.forkScoped); - yield* awaitUntil( - () => - harness.events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", - ).length === 2, - "post-interrupt running command item", - ); - - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "timeout terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - const terminalProviderTurnsBeforeLateEvents = harness.events.filter( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "interrupted", - ); - assert.lengthOf(terminalProviderTurnsBeforeLateEvents, 1); - - const commandUpdatesBeforeLateEvents = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "turn_item.updated" } - > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ); - const terminalCommands = commandUpdatesBeforeLateEvents.filter( - (event) => - event.turnItem.status === "interrupted" && - event.turnItem.nativeItemRef?.nativeId !== INTERRUPT_TIMEOUT_BOUNDARY_ITEM, - ); - assert.lengthOf(terminalCommands, 2); - const boundaryUpdates = commandUpdatesBeforeLateEvents.filter( - (event) => event.turnItem.nativeItemRef?.nativeId === INTERRUPT_TIMEOUT_BOUNDARY_ITEM, - ); - const lastBoundaryUpdate = boundaryUpdates.at(-1); - assert.isDefined(lastBoundaryUpdate); - assert.equal(lastBoundaryUpdate.turnItem.status, "interrupted"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - - yield* TestClock.adjust("20 seconds"); - for (let attempt = 0; attempt < 100; attempt++) { - yield* Effect.yieldNow; - } - 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), - ), - ), - ), - ); - - const interruptTerminationFailureTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-termination-failure", - entries: [ - ...interruptMidCommandTranscript.entries - .filter( - (entry) => entry.type === "runtime_exit" || entry.label !== "item/completed/command-late", - ) - .map((entry) => - entry.type === "emit_inbound" && - entry.label === "thread/backgroundTerminals/list/after-false" - ? { - ...entry, - frame: { - id: 6, - result: { - data: [{ processId: "57680" }], - nextCursor: null, - }, - }, - } - : entry, - ), - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/one-retry", - frame: { - id: 8, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/one-retry", - frame: { id: 8, result: { terminated: false } }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/list/after-false-retry", - frame: { - id: 9, - method: "thread/backgroundTerminals/list", - params: { threadId: INTERRUPT_NATIVE_THREAD }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/list/after-false-retry", - frame: { - id: 9, - result: { data: [{ processId: "57680" }], nextCursor: null }, - }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - it.effect("attempts every terminal and cleans up tracking when termination fails", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptTerminationFailureTranscript); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-termination-failure"), - text: INTERRUPT_PROMPT, - }), - ); - 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.status === "running", + ), ); + const providerTurnId = harness.events.find( ( event, @@ -7146,272 +3443,91 @@ describe("CodexAdapterV2 post-settle continuation", () => { )?.providerTurn.id; assert.isDefined(providerTurnId); - const interruptExit = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }) - .pipe(Effect.exit); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); - assert.equal(interruptExit._tag, "Failure"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); + yield* harness.awaitTerminal(1); assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect.each([ - { - name: "usage", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { name: "rate", code: "rateLimitExceeded", notification: false, expectedClass: "usage_limit" }, - { - name: "ordinary", - code: "contextWindowExceeded", - notification: false, - expectedClass: "provider_error", - }, - { - name: "notification", - code: "usageLimitExceeded", - notification: true, - expectedClass: "usage_limit", - }, - { - name: "replacement", - code: "usageLimitExceeded", - notification: true, - expectedClass: "provider_error", - }, - { - name: "known-reset", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { - name: "late-reset", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { - name: "matching-details", - code: "usageLimitExceeded", - notification: true, - expectedClass: "usage_limit", - }, - { - name: "deferred-reset", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, - { - name: "exhausted-429", - code: "responseTooManyFailedAttempts", - errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 429 } }, - notification: false, - expectedClass: "usage_limit", - }, - { - name: "exhausted-500", - code: "responseTooManyFailedAttempts", - errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 500 } }, - notification: false, - expectedClass: "provider_error", - }, - ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = `native-limit-${scenario.name}`; - const nativeTurnId = `turn-limit-${scenario.name}`; - const message = "Provider stopped this request."; - const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code; - const resetAt = "2033-05-19T07:20:00.000Z"; - const snapshot = { - type: "emit_inbound" as const, - label: "account/rateLimits/updated", - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, - }, - }, - }, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-limit-${scenario.name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" - ? [snapshot] - : []), - ...(scenario.name === "deferred-reset" - ? [ - { - type: "emit_inbound" as const, - label: "item/completed/subAgentActivity-started", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "subAgentActivity", - id: "limit-child-spawn", - kind: "started", - agentThreadId: "native-limit-child", - agentPath: "/root/limit_child", - }, - }, - }, - }, - { - type: "emit_inbound" as const, - label: "turn/started/child", - frame: { - method: "turn/started", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "inProgress", - }), - }, - }, - }, - ] - : []), - ...(scenario.notification - ? [ - { - type: "emit_inbound" as const, - label: "error", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: scenario.name === "retry", - error: { - message, - codexErrorInfo: errorInfo, - additionalDetails: - scenario.name === "matching-details" - ? "Detailed provider allowance explanation." - : null, - }, - }, - }, - }, - ] - : []), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message: scenario.name === "replacement" ? "A different failure." : message, - ...(scenario.notification && scenario.name !== "matching-details" - ? {} - : { codexErrorInfo: errorInfo }), - }, - }, - }, - }, - }, - ...(scenario.name === "late-reset" ? [snapshot] : []), - ...(scenario.name === "deferred-reset" - ? [ - { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { - usedPercent: 100, - resetsAt: 2000200000, - windowDurationMins: 300, - }, - }, - }, - }, - }, - { - type: "emit_inbound" as const, - label: "turn/completed/child", - frame: { - method: "turn/completed", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "completed", - }), - }, - }, - }, - ] - : []), - ], - }); - const resetReceipt = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt - ? Deferred.succeed(resetReceipt, undefined) - : Effect.void, + 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", ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), - }), + assert.isAbove( + terminalIndex, + interruptedCommandIndex, + "command terminalization must precede turn.terminal", ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, scenario.expectedClass); - assert.equal(terminal.threadDisposition, "reusable"); - if (scenario.name === "known-reset" || scenario.name === "deferred-reset") - assert.equal(terminal.failure.resetAt, resetAt); - if (scenario.name === "matching-details") - assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); - if (scenario.name === "late-reset") { - yield* Deferred.await(resetReceipt); - const item = harness.events.find( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt, - ); - assert.isDefined(item); + + 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; } - if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); + 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", + ); + 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), @@ -7420,180 +3536,176 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect.each([ - { - notificationStatus: 500, - completionStatus: 429, - willRetry: false, - expectedClass: "usage_limit", - }, - { - notificationStatus: 429, - completionStatus: 500, - willRetry: true, - expectedClass: "provider_error", - }, - { - notificationStatus: 429, - completionStatus: null, - willRetry: false, - expectedClass: "usage_limit", - }, - { - notificationStatus: 429, - completionStatus: null, - willRetry: true, - expectedClass: "usage_limit", - }, - ] as const)( - "honors completion HTTP status from $notificationStatus to $completionStatus (retry: $willRetry)", - ({ notificationStatus, completionStatus, expectedClass, willRetry }) => - Effect.scoped( - Effect.gen(function* () { - const name = `${notificationStatus}-${completionStatus}-${willRetry}`; - const nativeThreadId = `native-exhaustion-status-${name}`; - const nativeTurnId = `turn-exhaustion-status-${name}`; - const message = "Provider stopped this request."; - const additionalDetails = "Detailed notification evidence."; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-exhaustion-status-${name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - { - type: "emit_inbound", - label: "error", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry, - error: { - message, - codexErrorInfo: { - responseTooManyFailedAttempts: { httpStatusCode: notificationStatus }, - }, - additionalDetails, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message, - codexErrorInfo: { - responseTooManyFailedAttempts: { httpStatusCode: completionStatus }, - }, - }, - }, - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-exhaustion-status-${name}`), - }), - ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, expectedClass); - assert.equal(terminal.failure.code, "responseTooManyFailedAttempts"); - assert.equal(terminal.threadDisposition, "reusable"); - const notificationClass = notificationStatus === 429 ? "usage_limit" : "provider_error"; - assert.equal( - terminal.failure.message, - expectedClass === notificationClass ? additionalDetails : message, - ); - if (willRetry) assert.equal(terminal.retry?.attempt, 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - const FAILED_SCENARIO = "codex-failed-mid-command"; - const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; - const FAILED_NATIVE_TURN = "native-codex-failed-turn"; - const FAILED_COMMAND_ITEM = "exec-codex-failed-command"; - const FAILED_COMMAND = "sleep 30"; - const FAILED_PROMPT = "Run a command that will be abandoned when the turn fails."; - - const failedMidCommandTranscript = makeCodexReplayTranscript({ - scenario: FAILED_SCENARIO, + const interruptSubagentCommandTranscript = makeCodexReplayTranscript({ + scenario: "codex-interrupt-subagent-command", entries: [ ...codexReplayPreamble({ - nativeThreadId: FAILED_NATIVE_THREAD, - nativeTurnId: FAILED_NATIVE_TURN, - prompt: FAILED_PROMPT, + nativeThreadId: INTERRUPT_NATIVE_THREAD, + nativeTurnId: INTERRUPT_NATIVE_TURN, + prompt: INTERRUPT_PROMPT, }), { type: "emit_inbound", - label: "item/started/command", + 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", + 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: { - method: "item/started", + id: 6, + method: "thread/backgroundTerminals/terminate", + params: { threadId: INTERRUPT_CHILD_NATIVE_THREAD, processId: "57682" }, + }, + }, + { + type: "emit_inbound", + label: "thread/backgroundTerminals/terminate/child", + frame: { id: 6, result: { terminated: true } }, + }, + { + type: "emit_inbound", + label: "turn/completed/root", + frame: { + method: "turn/completed", params: { - item: { - type: "commandExecution", - id: FAILED_COMMAND_ITEM, - command: FAILED_COMMAND, - cwd: "/workspace", - processId: "99", - source: "unifiedExecStartup", - status: "inProgress", - commandActions: [{ type: "unknown", command: FAILED_COMMAND }], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }, - threadId: FAILED_NATIVE_THREAD, - turnId: FAILED_NATIVE_TURN, - startedAtMs: 1782622440500, + threadId: INTERRUPT_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), }, }, }, { type: "emit_inbound", - label: "turn/completed", + label: "turn/completed/child-completed-race", frame: { method: "turn/completed", params: { - threadId: FAILED_NATIVE_THREAD, - turn: { - ...makeCodexReplayTurn({ - id: FAILED_NATIVE_TURN, - status: "failed", - }), - error: { message: "provider failed mid-command" }, - }, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_CHILD_NATIVE_TURN, + status: "completed", + }), }, }, }, + { type: "runtime_exit", status: "success" }, ], }); - it.effect("terminalizes running command items before turn.terminal on failed turns", () => + 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(failedMidCommandTranscript); + const harness = yield* makeCodexReplayHarness(interruptSubagentCommandTranscript); const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -7601,29 +3713,51 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-failed-mid-command"), - text: FAILED_PROMPT, + attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-command"), + text: INTERRUPT_PROMPT, }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); + 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( + ( + 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 terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - const failedCommandIndex = harness.events.findIndex( - (event, index) => - index < terminalIndex && + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); + + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + const childCommandUpdates = harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn_item.updated" } + > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution" && - event.turnItem.status === "failed", - ); - assert.isAtLeast(failedCommandIndex, 0); - assert.isAbove( - terminalIndex, - failedCommandIndex, - "failed-turn command terminalization must precede turn.terminal", + 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); - assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -7632,161 +3766,357 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const MCP_APP_SCENARIO = "codex-mcp-app-capture"; - const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread"; - const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn"; - const MCP_APP_ITEM = "mcp-weather-call"; - const MCP_APP_PROMPT = "Show the weather in Oslo."; - const MCP_APP_RESOURCE = "ui://weather/dashboard"; - const MCP_APP_HTML = "

Weather

"; - const mcpAppToolItem = (status: "inProgress" | "completed") => ({ - type: "mcpToolCall", - id: MCP_APP_ITEM, - server: "weather", - tool: "get_weather", - status, - arguments: { city: "Oslo" }, - mcpAppResourceUri: MCP_APP_RESOURCE, - ...(status === "completed" - ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } - : {}), - }); - - const mcpAppTranscript = makeCodexReplayTranscript({ - scenario: MCP_APP_SCENARIO, + 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: [ - ...codexReplayPreamble({ - nativeThreadId: MCP_APP_NATIVE_THREAD, - nativeTurnId: MCP_APP_NATIVE_TURN, - prompt: MCP_APP_PROMPT, - }), - { - type: "emit_inbound", - label: "item/started/app-tool", - frame: { - method: "item/started", - params: { - item: mcpAppToolItem("inProgress"), - threadId: MCP_APP_NATIVE_THREAD, - turnId: MCP_APP_NATIVE_TURN, - startedAtMs: 1782622440500, - }, - }, - }, + ...interruptSubagentCommandTranscript.entries.slice(0, rootCompletionIndex), { type: "emit_inbound", - label: "item/completed/app-tool", + label: "turn/started/late-child", frame: { - method: "item/completed", + method: "turn/started", params: { - item: mcpAppToolItem("completed"), - threadId: MCP_APP_NATIVE_THREAD, - turnId: MCP_APP_NATIVE_TURN, - completedAtMs: 1782622441500, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_NATIVE_TURN, + status: "inProgress", + }), }, }, }, + ...interruptSubagentCommandTranscript.entries.slice(rootCompletionIndex, -1), { - type: "emit_inbound", - label: "turn/completed", + type: "expect_outbound", + label: "turn/interrupt/late-child", frame: { - method: "turn/completed", + id: 7, + method: "turn/interrupt", params: { - threadId: MCP_APP_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), + threadId: INTERRUPT_CHILD_NATIVE_THREAD, + turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, }, }, }, - { - type: "expect_outbound", - label: "mcpServer/resource/read", - frame: { - id: 4, - method: "mcpServer/resource/read", - params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, - }, - }, { type: "emit_inbound", - label: "mcpServer/resource/read", - frame: { - id: 4, - result: { - contents: [ - { - uri: MCP_APP_RESOURCE, - mimeType: "text/html;profile=mcp-app", - text: MCP_APP_HTML, - _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, - }, - ], - }, - }, + label: "turn/interrupt/late-child", + frame: { id: 7, result: {} }, }, + { type: "runtime_exit", status: "success" }, ], }); - it.effect("captures an MCP app's resource and holds the turn open until it lands", () => + 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* 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( + ( + 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* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + ), + ); + yield* Fiber.join(interruptFiber); + + 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( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + { + name: "usage", + code: "usageLimitExceeded", + notification: false, + expectedClass: "usage_limit", + }, + { + name: "replacement", + code: "usageLimitExceeded", + notification: true, + expectedClass: "provider_error", + }, + { + name: "late-reset", + code: "usageLimitExceeded", + notification: false, + expectedClass: "usage_limit", + }, + { + name: "matching-details", + code: "usageLimitExceeded", + notification: true, + expectedClass: "usage_limit", + }, + { + name: "deferred-reset", + code: "usageLimitExceeded", + notification: false, + expectedClass: "usage_limit", + }, + { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, + { + name: "exhausted-429", + code: "responseTooManyFailedAttempts", + errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 429 } }, + notification: false, + expectedClass: "usage_limit", + }, + { + name: "exhausted-500", + code: "responseTooManyFailedAttempts", + errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 500 } }, + notification: false, + expectedClass: "provider_error", + }, + { + name: "chatgpt-workspace-limit", + code: "other", + details: "subscription_sharing_workplace_individual_usage_limit_exceeded", + notification: true, + expectedClass: "provider_error", + }, + ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => Effect.scoped( Effect.gen(function* () { - const appCaptured = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => + const nativeThreadId = `native-limit-${scenario.name}`; + const nativeTurnId = `turn-limit-${scenario.name}`; + const message = "Provider stopped this request."; + const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code; + const resetAt = "2033-05-19T07:20:00.000Z"; + const snapshot = { + type: "emit_inbound" as const, + label: "account/rateLimits/updated", + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, + }, + }, + }, + }; + const transcript = makeCodexReplayTranscript({ + scenario: `codex-limit-${scenario.name}`, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + ...(scenario.name === "deferred-reset" ? [snapshot] : []), + ...(scenario.name === "deferred-reset" + ? [ + { + type: "emit_inbound" as const, + label: "item/completed/subAgentActivity-started", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { + type: "subAgentActivity", + id: "limit-child-spawn", + kind: "started", + agentThreadId: "native-limit-child", + agentPath: "/root/limit_child", + }, + }, + }, + }, + { + type: "emit_inbound" as const, + label: "turn/started/child", + frame: { + method: "turn/started", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "inProgress", + }), + }, + }, + }, + ] + : []), + ...(scenario.notification + ? [ + { + type: "emit_inbound" as const, + label: "error", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: scenario.name === "retry", + error: { + message, + codexErrorInfo: errorInfo, + additionalDetails: + "details" in scenario + ? scenario.details + : scenario.name === "matching-details" + ? "Detailed provider allowance explanation." + : null, + }, + }, + }, + }, + ] + : []), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), + error: { + message: scenario.name === "replacement" ? "A different failure." : message, + ...(scenario.notification && + scenario.name !== "matching-details" && + !("details" in scenario) + ? {} + : { codexErrorInfo: errorInfo }), + }, + }, + }, + }, + }, + ...(scenario.name === "late-reset" ? [snapshot] : []), + ...(scenario.name === "deferred-reset" + ? [ + { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { + usedPercent: 100, + resetsAt: 2000200000, + windowDurationMins: 300, + }, + }, + }, + }, + }, + { + type: "emit_inbound" as const, + label: "turn/completed/child", + frame: { + method: "turn/completed", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "completed", + }), + }, + }, + }, + ] + : []), + ], + }); + const resetReceipt = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed" - ? Deferred.succeed(appCaptured, event.turnItem) + event.turnItem.type === "error" && + event.turnItem.failure.resetAt === resetAt + ? Deferred.succeed(resetReceipt, 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-mcp-app"), - text: MCP_APP_PROMPT, + now: yield* DateTime.now, + text: "Continue.", + attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), }), ); - const item = yield* Deferred.await(appCaptured); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - - const completedRows = harness.events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed", - ); - assert.lengthOf(completedRows, 1); - - assert.equal(item.type, "dynamic_tool"); - const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; - const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); - assert.deepEqual( - { ...reference, attachmentId: undefined }, - { - attachmentId: undefined, - server: "weather", - tool: "get_weather", - resourceUri: MCP_APP_RESOURCE, - csp: { connectDomains: ["https://api.weather.test"] }, - }, - ); - assert.deepEqual(output.result, { - content: [{ type: "text", text: "Sunny" }], - structuredContent: { temp: 21 }, - }); - - const fileSystem = yield* FileSystem.FileSystem; - const stored = yield* fileSystem.readFileString( - resolveAttachmentPathById({ - attachmentsDir: harness.serverConfig.attachmentsDir, - attachmentId: reference!.attachmentId, - })!, - ); - assert.include(stored, "connect-src https://api.weather.test"); - assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf(""; + const mcpAppToolItem = (status: "inProgress" | "completed") => ({ + type: "mcpToolCall", + id: MCP_APP_ITEM, + server: "weather", + tool: "get_weather", + status, + arguments: { city: "Oslo" }, + mcpAppResourceUri: MCP_APP_RESOURCE, + ...(status === "completed" + ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } + : {}), + }); - const childAgentMessage = (input: { - readonly id: string; - readonly text: string; - readonly turnId: string; - readonly completedAtMs: number; - readonly afterMs?: number; - readonly omitPhase?: boolean; - }): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `item/completed/${input.id}`, - ...(input.afterMs === undefined ? {} : { afterMs: input.afterMs }), - frame: { - method: "item/completed", - params: { - item: { - type: "agentMessage", - id: input.id, - text: input.text, - ...(input.omitPhase ? {} : { phase: "final_answer" as const }), - memoryCitation: null, + const mcpAppTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: MCP_APP_NATIVE_THREAD, + nativeTurnId: MCP_APP_NATIVE_TURN, + prompt: MCP_APP_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/app-tool", + frame: { + method: "item/started", + params: { + item: mcpAppToolItem("inProgress"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + startedAtMs: 1782622440500, + }, }, - threadId: RESUME_CHILD_THREAD, - turnId: input.turnId, - completedAtMs: input.completedAtMs, }, - }, - }); - - const childTurnStarted = ( - turnId: string, - afterMs?: number, - ): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `turn/started/${turnId}`, - ...(afterMs === undefined ? {} : { afterMs }), - frame: { - method: "turn/started", - params: { - threadId: RESUME_CHILD_THREAD, - turn: { - ...makeCodexReplayTurn({ id: turnId, status: "inProgress" }), - startedAt: turnId === RESUME_CHILD_TURN_2 ? 1782622470 : 1782622440, + { + type: "emit_inbound", + label: "item/completed/app-tool", + frame: { + method: "item/completed", + params: { + item: mcpAppToolItem("completed"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, }, }, - }, - }); - - const childTurnCompleted = ( - turnId: string, - afterMs?: number, - ): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `turn/completed/${turnId}`, - ...(afterMs === undefined ? {} : { afterMs }), - frame: { - method: "turn/completed", - params: { - threadId: RESUME_CHILD_THREAD, - turn: makeCodexReplayTurn({ id: turnId, status: "completed" }), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: MCP_APP_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), + }, + }, }, - }, + { + type: "expect_outbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + method: "mcpServer/resource/read", + params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, + }, + }, + { + type: "emit_inbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + result: { + contents: [ + { + uri: MCP_APP_RESOURCE, + mimeType: "text/html;profile=mcp-app", + text: MCP_APP_HTML, + _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, + }, + ], + }, + }, + }, + ], }); - const resumeSubagentTranscript = makeCodexReplayTranscript({ - scenario: RESUME_SCENARIO, + it.effect("captures an MCP app's resource and holds the turn open until it lands", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, + ); + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + + const completedRows = harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + assert.lengthOf(completedRows, 1); + + assert.equal(item.type, "dynamic_tool"); + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); + assert.deepEqual( + { ...reference, attachmentId: undefined }, + { + attachmentId: undefined, + server: "weather", + tool: "get_weather", + resourceUri: MCP_APP_RESOURCE, + csp: { connectDomains: ["https://api.weather.test"] }, + }, + ); + assert.deepEqual(output.result, { + content: [{ type: "text", text: "Sunny" }], + structuredContent: { temp: 21 }, + }); + + const fileSystem = yield* FileSystem.FileSystem; + const stored = yield* fileSystem.readFileString( + resolveAttachmentPathById({ + attachmentsDir: harness.serverConfig.attachmentsDir, + attachmentId: reference!.attachmentId, + })!, + ); + assert.include(stored, "connect-src https://api.weather.test"); + assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf(" - `); - // Chromium opens a data: URL whose type is malformed too, and quotes it unchanged. - for (const url of ["data:text/plain,head PAYLOAD'tail x", "data:text/plain charset,PAYLOAD"]) { - const blocked = page.waitForEvent("console"); - await page.evaluate(`location.href = ${JSON.stringify(url)}`); - await blocked; - } - expect(logged).toEqual([ - "Downloaded data:text/plain,example", - "Loading the image 'data:image/png,…", - "Not allowed to navigate top frame to data URL: data:text/plain,…", - "Not allowed to navigate top frame to data URL: data:,…", - ]); - }); - it("rejects refs from another tab", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label"); @@ -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 3be31f3ca5c11..02089cca3205c 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 b9cd7f66710c7..a5971f010a57e 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 b9fee70bf39fc..0000000000000 --- 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 6de4207778734..30be9346dc1d4 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 32461ac69f7f0..74858468d1b8a 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 0dedda030dcbb..73ff1b067e875 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", @@ -1244,7 +340,7 @@ for (const { platform, installPath, editor, args } of [ platform: "darwin", installPath: "Applications/WebStorm.app/Contents/MacOS/webstorm", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "darwin", @@ -1268,13 +364,13 @@ for (const { platform, installPath, editor, args } of [ platform: "win32", installPath: "Programs/JetBrains/WebStorm 2026.2/bin/webstorm64.exe", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "win32", installPath: "Programs/WebStorm/bin/webstorm64.exe", editor: "webstorm", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "win32", @@ -1286,7 +382,7 @@ for (const { platform, installPath, editor, args } of [ platform: "linux", installPath: ".local/share/JetBrains/Toolbox/scripts/idea", editor: "idea", - args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"], + args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], }, { platform: "linux", @@ -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"]) { @@ -1413,7 +472,7 @@ it.effect.each([ "--line", "12", "--column", - "4", + "3", "/workspace with spaces/file.ts", ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), @@ -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/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 2c24857555bb6..1260f2e3fda80 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -163,9 +163,10 @@ function resolveCommandEditorArgs( onSome: ({ path, line, column }) => [ "--line", line, + // JetBrains IDEs count `--column` from 0; targets count columns from 1. ...Option.match(column, { onNone: () => [], - onSome: (value) => ["--column", value], + onSome: (value) => ["--column", String(Math.max(0, Number(value) - 1))], }), path, ], diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index eb376ee5cedd5..29d6dbd836597 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 002aebcac9c4f..05640ec968d34 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 9ea0d62b327df..839c63aa4eaee 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -319,131 +319,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; @@ -522,38 +397,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; @@ -800,34 +643,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; @@ -869,25 +684,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; @@ -954,48 +750,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; @@ -1224,388 +978,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"), @@ -1661,54 +1107,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; @@ -1855,109 +1253,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; @@ -2181,595 +1476,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, }); @@ -2777,60 +1524,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" }]); }), ); @@ -2878,88 +1657,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"]); - }), - ); }); }); @@ -3494,53 +2191,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", @@ -3566,26 +2216,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({ @@ -3600,32 +2230,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 c89a8bf4b8108..6f13fba0a183f 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,13 +1,7 @@ import * as SidebarLayout from "../sidebarLayout.ts"; 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"; @@ -118,15 +112,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; @@ -564,36 +549,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/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index 566636d31e44f..40e35e408a593 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { OrchestrationDispatchCommandError, ProjectId, + projectCloneProgressSummary, SourceControlRepositoryError, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -105,6 +106,45 @@ describe("ProjectCloneTracker", () => { }).pipe(Effect.provide(harness.layer)); }); + it.effect("flags a clone stalled when git goes quiet and clears it when git speaks", () => { + const release = Deferred.makeUnsafe(); + const resumed = Deferred.makeUnsafe(); + const harness = makeHarness({ + clone: (input, options) => + Effect.gen(function* () { + const report = options?.onProgress ?? (() => Effect.void); + yield* report({ stage: "receiving", percent: 24, detail: "168.80 MiB | 20.69 MiB/s" }); + yield* Deferred.await(resumed); + yield* report({ stage: "receiving", percent: 25, detail: "170.00 MiB | 1.00 MiB/s" }); + yield* Deferred.await(release); + return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null }; + }), + }); + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + yield* tracker.start(startInput, harness.hooks); + yield* Effect.yieldNow; + expect((yield* tracker.get(projectId))?.stalled).toBe(false); + + yield* TestClock.adjust("19 seconds"); + expect((yield* tracker.get(projectId))?.stalled).toBe(false); + yield* TestClock.adjust("1 second"); + const stalled = yield* tracker.get(projectId); + expect(stalled).toMatchObject({ stalled: true, percent: 24 }); + expect(projectCloneProgressSummary(stalled!)).toBe("Receiving objects · 24% · Stalled"); + + yield* Deferred.succeed(resumed, undefined); + yield* Effect.yieldNow; + expect(yield* tracker.get(projectId)).toMatchObject({ stalled: false, percent: 25 }); + + yield* TestClock.adjust("20 seconds"); + expect((yield* tracker.get(projectId))?.stalled).toBe(true); + yield* Deferred.succeed(release, undefined); + yield* Effect.yieldNow; + expect(yield* tracker.get(projectId)).toMatchObject({ phase: "done", stalled: false }); + }).pipe(Effect.provide(harness.layer)); + }); + it.effect("keeps a failed clone with git's own explanation and retries it", () => { let attempts = 0; const harness = makeHarness({ diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 115eef6e0f716..8c14a32dd463a 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -20,6 +20,7 @@ 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 PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -69,6 +70,10 @@ export interface ProjectCloneHooks { } const DONE_RETENTION = "30 seconds"; +// Git prints nothing while a transfer waits on the network, and the kernel can +// take 15+ minutes to give up on a dead connection. Without this the banner +// keeps showing the last transfer speed as if data were still arriving. +const STALL_AFTER = "20 seconds"; function clampText(text: string, maxLength: number): string { return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; @@ -167,9 +172,28 @@ export const make = Effect.gen(function* () { percent: update.percent, detail: update.detail === null ? null : clampText(update.detail, PROJECT_CLONE_DETAIL_MAX_LENGTH), + stalled: false, }, })).pipe(Effect.asVoid); + const markStalled = (projectId: ProjectId) => + modify(projectId, (tracked) => ({ + ...tracked, + snapshot: { ...tracked.snapshot, stalled: true }, + })).pipe(Effect.asVoid); + + /** Marks the clone stalled once, then waits for git to speak again. */ + const watchForStall = (projectId: ProjectId, pulses: Queue.Dequeue) => + Queue.take(pulses).pipe( + Effect.timeoutOption(STALL_AFTER), + Effect.flatMap((pulse) => + Option.isSome(pulse) + ? Effect.void + : markStalled(projectId).pipe(Effect.andThen(Queue.take(pulses))), + ), + Effect.forever, + ); + const finish = Effect.fn("ProjectCloneTracker.finish")(function* ( projectId: ProjectId, phase: "done" | "failed" | "cancelled", @@ -184,12 +208,29 @@ export const make = Effect.gen(function* () { phase, endedAt, percent: phase === "done" ? 100 : tracked.snapshot.percent, + stalled: false, error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), }, })); if (phase === "done") yield* scheduleRemoval(projectId); }); + const cloneWatchingForStall = Effect.fn("ProjectCloneTracker.cloneWatchingForStall")(function* ( + projectId: ProjectId, + tracked: TrackedClone, + ) { + const pulses = yield* Queue.sliding(1); + yield* watchForStall(projectId, pulses).pipe(Effect.forkScoped); + return yield* repositories.cloneRepository( + { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, + { + onProgress: (update) => + Queue.offer(pulses, undefined).pipe(Effect.andThen(progress(projectId, update))), + timeoutMs: null, + }, + ); + }, Effect.scoped); + /** * Runs in its own fiber; the tracker records the outcome through `onExit`, * which still runs when the fiber is interrupted (a `matchCause` handler @@ -198,26 +239,21 @@ export const make = Effect.gen(function* () { * checkout. */ const runClone = (projectId: ProjectId, tracked: TrackedClone) => - repositories - .cloneRepository( - { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, - { onProgress: (update) => progress(projectId, update), timeoutMs: null }, - ) - .pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) - ? finish(projectId, "done", null) - : Cause.hasInterruptsOnly(exit.cause) - ? finish(projectId, "cancelled", null) - : finish(projectId, "failed", describeCloneFailure(exit.cause)), - ), - Effect.flatMap(() => - tracked.hooks - .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) - .pipe(Effect.ignoreCause({ log: true })), - ), - Effect.ignoreCause(), - ); + cloneWatchingForStall(projectId, tracked).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? finish(projectId, "done", null) + : Cause.hasInterruptsOnly(exit.cause) + ? finish(projectId, "cancelled", null) + : finish(projectId, "failed", describeCloneFailure(exit.cause)), + ), + Effect.flatMap(() => + tracked.hooks + .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) + .pipe(Effect.ignoreCause({ log: true })), + ), + Effect.ignoreCause(), + ); const launch = Effect.fn("ProjectCloneTracker.launch")(function* (projectId: ProjectId) { const current = yield* Ref.get(clones); @@ -246,6 +282,7 @@ export const make = Effect.gen(function* () { stage: "connecting", percent: null, detail: null, + stalled: false, error: null, startedAt, endedAt: null, @@ -352,6 +389,7 @@ export const make = Effect.gen(function* () { stage: "connecting", percent: null, detail: null, + stalled: false, error: null, startedAt, endedAt: null, diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 402cb56fad0eb..492d1c43419ab 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 10f8fa4169bf6..5877662f46bc7 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 c2f9ee1a89b1b..0000000000000 --- 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 82a828c544766..439cb571e66d1 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -563,28 +563,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; @@ -846,112 +824,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 dcba984ffe974..0000000000000 --- 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 ed78280a7146d..f2946473bba0f 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; @@ -454,6 +263,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.displayName).toBe("T3Tools/t3code"); expect(identity?.origin).toEqual({ canonicalKey: "github.com/julius/t3code-fork", + remoteUrl: "git@github.com:julius/t3code-fork.git", displayName: "julius/t3code-fork", }); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), @@ -479,86 +289,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/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index e3f3674c11e64..0ec948b485fc0 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -105,7 +105,7 @@ function buildRepositoryOrigin( const originKey = normalizeGitRemoteUrl(originUrl); if (originKey === canonicalKey) return undefined; const displayName = repositoryPathOf(originKey, originUrl); - return { canonicalKey: originKey, ...(displayName ? { displayName } : {}) }; + return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) }; } function buildRepositoryIdentity(input: { diff --git a/apps/server/src/project/T3ProjectFileLoader.test.ts b/apps/server/src/project/T3ProjectFileLoader.test.ts index 5b42ea4cd2134..5abc78098869b 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 e96a1815f1852..ed3fcc027c720 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 ba20c5fbbc9f1..0000000000000 --- 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 e1a04655d8353..825e6c1cc8f43 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 6c8cb5aebf836..bc7c14d3b3c8a 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 02e8b691a1334..dfcfb2fecdc8c 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 efab762da7cbc..e7bcbe7553f67 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 be709add4f60f..ade033551f655 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.fork.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.fork.test.ts new file mode 100644 index 0000000000000..99aff77587bbf --- /dev/null +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.fork.test.ts @@ -0,0 +1,21 @@ +// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. + +// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import { vi } from "vite-plus/test"; + +import { assert, it } from "@effect/vitest"; + +import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; + +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + +it("reads only user settings when the probe starts in the home directory", () => { + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath: "/usr/bin/claude", + abortController: new AbortController(), + environment: { HOME: "/home/user" }, + cwd: "/home/user/", + managedMcpConfig: false, + }); + assert.deepEqual(options.settingSources, ["user"]); +}); diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 6e2b18fcd9685..80740b0a21d49 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,78 +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("reads only user settings when the probe starts in the home directory", () => { - const options = buildClaudeCapabilitiesProbeQueryOptions({ - executablePath: "/usr/bin/claude", - abortController: new AbortController(), - environment: { HOME: "/home/user" }, - cwd: "/home/user/", - managedMcpConfig: false, - }); - assert.deepEqual(options.settingSources, ["user"]); -}); - -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", @@ -87,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 da61038215d82..2092429f6d556 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 a04672d88c3cd..762f919dc39c1 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 a3b29871f3c2f..cc9a1e943dedf 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 2ed528eb16332..7bcb7413a1578 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.fork.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.fork.test.ts new file mode 100644 index 0000000000000..e0009d1cc6e3e --- /dev/null +++ b/apps/server/src/provider/CodexDeveloperInstructions.fork.test.ts @@ -0,0 +1,50 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { + buildCodexAdditionalContext, + buildCodexDeveloperInstructions, + CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES, +} from "./CodexDeveloperInstructions.ts"; + +describe("buildCodexAdditionalContext", () => { + it("splits managed issue instructions into entries within Codex's context cap", () => { + const context = buildCodexAdditionalContext( + { model: "gpt-5.3-codex", reasoningEffort: "high" }, + false, + [], + true, + ); + const first = context.t3_code_orchestration_01?.value ?? ""; + const orchestration = Object.entries(context) + .filter(([key]) => key.startsWith("t3_code_orchestration_")) + .map(([, entry]) => entry.value) + .join("\n\n"); + + NodeAssert.match(first, /^Keep issues current through propose_issue_change/); + NodeAssert.match( + orchestration, + /Native subagent tools are disabled in this repository-managed session/, + ); + for (const [key, entry] of Object.entries(context)) { + NodeAssert.ok( + Buffer.byteLength(entry.value, "utf8") <= CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES, + `${key} exceeds Codex's per-entry context cap`, + ); + } + }); +}); + +describe("buildCodexDeveloperInstructions", () => { + it("routes unresolved Default mode questions through the native question tool", () => { + const instructions = buildCodexDeveloperInstructions("default"); + + NodeAssert.match(instructions, /Use the `request_user_input` tool only when it is listed/); + NodeAssert.match( + instructions, + /When a missing answer would materially change the task[\s\S]*ask with `request_user_input` and wait for the answer/, + ); + NodeAssert.match(instructions, /If the tool is unavailable or fails, report the blocker/); + }); +}); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index e5a86eeb6f96b..14a613967c117 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"; @@ -59,97 +58,9 @@ describe("buildCodexAdditionalContext", () => { T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim(), ); }); - - it("splits managed issue instructions into entries within Codex's context cap", () => { - const context = buildCodexAdditionalContext( - { model: "gpt-5.3-codex", reasoningEffort: "high" }, - false, - [], - true, - ); - const first = context.t3_code_orchestration_01?.value ?? ""; - const orchestration = Object.entries(context) - .filter(([key]) => key.startsWith("t3_code_orchestration_")) - .map(([, entry]) => entry.value) - .join("\n\n"); - - NodeAssert.match(first, /^Keep issues current through propose_issue_change/); - NodeAssert.match( - orchestration, - /Native subagent tools are disabled in this repository-managed session/, - ); - for (const [key, entry] of Object.entries(context)) { - NodeAssert.ok( - Buffer.byteLength(entry.value, "utf8") <= CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES, - `${key} exceeds Codex's per-entry context cap`, - ); - } - }); }); 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("routes unresolved Default mode questions through the native question tool", () => { - const instructions = buildCodexDeveloperInstructions("default"); - - NodeAssert.match(instructions, /Use the `request_user_input` tool only when it is listed/); - NodeAssert.match( - instructions, - /When a missing answer would materially change the task[\s\S]*ask with `request_user_input` and wait for the answer/, - ); - NodeAssert.match(instructions, /If the tool is unavailable or fails, report the blocker/); - }); - - 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, - /[\s\S]*!\[Short description\]\(\/absolute\/path\/to\/chart\.png\)[\s\S]*<\/showing_images>/, - ); - } - }); - - 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", @@ -161,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 4eab3b32a12a2..849b3c928af8b 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 3284b30544cc7..e481f95e3c578 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/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts index 9c8fe26bbb6ec..f2ee114c74fd1 100644 --- a/apps/server/src/provider/CodexManagedErrors.ts +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -9,6 +9,11 @@ const legacyFailures = { "Your ChatGPT usage limit was reached. Check ChatGPT Usage settings for your available allowance.", revoke: false, }, + subscription_sharing_workplace_individual_usage_limit_exceeded: { + message: + "You reached your workspace's limit for using ChatGPT in other apps. Ask your workspace admin to increase it.", + revoke: false, + }, subscription_sharing_usage_unavailable: { message: "ChatGPT usage is temporarily unavailable. Try again shortly.", revoke: false, diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 69d27f1377b6b..8034e910d67a2 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 c0655faf0d67a..66dd7ddb6e7d0 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 fe859429d30b2..0000000000000 --- 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.fork.test.ts b/apps/server/src/provider/CodexProvider.fork.test.ts new file mode 100644 index 0000000000000..8238247fb2c56 --- /dev/null +++ b/apps/server/src/provider/CodexProvider.fork.test.ts @@ -0,0 +1,97 @@ +import { assert, it } from "@effect/vitest"; + +import type * as CodexSchema from "effect-codex-app-server/schema"; + +import { applyCodexInstalledPlugins } from "./CodexProvider.ts"; + +const codexPlugin = ( + name: string, + marketplace: string, + state: { installed: boolean; enabled: boolean }, +): CodexSchema.V2PluginInstalledResponse["marketplaces"][number]["plugins"][number] => ({ + id: `${name}@${marketplace}`, + name, + installed: state.installed, + enabled: state.enabled, + authPolicy: "ON_USE", + installPolicy: "AVAILABLE", + source: { type: "local", path: `/plugins/${marketplace}/${name}` }, +}); + +it("tags plugin skills and lists only enabled plugins", () => { + const cache = "/home/me/.codex/plugins/cache"; + const result = applyCodexInstalledPlugins( + [ + { + name: "review", + path: "/home/me/.codex/skills/review/SKILL.md", + enabled: true, + scope: "user", + }, + { + name: "cse:build", + path: `${cache}/cse/cse/1/skills/build/SKILL.md`, + enabled: true, + scope: "user", + }, + { + name: "cse:plot", + path: `${cache}/cse/cse/1/skills/plot/SKILL.md`, + enabled: false, + scope: "user", + }, + { + name: "browser:open", + path: `${cache}/openai-bundled/browser/2/skills/open/SKILL.md`, + enabled: true, + scope: "user", + }, + { + name: "github:fix-ci", + path: `${cache}/curated/github/1/skills/fix-ci/SKILL.md`, + enabled: true, + scope: "user", + }, + ], + { + marketplaces: [ + { + name: "personal", + plugins: [codexPlugin("cse", "personal", { installed: true, enabled: false })], + }, + { name: "cse", plugins: [codexPlugin("cse", "cse", { installed: true, enabled: true })] }, + { + name: "openai-bundled", + plugins: [ + codexPlugin("browser", "openai-bundled", { installed: true, enabled: true }), + codexPlugin("visualize", "openai-bundled", { installed: false, enabled: false }), + ], + }, + { + name: "curated", + plugins: [codexPlugin("github", "curated", { installed: true, enabled: false })], + }, + ], + }, + ); + + assert.deepStrictEqual( + result.skills.map((skill) => [skill.name, skill.scope, skill.pluginName, skill.enabled]), + [ + ["review", "user", undefined, true], + // A same-named disabled plugin in another marketplace does not claim + // these: the install path names the enabled one. + ["cse:build", "plugin", "cse", true], + ["cse:plot", "plugin", "cse", false], + ["browser:open", "plugin", "browser", true], + ], + ); + assert.deepStrictEqual(result.plugins, [ + { name: "cse", marketplace: "cse", skillCount: 2 }, + { name: "browser", marketplace: "openai-bundled", skillCount: 1, requiresDesktopApp: true }, + ]); +}); +const account = { + account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, + requiresOpenaiAuth: false, +} as const; diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 845f104b5ae8c..30f63b21f318e 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,26 +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 type * as CodexSchema from "effect-codex-app-server/schema"; - -import { - applyCodexInstalledPlugins, - applyPreferredCodexDefaultModel, - checkCodexProviderStatus, - mapCodexModelCapabilities, -} from "./CodexProvider.ts"; +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -148,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([ { @@ -174,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 }, @@ -191,308 +153,3 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); - -const codexPlugin = ( - name: string, - marketplace: string, - state: { installed: boolean; enabled: boolean }, -): CodexSchema.V2PluginInstalledResponse["marketplaces"][number]["plugins"][number] => ({ - id: `${name}@${marketplace}`, - name, - installed: state.installed, - enabled: state.enabled, - authPolicy: "ON_USE", - installPolicy: "AVAILABLE", - source: { type: "local", path: `/plugins/${marketplace}/${name}` }, -}); - -it("tags plugin skills and lists only enabled plugins", () => { - const cache = "/home/me/.codex/plugins/cache"; - const result = applyCodexInstalledPlugins( - [ - { - name: "review", - path: "/home/me/.codex/skills/review/SKILL.md", - enabled: true, - scope: "user", - }, - { - name: "cse:build", - path: `${cache}/cse/cse/1/skills/build/SKILL.md`, - enabled: true, - scope: "user", - }, - { - name: "cse:plot", - path: `${cache}/cse/cse/1/skills/plot/SKILL.md`, - enabled: false, - scope: "user", - }, - { - name: "browser:open", - path: `${cache}/openai-bundled/browser/2/skills/open/SKILL.md`, - enabled: true, - scope: "user", - }, - { - name: "github:fix-ci", - path: `${cache}/curated/github/1/skills/fix-ci/SKILL.md`, - enabled: true, - scope: "user", - }, - ], - { - marketplaces: [ - { - name: "personal", - plugins: [codexPlugin("cse", "personal", { installed: true, enabled: false })], - }, - { name: "cse", plugins: [codexPlugin("cse", "cse", { installed: true, enabled: true })] }, - { - name: "openai-bundled", - plugins: [ - codexPlugin("browser", "openai-bundled", { installed: true, enabled: true }), - codexPlugin("visualize", "openai-bundled", { installed: false, enabled: false }), - ], - }, - { - name: "curated", - plugins: [codexPlugin("github", "curated", { installed: true, enabled: false })], - }, - ], - }, - ); - - assert.deepStrictEqual( - result.skills.map((skill) => [skill.name, skill.scope, skill.pluginName, skill.enabled]), - [ - ["review", "user", undefined, true], - // A same-named disabled plugin in another marketplace does not claim - // these: the install path names the enabled one. - ["cse:build", "plugin", "cse", true], - ["cse:plot", "plugin", "cse", false], - ["browser:open", "plugin", "browser", true], - ], - ); - assert.deepStrictEqual(result.plugins, [ - { name: "cse", marketplace: "cse", skillCount: 2 }, - { name: "browser", marketplace: "openai-bundled", skillCount: 1, requiresDesktopApp: true }, - ]); -}); - -const decodeCodexSettings = Schema.decodeSync(CodexSettings); -const decodeRequest = Schema.decodeUnknownSync( - Schema.fromJsonString( - Schema.Struct({ - id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Number])), - method: Schema.String, - }), - ), -); -const account = { - account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, - requiresOpenaiAuth: false, -} as const; - -const startProbe = Effect.fn("startProbe")(function* (hang: ReadonlyArray) { - const fs = yield* FileSystem.FileSystem; - const homePath = yield* fs.makeTempDirectoryScoped(); - yield* fs.writeFileString( - `${homePath}/models_cache.json`, - JSON.stringify({ fetched_at: DateTime.formatIso(yield* DateTime.now) }), - ); - const output = yield* Queue.unbounded(); - const requests = new Map( - yield* Effect.forEach( - [ - "account/read", - "skills/list", - "plugin/installed", - "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 === "plugin/installed") { - yield* reply(id, { marketplaces: [] }); - } 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 b27db9ca86b9b..74f4daa1323af 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 4293e800f56b1..5060d8a24e5ed 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 b03bd994a14bd..e1319caf69b16 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 fb1caf753f869..b9eb3b806ce0a 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 9b3f6f27570fe..975eb841b67a3 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.fork.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.fork.test.ts new file mode 100644 index 0000000000000..cc2cf1705e10d --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkills.fork.test.ts @@ -0,0 +1,400 @@ +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 * as Schema from "effect/Schema"; + +import { discoverClaudeSkills, discoverClaudeSkillsAndPlugins } from "./ClaudeSkills.ts"; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + contents: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); +}); + +it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("keeps plugin enablement when skill overrides are invalid", () => + 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 installPath = path.join(configDir, "plugins", "kept"); + yield* writeSkill(path.join(installPath, "skills"), "review", "# Review"); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ + plugins: { "kept@shop": [{ scope: "user", installPath }] }, + }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + JSON.stringify({ + skillOverrides: { invalid: false }, + enabledPlugins: { "kept@shop": true }, + }), + ); + const result = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); + assert.deepEqual( + result.skills.map((skill) => skill.name), + ["kept:review"], + ); + assert.deepEqual( + result.plugins.map((plugin) => plugin.name), + ["kept"], + ); + }), + ); + it.effect("merges plugin enablement and selects installs in native registry order", () => + 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"); + const nestedWorkspace = path.join(workspace, "packages", "app"); + const aliasedWorkspace = path.join(tempDir, "aliased-workspace"); + const userInstall = path.join(configDir, "plugins", "user"); + const projectInstall = path.join(configDir, "plugins", "project"); + const localInstall = path.join(configDir, "plugins", "local"); + const managedInstall = path.join(configDir, "plugins", "managed"); + yield* fs.makeDirectory(path.join(workspace, ".git"), { recursive: true }); + yield* fs.makeDirectory(nestedWorkspace, { recursive: true }); + yield* fs.symlink(workspace, aliasedWorkspace); + for (const install of [userInstall, projectInstall, localInstall, managedInstall]) { + yield* writeSkill(path.join(install, "skills"), "review", "# Review"); + } + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ + plugins: { + "plugin@shop": [ + { scope: "project", projectPath: aliasedWorkspace, installPath: localInstall }, + { scope: "project", projectPath: workspace, installPath: projectInstall }, + { scope: "user", installPath: userInstall }, + { scope: "managed", installPath: managedInstall }, + ], + "local@shop": [ + { scope: "local", projectPath: workspace, installPath: localInstall }, + { scope: "project", projectPath: workspace, installPath: projectInstall }, + { scope: "managed", installPath: path.join(configDir, "plugins", "missing") }, + { scope: "managed", installPath: managedInstall }, + { scope: "user", installPath: userInstall }, + ], + }, + }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":false}}', + ); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.json"), + '{"enabledPlugins":{"plugin@shop":false,"local@shop":true,"other@shop":["^1.0.0"]}}', + ); + const projectSettings = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + assert.deepEqual( + projectSettings.map((skill) => [skill.name, skill.scope, skill.path]), + [["local:review", "project", path.join(localInstall, "skills", "review", "SKILL.md")]], + ); + assert.deepEqual( + (yield* discoverClaudeSkills({ homePath: configDir }, nestedWorkspace)).map((skill) => [ + skill.name, + skill.scope, + skill.path, + ]), + [["plugin:review", "project", path.join(projectInstall, "skills", "review", "SKILL.md")]], + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":false},"skillOverrides":{"invalid":"bogus"}}', + ); + assert.deepEqual( + (yield* discoverClaudeSkills({ homePath: configDir }, workspace)).map( + (skill) => skill.name, + ), + ["plugin:review"], + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":true}}', + ); + const localSettings = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + assert.deepEqual( + localSettings.map((skill) => [skill.name, skill.scope, skill.path]), + [ + ["local:review", "project", path.join(localInstall, "skills", "review", "SKILL.md")], + ["plugin:review", "project", path.join(projectInstall, "skills", "review", "SKILL.md")], + ], + ); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, nestedWorkspace), + localSettings, + ); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, aliasedWorkspace), + localSettings, + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{"enabledPlugins":{"local@shop":["^1.0.0"]}}', + ); + assert.deepEqual(yield* discoverClaudeSkills({ homePath: configDir }, workspace), []); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true,"local@shop":true}}', + ); + const separateRepository = path.join(workspace, "separate"); + yield* fs.makeDirectory(path.join(separateRepository, ".git"), { recursive: true }); + for (const cwd of [undefined, path.join(tempDir, "elsewhere"), separateRepository]) { + const elsewhere = yield* discoverClaudeSkills({ homePath: configDir }, cwd); + assert.deepEqual( + elsewhere.map((skill) => [skill.name, skill.scope, skill.path]), + [ + ["local:review", "user", path.join(managedInstall, "skills", "review", "SKILL.md")], + ["plugin:review", "user", path.join(userInstall, "skills", "review", "SKILL.md")], + ], + ); + } + const worktree = path.join(tempDir, "worktree"); + const worktreeGitDirectory = path.join(workspace, ".git", "worktrees", "fixture"); + yield* fs.makeDirectory(worktree); + yield* fs.makeDirectory(worktreeGitDirectory, { recursive: true }); + yield* fs.writeFileString(path.join(worktree, ".git"), `gitdir: ${worktreeGitDirectory}\n`); + yield* fs.writeFileString(path.join(worktreeGitDirectory, "commondir"), "../..\n"); + yield* fs.writeFileString( + path.join(worktreeGitDirectory, "gitdir"), + path.join(worktree, ".git"), + ); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, worktree), + localSettings, + ); + const linkedWorktree = path.join(tempDir, "linked-worktree"); + yield* fs.symlink(worktree, linkedWorktree); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, linkedWorktree), + localSettings, + ); + }), + ); + + it.effect("adds skills of enabled plugins under their namespaced names", () => + 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"); + const cache = path.join(configDir, "plugins", "cache"); + const install = (name: string, version: string) => path.join(cache, "mk", name, version); + + yield* writeSkill( + path.join(install("alpha", "1"), "skills"), + "build", + ["---", "description: Build it.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(install("alpha", "1"), "skills"), + "internal", + ["---", "user-invocable: false", "---"].join("\n"), + ); + yield* writeSkill(path.join(install("alpha", "project"), "skills"), "local-only", ""); + yield* writeSkill(path.join(install("beta", "1"), "skills"), "hidden", ""); + yield* writeSkill(path.join(install("gamma", "1"), "skills"), "hidden", ""); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + toJson({ + version: 2, + plugins: { + "alpha@mk": [ + { scope: "user", installPath: install("alpha", "1") }, + { + scope: "project", + projectPath: workspace, + installPath: install("alpha", "project"), + }, + ], + "beta@mk": [{ scope: "user", installPath: install("beta", "1") }], + "gamma@mk": [{ scope: "user", installPath: install("gamma", "1") }], + "delta@mk": [ + { scope: "project", projectPath: "/elsewhere", installPath: install("delta", "1") }, + ], + }, + }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + toJson({ + enabledPlugins: { + "alpha@mk": true, + "beta@mk": false, + "gamma@mk": true, + "delta@mk": true, + }, + }), + ); + // Project settings layer over the user file, as for skillOverrides. + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.json"), + toJson({ enabledPlugins: { "gamma@mk": false } }), + ); + + const discovered = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }, workspace); + + // The native registry order picks the user's alpha install first; + // delta is installed only for another project. + assert.deepEqual( + discovered.skills.map((skill) => [skill.name, skill.scope, skill.pluginName]), + [ + ["alpha:build", "plugin", "alpha"], + ["alpha:internal", "plugin", "alpha"], + ], + ); + assert.deepEqual(discovered.plugins, [{ name: "alpha", marketplace: "mk", skillCount: 2 }]); + + const outsideProject = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); + assert.deepEqual(outsideProject.skills, [ + { + name: "alpha:build", + path: path.join(install("alpha", "1"), "skills", "build", "SKILL.md"), + enabled: true, + scope: "plugin", + pluginName: "alpha", + description: "Build it.", + }, + { + name: "alpha:internal", + path: path.join(install("alpha", "1"), "skills", "internal", "SKILL.md"), + enabled: true, + scope: "plugin", + pluginName: "alpha", + userInvocable: false, + }, + { + name: "gamma:hidden", + path: path.join(install("gamma", "1"), "skills", "hidden", "SKILL.md"), + enabled: true, + scope: "plugin", + pluginName: "gamma", + }, + ]); + }), + ); + + it.effect("follows the skill paths a plugin manifest lists", () => + 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 installPath = path.join(configDir, "plugins", "cache", "mk", "matt", "1"); + const skills = path.join(installPath, "skills"); + // Grouped in folders, as mattpocock-skills is: one listed skill, one listed folder of + // skills, and an unlisted folder that stays out. + yield* writeSkill( + path.join(skills, "engineering"), + "tdd", + ["---", "disable-model-invocation: true", "---"].join("\n"), + ); + yield* writeSkill(path.join(skills, "misc"), "setup", ""); + yield* writeSkill(path.join(skills, "deprecated"), "old", ""); + yield* fs.makeDirectory(path.join(installPath, ".claude-plugin")); + yield* fs.writeFileString( + path.join(installPath, ".claude-plugin", "plugin.json"), + toJson({ name: "matt", skills: ["./skills/engineering/tdd", "./skills/misc/"] }), + ); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + toJson({ plugins: { "matt@mk": [{ scope: "user", installPath }] } }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + toJson({ enabledPlugins: { "matt@mk": true } }), + ); + + const discovered = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); + + assert.deepEqual( + discovered.skills.map((skill) => [skill.name, skill.userInvocationOnly === true]), + [ + ["matt:setup", false], + ["matt:tdd", true], + ], + ); + assert.deepEqual(discovered.plugins, [{ name: "matt", marketplace: "mk", skillCount: 2 }]); + }), + ); + + it.effect("follows a symlinked plugins directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + // T3's provider home links `plugins` to the user's real Claude home. + const realPlugins = path.join(tempDir, "real-home", "plugins"); + const configDir = path.join(tempDir, "provider-home"); + const installPath = path.join(configDir, "plugins", "cache", "mk", "cse", "1"); + yield* writeSkill(path.join(realPlugins, "cache", "mk", "cse", "1", "skills"), "build", ""); + yield* fs.writeFileString( + path.join(realPlugins, "installed_plugins.json"), + toJson({ plugins: { "cse@mk": [{ scope: "user", installPath }] } }), + ); + yield* fs.makeDirectory(configDir, { recursive: true }); + yield* fs.symlink(realPlugins, path.join(configDir, "plugins")); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + toJson({ enabledPlugins: { "cse@mk": true } }), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["cse:build"], + ); + }), + ); + + it.effect("keeps other skills when installed_plugins.json is missing or malformed", () => + 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"), "mine", ""); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + toJson({ enabledPlugins: { "cse@mk": true } }), + ); + + const missing = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); + assert.deepEqual( + [missing.skills.map((skill) => skill.name), missing.plugins], + [["mine"], []], + ); + + yield* fs.makeDirectory(path.join(configDir, "plugins")); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + '{ "plugins": { "cse@mk": "not-a-list" } }', + ); + const malformed = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); + assert.deepEqual( + [malformed.skills.map((skill) => skill.name), malformed.plugins], + [["mine"], []], + ); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index c601dcdcfbd2e..1fb6583a1264e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -1,20 +1,12 @@ -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 * as Schema from "effect/Schema"; -import { - discoverClaudeSkills, - discoverClaudeSkillsAndPlugins, - skillOverrideSettingsPaths, -} from "./ClaudeSkills.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; -const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - const writeSkill = Effect.fn(function* ( skillsDir: string, directoryName: string, @@ -84,38 +76,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("keeps plugin enablement when skill overrides are invalid", () => - 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 installPath = path.join(configDir, "plugins", "kept"); - yield* writeSkill(path.join(installPath, "skills"), "review", "# Review"); - yield* fs.writeFileString( - path.join(configDir, "plugins", "installed_plugins.json"), - JSON.stringify({ - plugins: { "kept@shop": [{ scope: "user", installPath }] }, - }), - ); - yield* fs.writeFileString( - path.join(configDir, "settings.json"), - JSON.stringify({ - skillOverrides: { invalid: false }, - enabledPlugins: { "kept@shop": true }, - }), - ); - const result = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); - assert.deepEqual( - result.skills.map((skill) => skill.name), - ["kept:review"], - ); - assert.deepEqual( - result.plugins.map((plugin) => plugin.name), - ["kept"], - ); - }), - ); it.effect("merges plugin enablement and selects installs in native registry order", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -441,28 +401,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; @@ -501,33 +439,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; @@ -672,48 +583,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; @@ -914,86 +783,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; @@ -1020,6 +809,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; @@ -1086,228 +876,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, []); - }), - ); - - it.effect("adds skills of enabled plugins under their namespaced names", () => - 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"); - const cache = path.join(configDir, "plugins", "cache"); - const install = (name: string, version: string) => path.join(cache, "mk", name, version); - - yield* writeSkill( - path.join(install("alpha", "1"), "skills"), - "build", - ["---", "description: Build it.", "---"].join("\n"), - ); - yield* writeSkill( - path.join(install("alpha", "1"), "skills"), - "internal", - ["---", "user-invocable: false", "---"].join("\n"), - ); - yield* writeSkill(path.join(install("alpha", "project"), "skills"), "local-only", ""); - yield* writeSkill(path.join(install("beta", "1"), "skills"), "hidden", ""); - yield* writeSkill(path.join(install("gamma", "1"), "skills"), "hidden", ""); - yield* fs.writeFileString( - path.join(configDir, "plugins", "installed_plugins.json"), - toJson({ - version: 2, - plugins: { - "alpha@mk": [ - { scope: "user", installPath: install("alpha", "1") }, - { - scope: "project", - projectPath: workspace, - installPath: install("alpha", "project"), - }, - ], - "beta@mk": [{ scope: "user", installPath: install("beta", "1") }], - "gamma@mk": [{ scope: "user", installPath: install("gamma", "1") }], - "delta@mk": [ - { scope: "project", projectPath: "/elsewhere", installPath: install("delta", "1") }, - ], - }, - }), - ); - yield* fs.writeFileString( - path.join(configDir, "settings.json"), - toJson({ - enabledPlugins: { - "alpha@mk": true, - "beta@mk": false, - "gamma@mk": true, - "delta@mk": true, - }, - }), - ); - // Project settings layer over the user file, as for skillOverrides. - yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); - yield* fs.writeFileString( - path.join(workspace, ".claude", "settings.json"), - toJson({ enabledPlugins: { "gamma@mk": false } }), - ); - - const discovered = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }, workspace); - - // The native registry order picks the user's alpha install first; - // delta is installed only for another project. - assert.deepEqual( - discovered.skills.map((skill) => [skill.name, skill.scope, skill.pluginName]), - [ - ["alpha:build", "plugin", "alpha"], - ["alpha:internal", "plugin", "alpha"], - ], - ); - assert.deepEqual(discovered.plugins, [{ name: "alpha", marketplace: "mk", skillCount: 2 }]); - - const outsideProject = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); - assert.deepEqual(outsideProject.skills, [ - { - name: "alpha:build", - path: path.join(install("alpha", "1"), "skills", "build", "SKILL.md"), - enabled: true, - scope: "plugin", - pluginName: "alpha", - description: "Build it.", - }, - { - name: "alpha:internal", - path: path.join(install("alpha", "1"), "skills", "internal", "SKILL.md"), - enabled: true, - scope: "plugin", - pluginName: "alpha", - userInvocable: false, - }, - { - name: "gamma:hidden", - path: path.join(install("gamma", "1"), "skills", "hidden", "SKILL.md"), - enabled: true, - scope: "plugin", - pluginName: "gamma", - }, - ]); - }), - ); - - it.effect("follows the skill paths a plugin manifest lists", () => - 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 installPath = path.join(configDir, "plugins", "cache", "mk", "matt", "1"); - const skills = path.join(installPath, "skills"); - // Grouped in folders, as mattpocock-skills is: one listed skill, one listed folder of - // skills, and an unlisted folder that stays out. - yield* writeSkill( - path.join(skills, "engineering"), - "tdd", - ["---", "disable-model-invocation: true", "---"].join("\n"), - ); - yield* writeSkill(path.join(skills, "misc"), "setup", ""); - yield* writeSkill(path.join(skills, "deprecated"), "old", ""); - yield* fs.makeDirectory(path.join(installPath, ".claude-plugin")); - yield* fs.writeFileString( - path.join(installPath, ".claude-plugin", "plugin.json"), - toJson({ name: "matt", skills: ["./skills/engineering/tdd", "./skills/misc/"] }), - ); - yield* fs.writeFileString( - path.join(configDir, "plugins", "installed_plugins.json"), - toJson({ plugins: { "matt@mk": [{ scope: "user", installPath }] } }), - ); - yield* fs.writeFileString( - path.join(configDir, "settings.json"), - toJson({ enabledPlugins: { "matt@mk": true } }), - ); - - const discovered = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); - - assert.deepEqual( - discovered.skills.map((skill) => [skill.name, skill.userInvocationOnly === true]), - [ - ["matt:setup", false], - ["matt:tdd", true], - ], - ); - assert.deepEqual(discovered.plugins, [{ name: "matt", marketplace: "mk", skillCount: 2 }]); - }), - ); - - it.effect("follows a symlinked plugins directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - // T3's provider home links `plugins` to the user's real Claude home. - const realPlugins = path.join(tempDir, "real-home", "plugins"); - const configDir = path.join(tempDir, "provider-home"); - const installPath = path.join(configDir, "plugins", "cache", "mk", "cse", "1"); - yield* writeSkill(path.join(realPlugins, "cache", "mk", "cse", "1", "skills"), "build", ""); - yield* fs.writeFileString( - path.join(realPlugins, "installed_plugins.json"), - toJson({ plugins: { "cse@mk": [{ scope: "user", installPath }] } }), - ); - yield* fs.makeDirectory(configDir, { recursive: true }); - yield* fs.symlink(realPlugins, path.join(configDir, "plugins")); - yield* fs.writeFileString( - path.join(configDir, "settings.json"), - toJson({ enabledPlugins: { "cse@mk": true } }), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }); - - assert.deepEqual( - skills.map((skill) => skill.name), - ["cse:build"], - ); - }), - ); - - it.effect("keeps other skills when installed_plugins.json is missing or malformed", () => - 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"), "mine", ""); - yield* fs.writeFileString( - path.join(configDir, "settings.json"), - toJson({ enabledPlugins: { "cse@mk": true } }), - ); - - const missing = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); - assert.deepEqual( - [missing.skills.map((skill) => skill.name), missing.plugins], - [["mine"], []], - ); - - yield* fs.makeDirectory(path.join(configDir, "plugins")); - yield* fs.writeFileString( - path.join(configDir, "plugins", "installed_plugins.json"), - '{ "plugins": { "cse@mk": "not-a-list" } }', - ); - const malformed = yield* discoverClaudeSkillsAndPlugins({ homePath: configDir }); - assert.deepEqual( - [malformed.skills.map((skill) => skill.name), malformed.plugins], - [["mine"], []], - ); - }), - ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index b0cacf3eec4bd..2643a660c901b 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -129,7 +129,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 e6c0e87b4df48..2ccc555256f5b 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/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index b6682d974b7e6..5452560d28917 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -76,6 +76,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { nativeUpdate: { args: ["update"], isCommandPath: isCodexStandaloneCommandPath, + requiresCommandPath: true, env: { CODEX_HOME: sharedHomePath }, }, }); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 7434552471e3a..093777e67db5d 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 e81a144210477..7980c8d0fad07 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 0ed7843249861..1db2197a53e55 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 2037b321437b6..11f16631bca8f 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(); @@ -187,6 +182,123 @@ describe("parseCodexLine", () => { } }); + it.each(["continuation", "fork", "subagent"])( + "bills only the latest request when a %s has no initial counter baseline", + (source) => { + const state = initialCodexScanState(); + parseCodexLine( + JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T05:17:41.000Z", + payload: { + id: "child", + model_provider: "openai", + ...(source === "fork" ? { forked_from_id: "parent" } : {}), + ...(source === "subagent" + ? { source: { subagent: { thread_spawn: { parent_thread_id: "parent" } } } } + : { source: "vscode" }), + }, + }), + state, + ); + parseCodexLine(turnContext, state); + const request = { + ...counters(100, 20), + cached_input_tokens: 30, + cache_write_input_tokens: 10, + reasoning_output_tokens: 5, + }; + const cumulative = { + ...counters(500, 60), + cached_input_tokens: 200, + cache_write_input_tokens: 20, + reasoning_output_tokens: 15, + }; + const first = counted(request, cumulative); + expect(parseCodexLine(first, state)).toMatchObject({ + sessionId: "child", + totals: { + uncachedInputTokens: 60, + cachedInputTokens: 30, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 5, + }, + }); + expect(parseCodexLine(first, state)).toBeNull(); + expect(parseCodexLine(counted(counters(10, 2), cumulative), state)).toBeNull(); + expect( + parseCodexLine( + counted(counters(10, 2), { + ...cumulative, + input_tokens: 510, + output_tokens: 62, + total_tokens: 572, + }), + state, + )?.totals.outputTokens, + ).toBe(2); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); + }, + ); + + it.each([ + { + ...counters(90, 40), + cached_input_tokens: 60, + cache_write_input_tokens: 20, + reasoning_output_tokens: 10, + }, + { + ...counters(200, 10), + cached_input_tokens: 60, + cache_write_input_tokens: 20, + reasoning_output_tokens: 5, + }, + { + ...counters(200, 40), + cached_input_tokens: 20, + cache_write_input_tokens: 20, + reasoning_output_tokens: 10, + }, + { + ...counters(200, 40), + cached_input_tokens: 60, + cache_write_input_tokens: 5, + reasoning_output_tokens: 10, + }, + { + ...counters(200, 40), + cached_input_tokens: 60, + cache_write_input_tokens: 20, + reasoning_output_tokens: 3, + }, + { + ...counters(110, 40), + cached_input_tokens: 60, + cache_write_input_tokens: 20, + reasoning_output_tokens: 10, + }, + { + ...counters(200, 30), + cached_input_tokens: 60, + cache_write_input_tokens: 20, + reasoning_output_tokens: 20, + }, + ])("rejects an initial snapshot with an impossible inherited baseline: %j", (cumulative) => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const request = { + ...counters(100, 20), + cached_input_tokens: 30, + cache_write_input_tokens: 10, + reasoning_output_tokens: 5, + }; + expect(parseCodexLine(counted(request, cumulative), state)).toBeNull(); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(1); + expect(state.lastCumulativeUsage).toEqual(cumulative); + }); + it.each(["request", "cumulative"])("skips malformed %s counters and recovers", (malformed) => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); @@ -231,24 +343,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 +476,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 20aa59e141141..d3fd0680e31c8 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,9 +241,17 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const signature = JSON.stringify(last); if (cumulative !== null) { - if ( - !CODEX_USAGE_FIELDS.every((key) => cumulative[key] - (previous?.[key] ?? 0) === last[key]) - ) { + // 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]); + // Forks and paginated continuations can inherit totals without copying a counter snapshot. + // Only bill the latest request, and require the unseen prefix to be valid usage too. + const reconciled = + previous === null + ? readCodexTokenUsage( + Object.fromEntries(CODEX_USAGE_FIELDS.map((key) => [key, cumulative[key] - last[key]])), + ) !== null + : CODEX_USAGE_FIELDS.every((key) => cumulative[key] - previous[key] === last[key]); + if (!restarted && !reconciled) { state.malformedRecords += 1; return null; } @@ -325,7 +329,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 e1af016095ee3..c932ac2fa5e5a 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 093ecbe42e5d5..373d9773280a0 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 635e5aa789113..560b702db3aa9 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 5ca237f2a6691..ddc3bdbaa0c17 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 01fd35c0c598d..0000000000000 --- 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 ae42e9b00c9ca..aa7d205271494 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 5f7dcd4410cf4..a5f8ff25aad56 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 1c95a255c7ad0..76fdea83a21f7 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"; @@ -106,28 +97,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => customModels: [], launchArgs: "", autoCompactWindow: "", - ...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: [], + idleCompaction: "default", ...overrides, }); @@ -218,79 +188,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 +212,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; @@ -752,17 +613,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" @@ -829,196 +679,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 16b3958f3613c..b825b29b674da 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.fork.test.ts b/apps/server/src/provider/ProviderRegistry.fork.test.ts new file mode 100644 index 0000000000000..8a4b8e769bb8b --- /dev/null +++ b/apps/server/src/provider/ProviderRegistry.fork.test.ts @@ -0,0 +1,133 @@ +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 } from "@effect/vitest"; + +import * as Effect from "effect/Effect"; + +import * as Layer from "effect/Layer"; + +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 { EnvironmentId, ClaudeSettings } from "@t3tools/contracts"; + +import { HttpClient, HttpClientResponse } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; + +import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; + +import * as ModelManifest from "./ModelManifest.ts"; + +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; + +import * as ServerSettingsModule from "../serverSettings.ts"; + +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + +const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); + +process.env.T3CODE_CURSOR_ENABLED = "1"; + +const encoder = new TextEncoder(); + +const layerTestHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), + ), +).pipe(Layer.provideMerge(ModelManifest.layerTest)); + +function mockHandle(result: { stdout: string; stderr: string; code: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.make(encoder.encode(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function layerMockSpawner( + handler: (args: ReadonlyArray) => { + stdout: string; + stderr: string; + code: number; + }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); +} + +const layerTestNodeServices = Layer.mergeAll( + ProviderLatestVersions.layer, + NodeServices.layer, + Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "unused-managed-installation", + }), + Layer.mock(ServerSecretStore.ServerSecretStore)({}), + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), + }), +); + +it.layer( + Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient), +)("ProviderRegistry", (it) => { + describe("checkClaudeProviderStatus", () => { + it.effect("keeps the original usage timestamp when capabilities are cached", () => + Effect.gen(function* () { + const usageCheckedAt = "1970-01-01T00:00:00.000Z"; + const cachedCapabilities = () => + Effect.succeed({ + email: undefined, + subscriptionType: "max", + tokenSource: "oauth", + apiKeySource: undefined, + apiProvider: undefined, + slashCommands: [], + usageCheckedAt, + usage: { + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 84, resets_at: null } }, + }, + }); + const first = yield* checkClaudeProviderStatus(defaultClaudeSettings, cachedCapabilities); + yield* TestClock.adjust("1 minute"); + const refreshed = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + cachedCapabilities, + ); + + assert.notStrictEqual(refreshed.checkedAt, first.checkedAt); + assert.strictEqual(first.usageLimits?.checkedAt, usageCheckedAt); + assert.strictEqual(refreshed.usageLimits?.checkedAt, usageCheckedAt); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + if (args.join(" ") === "--version") { + return { stdout: "2.1.288\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${args.join(" ")}`); + }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 0382fa7149a99..cd056b7b6d8f3 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", [ @@ -368,7 +259,17 @@ function makeMutableServerSettingsService( start: Effect.void, ready: Effect.void, getSettings: Ref.get(settingsRef), - getDontAskMode: Effect.succeed(false), + getDontAskMode: Ref.get(settingsRef).pipe(Effect.map((settings) => settings.dontAskMode)), + updateSettingsWith: (update) => + Effect.gen(function* () { + const next = applyServerSettingsPatch( + yield* Ref.get(settingsRef), + update(yield* Ref.get(settingsRef)), + ); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }), updateSettings: (patch) => Effect.gen(function* () { const current = yield* Ref.get(settingsRef); @@ -390,7 +291,6 @@ function makeMutableServerSettingsService( yield* PubSub.publish(changes, next); return next; }), - updateSettingsWith: () => Effect.die("Unexpected settings function update"), withSettingsSnapshot: (use) => Ref.get(settingsRef).pipe(Effect.flatMap(use)), get streamChanges() { return Stream.fromPubSub(changes); @@ -488,21 +388,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, () => @@ -542,114 +427,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", () => { @@ -832,45 +609,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"), @@ -904,73 +642,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"), @@ -1027,55 +698,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"), @@ -1128,116 +750,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", @@ -1301,27 +813,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, @@ -1481,55 +972,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; @@ -1644,65 +1086,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, @@ -1780,31 +1163,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" } }, @@ -1893,57 +1251,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, @@ -1957,655 +1323,21 @@ 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 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: Ref.update(cacheInvalidations, (count) => count + 1).pipe(Effect.as(provider)), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - snapshotForCwd, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const firstInstance = makeInstance(machineProvider, () => - Effect.gen(function* () { - yield* Ref.update(snapshotCalls, (count) => count + 1); - if (yield* Ref.get(failScan)) { - return yield* new ProviderDriverError({ - driver, - instanceId, - detail: "The workspace scan failed.", - }); - } - yield* Deferred.succeed(probeStarted, undefined); - yield* Deferred.await(releaseProbe); - const result = yield* Ref.get(scopedResult); - const gate = yield* Ref.getAndSet(scanGate, null); - if (gate) { - yield* Deferred.succeed(gate.started, undefined); - yield* Deferred.await(gate.release); - } - return result; - }), - ); - const rebuiltProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - status: "warning", - installed: false, - auth: { status: "unknown" }, - } satisfies ServerProvider; - const rebuiltInstance = makeInstance(rebuiltProvider, () => - Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), - ); - const registryChanges = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([firstInstance]); - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (requestedId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === requestedId), - ), - ), - listInstances: Ref.get(instancesRef), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(registryChanges), - subscribeChanges: PubSub.subscribe(registryChanges), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-workspace-snapshot-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); - yield* Ref.set(failScan, false); - const workspaceUpdate = yield* registry.streamChanges.pipe( - Stream.runHead, - Effect.forkChild, - ); - yield* Effect.yieldNow; - const firstRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Deferred.await(probeStarted); - const duplicateRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); - yield* Deferred.succeed(releaseProbe, undefined); - yield* Fiber.join(firstRefresh); - yield* Fiber.join(duplicateRefresh); - const published = yield* Fiber.join(workspaceUpdate); - assert.strictEqual(published._tag, "Some"); - const providers = yield* registry.getProviders; - assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); - assert.deepStrictEqual( - providers[0]?.workspaceSnapshots?.[0]?.skills, - scopedProvider.skills, - ); - assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ - COMPACT_SLASH_COMMAND, - ]); - assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); - yield* Ref.set(scopedResult, { - ...scopedProvider, - status: "error", - slashCommandsPending: false, - }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, - scopedProvider.slashCommands, - ); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, - undefined, - ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - yield* TestClock.adjust(1); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 4); - const newSkills = [ - ...scopedProvider.skills, - { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 5); - assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [newSkills], - ); - - const slowStarted = yield* Deferred.make(); - const releaseSlow = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); - yield* Ref.set(scopedResult, scopedProvider); - const slowScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(slowStarted); - const latestSkills = [ - ...newSkills, - { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - yield* Deferred.succeed(releaseSlow, undefined); - yield* Fiber.join(slowScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [latestSkills], - ); - - // With no cached snapshot, an older scan must leave publication and - // request deduplication to the forced scan even when it finishes first. - const olderStarted = yield* Deferred.make(); - const releaseOlder = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: olderStarted, release: releaseOlder }); - yield* Ref.set(scopedResult, scopedProvider); - const olderScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace2" }) - .pipe(Effect.forkChild); - yield* Deferred.await(olderStarted); - const newerStarted = yield* Deferred.make(); - const releaseNewer = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: newerStarted, release: releaseNewer }); - yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); - const newerScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace2", fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(newerStarted); - yield* Deferred.succeed(releaseOlder, undefined); - yield* Fiber.join(olderScan); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (snapshot) => snapshot.cwd === "/workspace2", - ), - undefined, - ); - const callsWhileForced = yield* Ref.get(snapshotCalls); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace2" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), callsWhileForced); - yield* Deferred.succeed(releaseNewer, undefined); - yield* Fiber.join(newerScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (snapshot) => snapshot.cwd === "/workspace2", - )?.skills, - latestSkills, - ); - - yield* Ref.set(instancesRef, [rebuiltInstance]); - yield* PubSub.publish(registryChanges, undefined); - let rebuilt = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - rebuilt = yield* registry.getProviders; - } - assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); - assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("publishes workspace skills while machine health is in error", () => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); - const healthyProvider = { - instanceId, - driver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [COMPACT_SLASH_COMMAND], - skills: [], - } as const satisfies ServerProvider; - // Codex's account check failed; its skill scan did not. - const unhealthyProvider = { - ...healthyProvider, - status: "error", - auth: { status: "unknown" }, - message: "Codex app-server provider probe failed: unauthorized (401).", - slashCommands: [], - } as const satisfies ServerProvider; - const firstSkills = [{ name: "first", path: "/workspace/first/SKILL.md", enabled: true }]; - const laterSkills = [{ name: "later", path: "/workspace/later/SKILL.md", enabled: true }]; - const scanResult = yield* Ref.make({ - ...healthyProvider, - skills: firstSkills, - }); - const instance: ProviderInstance = { - instanceId, - driverKind: driver, - continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, - 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]); + 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. @@ -2651,254 +1383,6 @@ it.layer( }), ); - it.effect("refreshes OpenCode catalogs and preserves other providers", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const openCodeDriver = ProviderDriverKind.make("opencode"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const openCodeInstanceId = ProviderInstanceId.make("opencode"); - const codexRefreshCalls = yield* Ref.make(0); - const openCodeRefreshCalls = yield* Ref.make(0); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const failedOpenCodeProvider = { - instanceId: openCodeInstanceId, - driver: openCodeDriver, - status: "error", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - message: "Failed to refresh OpenCode models.", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const recoveredOpenCodeProvider = { - ...failedOpenCodeProvider, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:01:00.000Z", - message: "One upstream provider connected through OpenCode.", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const changedCatalogProvider = { - ...recoveredOpenCodeProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - models: [ - { - slug: "anthropic/claude-sonnet-4", - name: "Claude Sonnet 4", - subProvider: "Anthropic", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const catalogSnapshot = yield* Ref.make(recoveredOpenCodeProvider); - const instances = [ - { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(codexProvider), - refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( - Effect.as(codexProvider), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - { - instanceId: openCodeInstanceId, - driverKind: openCodeDriver, - continuationIdentity: { - driverKind: openCodeDriver, - continuationKey: "opencode:instance:opencode", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: openCodeDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(failedOpenCodeProvider), - refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( - Effect.andThen(Ref.get(catalogSnapshot)), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - ] satisfies ReadonlyArray; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-reconnect-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const initialProviders = yield* registry.getProviders; - assert.strictEqual( - initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, - "error", - ); - - const recoveredProviders = yield* registry.refresh(); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) - ?.models, - recoveredOpenCodeProvider.models, - ); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - - yield* Ref.set(catalogSnapshot, changedCatalogProvider); - const changedProviders = yield* registry.refresh(); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models, - changedCatalogProvider.models, - ); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - }).pipe(Effect.provide(runtimeServices)); - - assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); - assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); - }), - ); - - it("persists merged provider snapshots for the providers that were refreshed", () => { - const previousProviders = [ - { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - const refreshedCursor = { - ...previousProviders[0], - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - const mergedProviders = ProviderRegistry.mergeProviderSnapshots(previousProviders, [ - refreshedCursor, - ]); - const persistedProviders = ProviderRegistry.selectProvidersByKind( - mergedProviders, - new Set([ProviderDriverKind.make("cursor")]), - ); - - assert.deepStrictEqual(persistedProviders, [ - { - ...refreshedCursor, - models: [...previousProviders[0].models], - }, - ]); - }); - it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); @@ -3290,126 +1774,10 @@ it.layer( 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, - continuationIdentity: { - driverKind: provider.driver, - continuationKey: `${provider.driver}:instance:${provider.instanceId}`, - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: provider.driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const codexInstance = makeInstance(codexProvider); - const claudeInstance = makeInstance(claudeProvider); - const changes = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([codexInstance]); - const failNextList = yield* Ref.make(false); - const wait = () => Effect.yieldNow; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), - ), - ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); - }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), }, ); const scope = yield* Scope.make(); @@ -3419,7 +1787,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), @@ -3429,120 +1797,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)); }), ); @@ -3858,396 +2122,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 the original usage timestamp when capabilities are cached", () => - Effect.gen(function* () { - const usageCheckedAt = "1970-01-01T00:00:00.000Z"; - const cachedCapabilities = () => - Effect.succeed({ - email: undefined, - subscriptionType: "max", - tokenSource: "oauth", - apiKeySource: undefined, - apiProvider: undefined, - slashCommands: [], - usageCheckedAt, - usage: { - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 84, resets_at: null } }, - }, - }); - const first = yield* checkClaudeProviderStatus(defaultClaudeSettings, cachedCapabilities); - yield* TestClock.adjust("1 minute"); - const refreshed = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - cachedCapabilities, - ); - - assert.notStrictEqual(refreshed.checkedAt, first.checkedAt); - assert.strictEqual(first.usageLimits?.checkedAt, usageCheckedAt); - assert.strictEqual(refreshed.usageLimits?.checkedAt, usageCheckedAt); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - if (args.join(" ") === "--version") { - return { stdout: "2.1.288\n", stderr: "", code: 0 }; - } - throw new Error(`Unexpected args: ${args.join(" ")}`); - }), - ), - ), - ); - 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: @@ -4373,27 +2276,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 @@ -4416,360 +2298,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 665c7488587dd..97da44aa93ed6 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 11fb306a904ec..36fee6411d760 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 cc911f2c60d0a..3385886629357 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 8bb03d3469cb9..b36c4afe91596 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 cfc7a6adfbe19..20d084c4bf81b 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 16a7df9633744..9138a28dab5b3 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 c32975dc7066a..4e4d32ffc1075 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 6fa7f65f84316..d494c4924d0e0 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 a8baa522b8e63..3d80993fc692b 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"; @@ -217,41 +214,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.fork.test.ts b/apps/server/src/provider/claudeUsageLimits.fork.test.ts new file mode 100644 index 0000000000000..f5cab33f769e3 --- /dev/null +++ b/apps/server/src/provider/claudeUsageLimits.fork.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; + +const checkedAt = "2026-07-18T10:00:00.000Z"; +const noNames = { overageIncluded: undefined } as const; + +describe("claudeUsageResponseToLimits", () => { + it("maps the session, weekly, and model-scoped weekly windows", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, + seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, + seven_day_opus: { utilization: 3, resets_at: null }, + // Newer CLIs add this on top of the typed keys; the pinned SDK + // typings do not know it yet. + ...({ + model_scoped: [ + { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, + { display_name: "Ghost", utilization: null, resets_at: null }, + ], + } as object), + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, + }, + }, + }, + }), + ).toEqual({ + names: { overageIncluded: "Fable" }, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 54, + windowDurationMins: 300, + resetsAt: "2026-07-18T14:39:00.000Z", + }, + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 18.4, + windowDurationMins: 10080, + resetsAt: "2026-07-24T08:59:00.000Z", + }, + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 73, + windowDurationMins: 10080, + resetsAt: "2026-07-24T08:59:00.000Z", + }, + { + id: "seven_day_opus", + kind: "weekly", + label: "Weekly · Opus", + usedPercent: 3, + windowDurationMins: 10080, + }, + ], + }, + }); + }); + + const enterpriseResponse = { + rate_limits_available: true, + rate_limits: { + five_hour: null, + seven_day: null, + spend: { + enabled: true, + used: { amount_minor: 4_631, currency: "USD", exponent: 2 }, + limit: { amount_minor: 50_000, currency: "USD", exponent: 2 }, + percent: 9, + }, + extra_usage: { + is_enabled: true, + used_credits: 4_631, + monthly_limit: 50_000, + utilization: 9.261999999999999, + currency: "USD", + decimal_places: 2, + }, + }, + }; + const monthlyWindow = { + id: "monthly_spend", + kind: "monthly", + label: "Monthly spend", + usedPercent: expect.closeTo(9.262, 10), + }; +}); + +describe("claudeRateLimitEventToUpdate", () => { + it("maps model-specific weekly events and ignores events without a utilization", () => { + expect( + claudeRateLimitEventToUpdate( + { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, + noNames, + ), + ).toEqual({ + windows: [ + { + id: "seven_day_opus", + kind: "weekly", + label: "Weekly · Opus", + usedPercent: 10, + windowDurationMins: 10080, + }, + ], + }); + expect( + claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index 3e56e90bdd025..b128e7fcd9e15 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -93,24 +93,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({ @@ -295,26 +277,4 @@ describe("claudeRateLimitEventToUpdate", () => { ], }); }); - - it("maps model-specific weekly events and ignores events without a utilization", () => { - expect( - claudeRateLimitEventToUpdate( - { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, - noNames, - ), - ).toEqual({ - windows: [ - { - id: "seven_day_opus", - kind: "weekly", - label: "Weekly · Opus", - usedPercent: 10, - windowDurationMins: 10080, - }, - ], - }); - 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 a7dfc81fae86b..ce18c8f3f1bd5 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.fork.test.ts b/apps/server/src/provider/codexUsageLimits.fork.test.ts new file mode 100644 index 0000000000000..c59e02ebebbde --- /dev/null +++ b/apps/server/src/provider/codexUsageLimits.fork.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { codexRateLimitsToLimits } from "./codexUsageLimits.ts"; + +const convert = (resetCredits: Parameters[0]["resetCredits"]) => + codexRateLimitsToLimits({ snapshot: {}, resetCredits, checkedAt: "2026-07-01T00:00:00.000Z" }) + .resetCredits; + +describe("codexResetCreditsToContract", () => { + it("counts available credits and reports the soonest expiry", () => { + expect( + convert({ + 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", + expiries: ["2026-07-14T03:33:20.000Z", "2026-07-19T22:26:40.000Z"], + }); + expect(convert({ availableCount: 0 })).toEqual({ availableCount: 0 }); + expect(convert(null)).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index b5080feb3e60b..51970a3a54726 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,43 +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", - expiries: ["2026-07-14T03:33:20.000Z", "2026-07-19T22:26:40.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", () => { @@ -281,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", () => { @@ -336,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 8c3389103c875..9f8115f853557 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/model-manifest.json b/apps/server/src/provider/model-manifest.json index 54be975b18346..e62f661fcfc2c 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-19T07:21:38Z", + "updatedAt": "2026-10-21T06:20:00Z", "compatibility": [ { "driver": "codex", @@ -134,7 +134,9 @@ { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, { "slug": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "status": "legacy" }, - { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" } + { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" }, + { "slug": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "status": "legacy" }, + { "slug": "gpt-5.3-codex-spark", "name": "GPT-5.3 Codex Spark", "status": "legacy" } ] }, "claudeAgent": { diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index c03a79fa19f84..dbb7a2bfc393d 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 af72c11a19f00..32d417a1d43d1 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 21fcdf0664355..43808ff37df87 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,134 +445,52 @@ 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); + it.effect("records command failure output in provider update state", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + const updater = yield* makeTestRunner(registry); - const result = yield* runner.updateProvider(CODEX_DRIVER); + const result = yield* updater.updateProvider(CODEX_DRIVER); + const updateState = result.providers[0]?.updateState; - 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" }; - }), - ), - ), + assert.strictEqual(updateState?.status, "failed"); + assert.strictEqual( + updateState?.message, + "Update command exited with code 1: Error: Could not detect the Codex installation method.", ); - }, - ); - - 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/); + assert.include(updateState?.output ?? "", "checking install"); }).pipe( Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ stdout: "updated" })), + layerMockSpawner(() => ({ + stdout: "checking install\n", + stderr: "Error: Could not detect the Codex installation method.\n\n", + code: 1, + })), ), ), ), ); - it.effect("records command failure output in provider update state", () => + it.effect("keeps the exit code message when a failed command prints nothing", () => Effect.gen(function* () { const { registry } = yield* makeRegistry(); const updater = yield* makeTestRunner(registry); const result = yield* updater.updateProvider(CODEX_DRIVER); - const updateState = result.providers[0]?.updateState; - assert.strictEqual(updateState?.status, "failed"); - assert.strictEqual(updateState?.message, "Update command exited with code 1."); - assert.include(updateState?.output ?? "", "permission denied"); + assert.strictEqual( + result.providers[0]?.updateState?.message, + "Update command exited with code 1.", + ); }).pipe( Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ stderr: "permission denied", code: 1 })), + layerMockSpawner(() => ({ code: 1 })), ), ), ), @@ -751,122 +569,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/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 7b59e1dea946f..43d8b1e3916ad 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -242,14 +242,30 @@ function commandOutput(result: ProviderMaintenanceCommandResult): string | null return truncateText(output, UPDATE_OUTPUT_MAX_BYTES); } +// Updaters print their reason last, usually on stderr. +function lastOutputLine(result: ProviderMaintenanceCommandResult): string | null { + for (const output of [result.stderr, result.stdout]) { + const line = output + .split(/\r\n|\r|\n/) + .map(toProgressLine) + .findLast(Boolean); + if (line) { + return line; + } + } + return null; +} + function failureMessage(result: ProviderMaintenanceCommandResult): string { if (result.timedOut) { return "Update timed out."; } - if (result.exitCode !== null && result.exitCode !== 0) { - return `Update command exited with code ${result.exitCode}.`; - } - return "Update command failed."; + const summary = + result.exitCode !== null && result.exitCode !== 0 + ? `Update command exited with code ${result.exitCode}` + : "Update command failed"; + const reason = lastOutputLine(result); + return reason ? `${summary}: ${reason}` : `${summary}.`; } function isOutdatedProvider(provider: ServerProvider | undefined): boolean { @@ -474,6 +490,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { + yield* Effect.logWarning("Provider update command failed", { + provider, + command: describeCommand(command), + exitCode: result.exitCode, + timedOut: result.timedOut, + output: commandOutput(result), + }); return yield* finish( makeUpdateState({ status: "failed", diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index cd7bfff273f86..79cb0bfbf5fdc 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 f85dba5e6b5f2..0000000000000 --- 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 4b8f3c38ac13f..0000000000000 --- 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 c6d55b8a24fa1..2d2fcfc1f05a8 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 62466e1827311..8d9691fd79e30 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.fork.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.fork.test.ts new file mode 100644 index 0000000000000..b452f4074d6d1 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.fork.test.ts @@ -0,0 +1,344 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +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 Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import { + ProjectId, + type PullRequestActivity, + type PullRequestDetail, + 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"; +import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as PullRequestReviewGuideService from "./PullRequestReviewGuideService.ts"; +import * as PullRequestService from "./PullRequestService.ts"; + +const projectId = ProjectId.make("project-1"); +const ACME_APP = { + canonicalKey: "github.com/acme/app", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/acme/app.git", + }, + provider: "github", + displayName: "acme/app", + owner: "acme", + name: "app", +}; + +const reference: PullRequestRef = { + projectId, + host: "github.com", + repository: "acme/app", + number: 7, +}; + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ operation: "test.git", cwd, args, timeoutMs: 10_000 }); + return result.stdout.trim(); + }); + +const makeRepositories = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-guide-" }); + const remote = `${root}/remote`; + const workspace = `${root}/workspace`; + yield* fs.makeDirectory(remote); + yield* git(remote, ["init", "--quiet", "--initial-branch=main"]); + yield* git(remote, ["config", "user.email", "test@example.com"]); + yield* git(remote, ["config", "user.name", "Test"]); + const commit = (path: string, message: string, contents = message) => + fs + .writeFileString(`${remote}/${path}`, contents) + .pipe( + Effect.andThen(git(remote, ["add", path])), + Effect.andThen(git(remote, ["commit", "--quiet", "-m", message])), + Effect.andThen(git(remote, ["rev-parse", "HEAD"])), + ); + yield* commit("base.txt", "base"); + yield* git(remote, ["checkout", "--quiet", "-b", "stack-base"]); + const lowerSha = yield* commit("lower.txt", "lower layer"); + yield* git(remote, ["checkout", "--quiet", "-b", "feature"]); + const headSha = yield* commit( + "feature.txt", + "the change", + Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n") + "\n", + ); + yield* git(remote, ["checkout", "--quiet", "-b", "upper"]); + const upperSha = yield* commit("upper.txt", "upper layer"); + yield* git(remote, ["checkout", "--quiet", "main"]); + yield* commit("unrelated.txt", "main moved on"); + yield* git(root, ["clone", "--quiet", remote, workspace]); + return { root, remote, workspace, headSha, lowerSha, upperSha }; +}); + +const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => + ({ + number: 7, + title: "Add the feature", + body: "Adds the feature.", + baseBranch: "stack-base", + headBranch: "feature", + headSha, + workspaceRoot, + }) as PullRequestDetail; + +const activity: PullRequestActivity = { + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], +}; + +const stack = (repositories: { + readonly headSha: string; + readonly lowerSha: string; + readonly upperSha: string; +}): PullRequestStack => ({ + id: "stack", + number: 1, + url: "https://example.com/stack/1", + base: "main", + layers: [ + { + number: 6, + title: "Lower", + headSha: repositories.lowerSha, + headBranch: "stack-base", + state: "open", + }, + { + number: 7, + title: "Add the feature", + headSha: repositories.headSha, + headBranch: "feature", + state: "open", + }, + { + number: 8, + title: "Upper", + headSha: repositories.upperSha.slice(0, 12), + headBranch: "upper", + state: "open", + }, + { number: 9, title: "Unpushed", headSha: "f".repeat(40), headBranch: "gone", state: "open" }, + ], +}); + +const makeLayer = (input: { + readonly root: string; + readonly remote: string; + readonly workspace: string; + readonly headSha: string; + readonly lowerSha: string; + readonly upperSha: string; + readonly generate: TextGeneration.TextGeneration["Service"]["generateReviewGuide"]; + readonly stored: Deferred.Deferred; + readonly enabled?: boolean; + readonly worktreesDirectory?: string; + + readonly currentHead?: Ref.Ref; + readonly identity?: typeof ACME_APP; +}) => + PullRequestReviewGuideService.layer.pipe( + Layer.provide( + Layer.effect( + PullRequestReviewGuides.PullRequestReviewGuideRepository, + Effect.gen(function* () { + const repository = yield* PullRequestReviewGuides.PullRequestReviewGuideRepository; + return { + get: repository.get, + put: (key, guide) => + repository + .put(key, guide) + .pipe(Effect.tap(() => Deferred.succeed(input.stored, undefined))), + } satisfies PullRequestReviewGuides.PullRequestReviewGuideRepository["Service"]; + }), + ).pipe(Layer.provide(PullRequestReviewGuides.layer)), + ), + Layer.provide( + Layer.succeed(PullRequestService.PullRequestService, { + detail: () => + (input.currentHead === undefined + ? Effect.succeed(input.headSha) + : Ref.get(input.currentHead) + ).pipe(Effect.map((head) => detail(input.workspace, head))), + activity: () => Effect.succeed(activity), + invalidate: () => Effect.void, + stack: () => Effect.succeed(stack(input)), + } as unknown as PullRequestService.PullRequestService["Service"]), + ), + Layer.provide( + Layer.succeed(ProjectService.ProjectService, { + getShell: () => + Effect.succeedSome({ + id: projectId, + workspaceRoot: input.workspace, + repositoryIdentity: { + ...(input.identity ?? ACME_APP), + locator: { ...ACME_APP.locator, remoteUrl: input.remote }, + }, + }), + } as unknown as ProjectService.ProjectService["Service"]), + ), + Layer.provide( + Layer.succeed(ProviderRegistry.ProviderRegistry, { + getProviders: Effect.succeed([]), + } as unknown as ProviderRegistry.ProviderRegistry["Service"]), + ), + Layer.provide( + Layer.succeed(TextGeneration.TextGeneration, { + generateReviewGuide: input.generate, + } as unknown as TextGeneration.TextGeneration["Service"]), + ), + Layer.provide( + ServerSettings.layerTest({ + reviewGuidesEnabled: input.enabled ?? true, + worktreesDirectory: input.worktreesDirectory ?? "", + }), + ), + Layer.provide(ServerConfig.layerTest(input.root, { prefix: "t3-review-guide-config-" })), + ); + +const layerBase = Layer.mergeAll( + GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-review-guide-git-" })), + Layer.provideMerge(VcsProcess.layer), + ), + SqlitePersistence.layerMemory, +).pipe(Layer.provideMerge(NodeServices.layer)); + +// 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("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, + worktreesDirectory: "project-local", + 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 observed = yield* Deferred.await(seen); + assert.include(observed.input.cwd, `${repositories.workspace}/.worktrees/detached-`); + 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); + }), + ), + ); +}); diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 9c2e09b83185a..76f703123c271 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"; @@ -156,8 +156,6 @@ const makeLayer = (input: { readonly generate: TextGeneration.TextGeneration["Service"]["generateReviewGuide"]; readonly stored: Deferred.Deferred; readonly enabled?: boolean; - readonly worktreesDirectory?: string; - readonly currentHead?: Ref.Ref; readonly identity?: typeof ACME_APP; }) => @@ -212,12 +210,7 @@ const makeLayer = (input: { generateReviewGuide: input.generate, } as unknown as TextGeneration.TextGeneration["Service"]), ), - Layer.provide( - ServerSettings.layerTest({ - reviewGuidesEnabled: input.enabled ?? true, - worktreesDirectory: input.worktreesDirectory ?? "", - }), - ), + Layer.provide(ServerSettings.layerTest({ reviewGuidesEnabled: input.enabled ?? true })), Layer.provide(ServerConfig.layerTest(input.root, { prefix: "t3-review-guide-config-" })), ); @@ -231,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; @@ -338,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, @@ -347,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", @@ -536,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("-"))); @@ -549,126 +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, - worktreesDirectory: "project-local", - 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.include(observed.input.cwd, `${repositories.workspace}/.worktrees/detached-`); - 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* () { @@ -765,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 9f2844800d3fe..a438ac780f43f 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,12 +1,9 @@ import { SourceControlProviderKind, VcsProcessExitError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeV8 from "node:v8"; -import * as NodeVM from "node:vm"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import { assert, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -56,10 +53,6 @@ import { import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; -import { - FILE_REVISIONS_CACHE_CAPACITY, - MAX_FILE_REVISION_PATHS, -} from "./pullRequestViewedFiles.ts"; function project(input: { readonly id: string; @@ -137,237 +130,6 @@ function hostedChangeRequest(body: string, additions = 1) { }; } -it.effect("caches narrow previews and invalidates them after refresh or mutation", () => - Effect.gen(function* () { - let reads = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => Effect.die("preview must not read the viewer"), - getChangeRequestPreview: () => - Effect.sync(() => { - reads += 1; - return changeRequest(1, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const previews = yield* Effect.all([service.preview(ref), service.preview(ref)], { - concurrency: 2, - }); - assert.deepStrictEqual(previews[0], { - ...ref, - title: "Change request 1", - url: "https://host/pull/1", - author: { login: "octocat", name: null, avatarUrl: null }, - state: "open", - isDraft: false, - createdAt: "2026-07-01T00:00:00Z", - }); - assert.strictEqual(reads, 1); - yield* service.invalidate({ reference: ref }); - yield* service.preview(ref); - assert.strictEqual(reads, 2); - yield* service.runAction({ ...ref, action: "close" }); - yield* service.preview(ref); - assert.strictEqual(reads, 3); - yield* service.refreshAfterTurn(ref.projectId); - yield* service.preview(ref); - assert.strictEqual(reads, 4); - const error = yield* Effect.flip(service.preview({ ...ref, repository: "another/repo" })); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(reads, 4); - }), -); - -it.effect("keeps cached previews available and pauses uncached previews until quota resets", () => - Effect.gen(function* () { - let reads = 0; - const retryAt = Date.parse("2099-08-13T14:00:00Z"); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestPreview: ({ number }) => - Effect.gen(function* () { - reads++; - if (reads === 2) - return yield* new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestPreview", - reason: "rate-limited", - detail: "GitHub requests are paused until the rate limit resets.", - retryAt, - }); - return changeRequest(number, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.preview(ref); - const limited = yield* Effect.flip(service.preview({ ...ref, number: 2 })); - assert.instanceOf(limited, PullRequestOperationError); - if (limited._tag !== "PullRequestOperationError") return; - assert.include(limited.detail, "paused"); - assert.strictEqual((yield* service.preview(ref)).number, 1); - for (const number of [2, 3, 4]) { - const paused = yield* Effect.flip(service.preview({ ...ref, number })); - assert.instanceOf(paused, PullRequestOperationError); - if (paused._tag !== "PullRequestOperationError") return; - assert.include(paused.detail, "paused"); - } - assert.strictEqual(reads, 2); - yield* TestClock.setTime(retryAt); - assert.strictEqual((yield* service.preview({ ...ref, number: 2 })).number, 2); - assert.strictEqual(reads, 3); - }), -); - -it.effect("reuses only unexpired detail for previews", () => - Effect.gen(function* () { - let previewReads = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), - getChangeRequestPreview: () => - Effect.sync(() => { - previewReads++; - return { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - title: "Updated title", - state: "closed" as const, - }; - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.detail(ref); - assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); - assert.strictEqual(previewReads, 0); - yield* TestClock.adjust("61 seconds"); - const preview = yield* service.preview(ref); - assert.strictEqual(preview.title, "Updated title"); - assert.strictEqual(preview.state, "closed"); - assert.strictEqual(previewReads, 1); - }), -); - -it.effect("shares detail and activity for a minute, and for ten once merged", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: ({ number }) => - Effect.sync(() => { - reads.push(`detail #${number}`); - return { - ...hostedChangeRequest("Description"), - number, - state: - number === 1 - ? ("open" as const) - : number === 2 - ? ("merged" as const) - : ("closed" as const), - }; - }), - getChangeRequestActivity: ({ number }) => - Effect.sync(() => { - reads.push(`activity #${number}`); - return { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ], - }); - const reread = Effect.forEach([1, 2, 3], (number) => { - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number }; - return Effect.andThen(service.detail(ref), service.activity(ref)); - }).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())), - ); - - const everything = [ - "activity #1", - "activity #2", - "activity #3", - "detail #1", - "detail #2", - "detail #3", - ]; - assert.deepStrictEqual(yield* reread, everything); - yield* TestClock.adjust("59 seconds"); - assert.deepStrictEqual(yield* reread, []); - yield* TestClock.adjust("2 seconds"); - assert.deepStrictEqual(yield* reread, ["activity #1", "activity #3", "detail #1", "detail #3"]); - yield* TestClock.adjust("9 minutes"); - assert.deepStrictEqual(yield* reread, everything); - }), -); - -it.effect("re-reads an open pull request after a turn, but not a merged one", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: ({ number }) => - Effect.sync(() => { - reads.push(`detail #${number}`); - return { - ...hostedChangeRequest("Description"), - number, - state: number === 1 ? ("open" as const) : ("merged" as const), - }; - }), - getChangeRequestActivity: ({ number }) => - Effect.sync(() => { - reads.push(`activity #${number}`); - return { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ], - }); - const merged = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; - const reread = Effect.forEach([1, 2], (number) => { - const ref = { ...merged, number }; - return Effect.andThen(service.detail(ref), service.activity(ref)); - }).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())), - ); - - assert.deepStrictEqual(yield* reread, ["activity #1", "activity #2", "detail #1", "detail #2"]); - for (let turn = 0; turn < 3; turn++) { - yield* service.refreshAfterTurn(merged.projectId); - assert.deepStrictEqual(yield* reread, ["activity #1", "detail #1"]); - } - yield* service.invalidate({ reference: merged }); - assert.deepStrictEqual(yield* reread, ["activity #2", "detail #2"]); - }), -); - it.effect("does not wait for an in-flight detail read to display a preview", () => Effect.gen(function* () { const detailStarted = yield* Deferred.make(); @@ -394,68 +156,6 @@ it.effect("does not wait for an in-flight detail read to display a preview", () }), ); -it.effect("keeps previews warm when another project finishes a turn", () => - Effect.gen(function* () { - const reads: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestPreview: (input) => - Effect.sync(() => { - reads.push(input.repository); - return changeRequest(1, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const web = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const docs = { projectId: "p2" as ProjectId, repository: "acme/docs", number: 1 }; - yield* service.preview(web); - yield* service.preview(docs); - yield* service.preview({ ...web, host: "github.com" }); - assert.deepStrictEqual(reads, ["acme/web", "acme/docs"]); - yield* service.refreshAfterTurn(web.projectId); - yield* service.preview(web); - yield* service.preview(docs); - assert.deepStrictEqual(reads, ["acme/web", "acme/docs", "acme/web"]); - }), -); - -it.effect("uses full detail for hosts without a narrow preview", () => - Effect.gen(function* () { - let reads = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/w", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getChangeRequest: () => - Effect.sync(() => { - reads += 1; - return hostedChangeRequest("Description"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const preview = yield* service.preview(ref); - assert.strictEqual(preview.title, "Change request 1"); - assert.strictEqual(reads, 1); - assert.ok(!("body" in preview)); - }), -); - function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, @@ -625,73 +325,6 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos }), ); -it.effect("reports each host's actions with its summary, so rows can offer them unread", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/repo", - repository: "acme/web", - provider: SourceControlProviderKind.make("bitbucket"), - host: "bitbucket.org", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("bitbucket"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("bitbucket")).capabilities, - actions: ["merge", "close"], - }, - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(result.providers[0]?.actions, ["merge", "close"]); - }), -); - -it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => - Effect.gen(function* () { - let refinementCalls = 0; - const selfHosted = project({ - id: "p1", - title: "self-hosted", - workspaceRoot: "/gitlab", - repository: "group/project", - provider: SourceControlProviderKind.make("unknown"), - host: "code.example.test", - }); - const service = yield* makeService({ - projects: [ - selfHosted, - { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" }, - ], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], - resolveHandle: ({ context }) => { - refinementCalls += 1; - assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git"); - return Effect.succeed({ - context: { - ...context!, - provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, - }, - provider: undefined as never, - }); - }, - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(refinementCalls, 1); - assert.strictEqual(result.providers[0]?.host, "code.example.test"); - assert.strictEqual(result.providers[0]?.kind, "gitlab"); - }), -); - it.effect("derives a legacy repository host after refining its provider", () => Effect.gen(function* () { const current = project({ @@ -817,30 +450,6 @@ it.effect("reads nothing from a host with no implementation, but reports it", () }), ); -it.effect("asks for a whole page of a host, and for the reader's own size when given one", () => - Effect.gen(function* () { - const limits: number[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - limits.push(input.limit); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - yield* service.list({ state: "open", limit: 10 }); - - assert.deepStrictEqual(limits, [99, 10]); - }), -); - it.effect("says where each repository carries on, and from nothing it has run out of", () => Effect.gen(function* () { const service = yield* makeService({ @@ -868,31 +477,6 @@ it.effect("says where each repository carries on, and from nothing it has run ou }), ); -it.effect("offers no continuation for a host that cannot be carried on from", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: true, - continues: false, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.isTrue(result.truncated); - assert.deepStrictEqual(result.nextCursors, {}); - }), -); - it.effect("uses a provider's raw cursor advance when it consumed malformed rows", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1003,64 +587,31 @@ it.effect("continues Bitbucket listings through tied timestamps and terminates", }), ); -it.effect("reads only the repositories it was asked to carry on with", () => +it.effect("keeps a row already sent at the boundary instant from arriving twice", () => Effect.gen(function* () { - const listed: string[] = []; - const cursors: Array = []; const service = yield* makeService({ projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - listed.push(input.repository); - cursors.push(input.cursor); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(7, "2026-07-02T00:00:00Z"), + changeRequest(8, "2026-07-02T00:00:00Z"), + changeRequest(9, "2026-07-01T00:00:00Z"), + ], + truncated: true, + continues: true, + }), }), ], }); const result = yield* service.list({ state: "open", - cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|99|7" }, - }); - - assert.deepStrictEqual(listed, ["acme/web"]); - assert.deepStrictEqual(cursors, [ - { updatedBefore: "2026-07-02T00:00:00Z", delivered: 99, seenAt: [7] }, - ]); - assert.strictEqual(result.providers.length, 1); - }), -); - -it.effect("keeps a row already sent at the boundary instant from arriving twice", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - changeRequest(7, "2026-07-02T00:00:00Z"), - changeRequest(8, "2026-07-02T00:00:00Z"), - changeRequest(9, "2026-07-01T00:00:00Z"), - ], - truncated: true, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ - state: "open", - cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|7" }, + cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|7" }, }); assert.deepStrictEqual( @@ -1109,31 +660,6 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega }), ); -it.effect("refuses a continuation it did not issue, before asking any host anything", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => Effect.die("should not be read"), - }), - ], - }); - - const error = yield* Effect.flip( - service.list({ state: "open", cursors: { "github.com pingdotgg/t3code": "yesterday" } }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual( - error.message, - "Pull request operation list failed: The list could not be carried on from where it left off.", - ); - }), -); - it.effect("calls a transient viewer failure a failed operation, not a signed-out CLI", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1200,90 +726,6 @@ it.effect("reports an unusable host over a merely failing one", () => }), ); -it.effect("lists every host that has an implementation", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/sub/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-01T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: (input) => - input.repository === "group/sub/project" - ? Effect.succeed({ - items: [changeRequest(2, "2026-07-05T00:00:00Z")], - truncated: false, - continues: true, - }) - : Effect.die("wrong repository identity"), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => [entry.provider, entry.number]), - [ - ["gitlab", 2], - ["github", 1], - ], - ); - }), -); - -it.effect("narrows the listing to one host when asked", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => Effect.die("should not be read"), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(2, "2026-07-05T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open", host: "gitlab.com" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.provider), - ["gitlab"], - ); - }), -); - it.effect("tells two hosts of one kind apart in the switcher and the filter", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1326,75 +768,6 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ); -it.effect("keeps one host listed when another is not set up", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-01T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.provider), - ["github"], - ); - assert.deepStrictEqual( - result.providers.map((summary) => [summary.kind, summary.configured]), - [ - ["github", true], - ["gitlab", false], - ], - ); - }), -); - -it.effect("fails as unavailable only when no host can be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")), - }), - ], - }); - - const error = yield* service.list({ state: "open" }).pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - assert.strictEqual( - error._tag === "PullRequestUnavailableError" ? error.reason : null, - "cli-missing", - ); - }), -); - it.effect("reads a repository once when several worktrees share it", () => Effect.gen(function* () { let calls = 0; @@ -1963,300 +1336,76 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = }), ); -it.effect("hands the host the strategy an armed merge was asked for", () => +it.effect("keeps two hosts of one provider kind as two accounts", () => Effect.gen(function* () { - let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null; + const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + repository: "acme/web", + host: "github.acme.dev", + }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "enable-auto-merge", "disable-auto-merge"], - mergeMethods: ["merge", "squash"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => + getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), + listChangeRequests: () => Effect.succeed({ - actions: ["merge", "enable-auto-merge", "disable-auto-merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, }), - runAction: (input) => { - ranWith = { - action: input.action, - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - }; - return Effect.void; - }, }), ], }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }); - assert.deepStrictEqual(ranWith, { action: "enable-auto-merge", mergeMethod: "squash" }); + const result = yield* service.list({ state: "open" }); - yield* service.runAction({ ...reference, action: "disable-auto-merge" }); - assert.deepStrictEqual(ranWith, { action: "disable-auto-merge" }); + assert.strictEqual(result.entries.length, 2); + assert.deepStrictEqual(result.viewers, { + "github.com": "bilal", + "github.acme.dev": "b.hassan", + }); + assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ + "github.acme.dev", + "github.com", + ]); }), ); -it.effect("refuses an auto-merge the host never claimed, without asking it", () => +it.effect("stops new reads after a rate limit while leaving manual actions available", () => Effect.gen(function* () { - let ran = false; + let listCalls = 0; + let actionCalls = 0; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - runAction: () => { - ran = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "enable-auto-merge", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(ran); - }), -); - -it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "ready", "draft", "close", "reopen"], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }), - setThreadResolution: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setThreadResolution({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - threadId: "t1", - resolved: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "to resolve a review conversation."); - }), -); - -it.effect("asks nobody what the viewer may do when the host cannot do it at all", () => - Effect.gen(function* () { - let asked = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "close"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => { - asked = true; - return Effect.die("must not be called"); - }, - }), - ], - }); - - yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "reopen", - }), - ); - - assert.isFalse(asked); - }), -); - -it.effect("refuses a comment on a host that cannot post one", () => - Effect.gen(function* () { - let posted = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: false, - comment: false, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - comment: () => { - posted = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.comment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - body: "Looks good.", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(posted); - }), -); - -it.effect("keeps two hosts of one provider kind as two accounts", () => - Effect.gen(function* () { - const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/enterprise", - repository: "acme/web", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(result.entries.length, 2); - assert.deepStrictEqual(result.viewers, { - "github.com": "bilal", - "github.acme.dev": "b.hassan", - }); - assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ - "github.acme.dev", - "github.com", - ]); - }), -); - -it.effect("reports repositories on a host that could not be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/enterprise", - repository: "acme/api", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => - input.cwd === "/cloud" - ? Effect.succeed("bilal") - : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(result.entries.length, 1); - assert.deepStrictEqual( - result.errors.map((error) => error.projectId), - ["p2"], - ); - }), -); - -it.effect("stops new reads after a rate limit while leaving manual actions available", () => - Effect.gen(function* () { - let listCalls = 0; - let actionCalls = 0; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.sync(() => { - listCalls += 1; - }).pipe( - Effect.andThen( - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "listChangeRequests", - reason: "rate-limited", - detail: "GitHub API rate limit exceeded.", - }), - ), - ), - ), - runAction: () => - Effect.sync(() => { - actionCalls += 1; - }), + listChangeRequests: () => + Effect.sync(() => { + listCalls += 1; + }).pipe( + Effect.andThen( + Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "listChangeRequests", + reason: "rate-limited", + detail: "GitHub API rate limit exceeded.", + }), + ), + ), + ), + runAction: () => + Effect.sync(() => { + actionCalls += 1; + }), }), ], }); @@ -2277,272 +1426,33 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [providerName, host] of [ +for (const [] of [ ["github", "github.com"], ["gitlab", "gitlab.com"], ["forgejo", "code.example.test"], ["bitbucket", "bitbucket.org"], ["azure-devops", "dev.azure.com"], ] as const) { - const provider = SourceControlProviderKind.make(providerName); - it.effect.each(["detail", "checks"] as const)( - `shares fresh ${provider} %s reads and respects rate-limit resets`, - (read) => - Effect.gen(function* () { - let calls = 0; - let limited = false; - const repository = provider === "azure-devops" ? "web" : "acme/web"; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/repo", repository, provider, host }), - ], - providers: [ - fakeProvider(provider, { - [read === "detail" ? "getChangeRequest" : "getChangeRequestChecks"]: () => - Effect.gen(function* () { - calls += 1; - if (limited) { - return yield* new PullRequestProviderError({ - provider, - operation: "getChangeRequest", - reason: "rate-limited", - detail: "Retry after the reset.", - retryAt: (yield* Clock.currentTimeMillis) + 120_000, - }); - } - return hostedChangeRequest("current details"); - }), - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository, - number: 1, - allowStale: false, - }; - const request: Effect.Effect = - service[read](reference); - yield* Effect.all([request, request], { concurrency: 2 }); - assert.strictEqual(calls, 1); - yield* TestClock.adjust("61 seconds"); - limited = true; - assert.strictEqual((yield* Effect.exit(request))._tag, "Failure"); - assert.strictEqual(calls, 2); - yield* TestClock.adjust("45 seconds"); - assert.strictEqual((yield* Effect.exit(request))._tag, "Failure"); - assert.strictEqual(calls, 2); - yield* TestClock.adjust("75 seconds"); - limited = false; - const recovered = yield* service[read](reference); - assert.strictEqual(recovered?.state, "open"); - assert.strictEqual(calls, 3); - yield* service.invalidate({ reference }); - yield* service[read](reference); - assert.strictEqual(calls, 4); - }), - ); } -it.effect("does not fall back to full detail when checks are unsupported", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.die("Unexpected full detail read"), - }), - ], - }); - assert.isNull( - yield* service.checks({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - }), -); - -it.effect("uses a manual rate limit to pause later reads", () => +it.effect("refuses a repository that does not belong to the requested project", () => Effect.gen(function* () { - let listCalls = 0; const service = yield* makeService({ projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.sync(() => { - listCalls += 1; - return { items: [], truncated: false, continues: true }; - }), - runAction: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "runAction", - reason: "rate-limited", - detail: "GitHub API rate limit exceeded.", - }), - ), - }), + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); - yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "close", - }), - ); - const error = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) + .pipe(Effect.flip); - assert.strictEqual(listCalls, 0); assert.strictEqual(error._tag, "PullRequestOperationError"); }), ); -it.effect("flags a review request for the viewer but not on their own change request", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewRequestLogins: ["Bilal"] }, - { - ...changeRequest(2, "2026-07-02T00:00:00Z"), - author: { login: "bilal", name: null, avatarUrl: null }, - reviewRequestLogins: ["bilal"], - }, - ], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.viewerReviewRequested), - [true, false], - ); - }), -); - -it.effect("refuses a repository that does not belong to the requested project", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - - const error = yield* service - .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("caches stack membership separately from action details", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestStack: (input) => - Effect.sync(() => { - reads.push(input.includeDetails === true); - return { - id: "9", - number: 3, - url: "https://github.com/acme/web/stacks/3", - base: "main", - layers: [ - { - number: 7, - headBranch: "a", - state: "open" as const, - ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}), - }, - ], - }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 }; - yield* service.stack(reference, { includeDetails: false }); - yield* service.stack(reference, { includeDetails: false }); - const detail = yield* service.stack(reference); - yield* service.stack(reference); - assert.deepStrictEqual(reads, [false, true]); - assert.strictEqual(detail?.layers[0]?.headSha, "abc"); - yield* service.invalidate({ reference }); - yield* service.stack(reference, { includeDetails: false }); - yield* service.stack(reference); - assert.deepStrictEqual(reads, [false, true, false, true]); - }), -); - -it.effect("reads a host-native stack through the provider and null where it has none", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestStack: () => - Effect.succeed({ - id: "9", - number: 3, - url: "https://github.com/acme/web/stacks/3", - base: "main", - layers: [ - { number: 7, headBranch: "a", state: "open" as const }, - { number: 8, headBranch: "b", state: "open" as const }, - ], - }), - }), - ], - }); - - const stack = yield* service.stack({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 7, - }); - assert.deepStrictEqual( - stack?.layers.map((layer) => layer.number), - [7, 8], - ); - - const withoutStacks = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - assert.isNull( - yield* withoutStacks.stack({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 7, - }), - ); - }), -); - -it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () => +it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () => Effect.gen(function* () { for (const provider of ["forgejo", "unknown"] as const) { const seen: string[] = []; @@ -2612,1345 +1522,343 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after const listed = yield* service.list({ projectId: "ssh" as ProjectId, host: "code.example:3000", - state: "open", - }); - assert.strictEqual(listed.viewers["code.example:3000"], "bilal"); - const preview = yield* service.preview({ - projectId: "ssh" as ProjectId, - host: "code.example:3000", - repository: "team/repo", - number: 42, - }); - assert.strictEqual(preview.number, 42); - const detail = yield* service.detail({ - projectId: "ssh" as ProjectId, - host: "code.example:3000", - repository: "team/repo", - number: 42, - }); - assert.strictEqual(detail.body, "Forgejo detail"); - assert.deepStrictEqual(viewers, ["code.example:3000"]); - } - }), -); - -it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "http", - title: "http", - workspaceRoot: "/http", - repository: "team/repo", - provider: SourceControlProviderKind.make("forgejo"), - host: "code.example", - remoteUrl: "http://code.example:4000/team/repo.git", - }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], - }); - const failure = yield* service - .summary( - { - projectId: "http" as ProjectId, - host: "code.example:3000", - repository: "team/repo", - number: 42, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(failure._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("resolves a project's repository identity when its shell has none cached", () => - Effect.gen(function* () { - const resolved = project({ - id: "web", - title: "web", - workspaceRoot: "/web", - repository: "acme/web", - }); - const service = yield* makeService({ - projects: [{ ...resolved, repositoryIdentity: null }], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), - }), - ], - resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null), - }); - - const summary = yield* service.summary( - { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 }, - { recoverTransientFailure: false }, - ); - - assert.strictEqual(summary.number, 7); - }), -); - -it.effect("routes a hosted reference to another repository through a project on that host", () => - Effect.gen(function* () { - const seen: Array<{ cwd: string; repository: string; host: string }> = []; - const service = yield* makeService({ - projects: [ - project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - - const summary = yield* service.summary( - { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, - { recoverTransientFailure: false }, - ); - - assert.strictEqual(summary.number, 7); - assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); - }), -); - -it.effect("routes Azure reads and writes through the requested organization's checkout", () => - Effect.gen(function* () { - const seen: string[] = []; - const service = yield* makeService({ - projects: ["org-a", "org-b"].map((organization) => - project({ - id: organization, - title: organization, - workspaceRoot: `/${organization}`, - repository: `${organization}/project/_git/web`, - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), - ), - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), - }), - ], - }); - const reference = { - projectId: "org-a" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); - }), -); - -it.effect.each([ - { - host: "ssh.dev.azure.com", - repository: "v3/org-b/project/web", - remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web", - }, - { - host: "vs-ssh.visualstudio.com", - repository: "v3/org-b/project/web", - remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web", - }, - { - host: "org-b.visualstudio.com", - repository: "DefaultCollection/project/_git/web", - remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", - }, -])("routes Azure URL reads and writes through a $host checkout", (checkout) => - Effect.gen(function* () { - const seen: string[] = []; - const target = project({ - id: "target", - title: "target", - workspaceRoot: "/target", - provider: SourceControlProviderKind.make("azure-devops"), - ...checkout, - }); - const service = yield* makeService({ - projects: [ - ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => - project({ - id: repository, - title: repository, - workspaceRoot: `/${repository}`, - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - repository, - }), - ), - target, - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), - }), - ], - }); - const reference = { - projectId: "org-a/project/_git/web" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); - }), -); - -it.effect("refuses Azure cross-organization reads and writes without its checkout", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "org-a", - title: "org-a", - workspaceRoot: "/org-a", - repository: "org-a/project/_git/web", - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), - runAction: () => Effect.die("must not modify the wrong organization"), - }), - ], - }); - const reference = { - projectId: "org-a" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - const readError = yield* Effect.flip( - service.summary(reference, { recoverTransientFailure: false }), - ); - const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); - assert.strictEqual(readError._tag, "PullRequestUnavailableError"); - assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); - }), -); - -it.effect.each([ - ["github.com", "github"], - ["bitbucket.org", "bitbucket"], - ["codeberg.org", "forgejo"], -] as const)("reads linked PR summaries and stacks on %s without a checkout", ([host, kind]) => - Effect.gen(function* () { - const seen: Array<{ cwd: string; repository: string; host: string }> = []; - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [ - fakeProvider(SourceControlProviderKind.make(kind), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - getChangeRequestStack: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return null; - }), - }), - ], - }); - const reference = { - projectId: "inbox" as ProjectId, - host, - repository: "acme/api", - number: 7, - }; - const summary = yield* service.summary(reference, { recoverTransientFailure: false }); - const stack = yield* service.stack(reference, { includeDetails: false }); - assert.strictEqual(summary.title, "Change request 7"); - assert.strictEqual(summary.state, "open"); - assert.strictEqual(summary.projectId, reference.projectId); - assert.strictEqual(stack, null); - assert.deepStrictEqual(seen, [ - { cwd: "/home/alex", repository: "acme/api", host }, - { cwd: "/home/alex", repository: "acme/api", host }, - ]); - }), -); - -it.effect("prefers a matching checkout over a non-repository project's directory", () => - Effect.gen(function* () { - const seen: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }), - project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(input.cwd); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - yield* service.summary( - { - projectId: "inbox" as ProjectId, - host: "github.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ); - assert.deepStrictEqual(seen, ["/api"]); - }), -); - -it.effect("requires an explicit host for a non-repository project's linked PR", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - const error = yield* service - .summary( - { - projectId: "inbox" as ProjectId, - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a public host reference without a selected project directory", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - const error = yield* service - .summary( - { - projectId: "missing" as ProjectId, - host: "github.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect( - "requires a checkout for GitLab because its adapter does not explicitly target the host", - () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], - }); - const error = yield* service - .summary( - { - projectId: "inbox" as ProjectId, - host: "gitlab.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a hosted reference when its provider is unavailable", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - - const error = yield* service - .summary( - { - projectId: "frontend" as ProjectId, - host: "gitlab.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a diff on a host that cannot produce one", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on azure", - workspaceRoot: "/a", - repository: "org/project", - provider: SourceControlProviderKind.make("azure-devops"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - capabilities: { - diff: false, - comment: true, - actions: ["merge", "close"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getDiff: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* service - .diff({ projectId: "p1" as ProjectId, repository: "org/project", number: 1 }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("rejects an empty comment before reaching the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - comment: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* service - .comment({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - body: " ", - }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses a verdict the host never claimed, without asking the provider", () => - Effect.gen(function* () { - let submitted = false; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { - inlineComment: true, - reply: true, - resolve: true, - verdicts: ["comment", "approve"], - }, - reviewers: FULL_REVIEWERS, - }, - submitReview: () => { - submitted = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.submitReview({ - projectId: "p1" as ProjectId, - repository: "group/project", - number: 1, - verdict: "request-changes", - body: "no", - comments: [], - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(submitted); - }), -); - -it.effect("refuses line comments on a host that takes only a summary", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] }, - reviewers: FULL_REVIEWERS, - }, - submitReview: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.submitReview({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - verdict: "comment", - body: "", - comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }], - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect( - "refuses a review with neither a summary nor a comment, but lets an approval through", - () => - Effect.gen(function* () { - let approved = false; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "t3code", - workspaceRoot: "/a", - repository: "pingdotgg/t3code", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - submitReview: () => { - approved = true; - return Effect.void; - }, - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const error = yield* Effect.flip( - service.submitReview({ ...reference, verdict: "comment", body: " ", comments: [] }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - - yield* service.submitReview({ ...reference, verdict: "approve", body: "", comments: [] }); - assert.isTrue(approved); - }), -); - -it.effect("refuses to resolve a conversation on a host that cannot", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] }, - reviewers: FULL_REVIEWERS, - }, - setThreadResolution: () => Effect.die("must not be called"), - replyToThread: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const resolveError = yield* Effect.flip( - service.setThreadResolution({ ...reference, threadId: "t1", resolved: true }), - ); - const replyError = yield* Effect.flip( - service.replyToThread({ ...reference, threadId: "t1", body: "hi" }), - ); - - assert.strictEqual(resolveError._tag, "PullRequestOperationError"); - assert.strictEqual(replyError._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses to react on a host with no reactions", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: false, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - setReaction: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - content: "heart", - reacted: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses to react on a host whose capabilities omit reactions entirely", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - setReaction: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - content: "heart", - reacted: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("passes a reaction through with its subject id on a host that has them", () => - Effect.gen(function* () { - let received: { - readonly subjectId: string | undefined; - readonly content: string; - readonly reacted: boolean; - } | null = null; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - setReaction: (input) => { - received = { - subjectId: input.subjectId, - content: input.content, - reacted: input.reacted, - }; - return Effect.void; - }, - }), - ], - }); - - yield* service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - subjectId: "IC_1", - content: "heart", - reacted: true, - }); - - assert.deepStrictEqual(received, { subjectId: "IC_1", content: "heart", reacted: true }); - }), -); - -it.effect("invalidates the cached activity after reacting, like the other mutations", () => - Effect.gen(function* () { - let activityCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestActivity: () => { - activityCalls += 1; - return Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }); - }, - }), - ], - }); - - yield* service.refreshAfterTurn(reference.projectId); - const previousRefresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.activity(reference); - assert.strictEqual(activityCalls, 1); - - yield* service.setReaction({ ...reference, content: "heart", reacted: true }); - assert.isAbove( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), - previousRefresh, - ); - yield* service.activity(reference); - - assert.strictEqual(activityCalls, 2); - }), -); - -it.effect("refuses an empty reply before it reaches the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - replyToThread: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.replyToThread({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - threadId: "t1", - body: " ", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses a merge strategy the host does not offer", () => - Effect.gen(function* () { - let ranWith: string | null = null; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge", "squash"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - runAction: (input) => { - ranWith = input.mergeMethod ?? "merge"; - return Effect.void; - }, - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const error = yield* Effect.flip( - service.runAction({ ...reference, action: "merge", mergeMethod: "rebase" }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(ranWith, null); - - yield* service.runAction({ ...reference, action: "merge", mergeMethod: "squash" }); - assert.strictEqual(ranWith, "squash"); - }), -); - -it.effect("hands the provider the host its repository lives on", () => - Effect.gen(function* () { - const hosts: string[] = []; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "enterprise", - workspaceRoot: "/a", - repository: "acme/web", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - hosts.push(input.host); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - - assert.deepStrictEqual(hosts, ["github.acme.dev"]); + state: "open", + }); + assert.strictEqual(listed.viewers["code.example:3000"], "bilal"); + const preview = yield* service.preview({ + projectId: "ssh" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }); + assert.strictEqual(preview.number, 42); + const detail = yield* service.detail({ + projectId: "ssh" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }); + assert.strictEqual(detail.body, "Forgejo detail"); + assert.deepStrictEqual(viewers, ["code.example:3000"]); + } }), ); -it.effect("asks every host the reader's search, rather than filtering what came back", () => +it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => Effect.gen(function* () { - const asked: Array = []; - const listing = (input: { readonly query?: string | undefined }) => { - asked.push(input.query); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }; const service = yield* makeService({ projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), + id: "http", + title: "http", + workspaceRoot: "/http", + repository: "team/repo", + provider: SourceControlProviderKind.make("forgejo"), + host: "code.example", + remoteUrl: "http://code.example:4000/team/repo.git", }), ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }), - ], + providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], }); - - yield* service.list({ state: "open", query: "pull requests page" }); - - assert.deepStrictEqual(asked, ["pull requests page", "pull requests page"]); + const failure = yield* service + .summary( + { + projectId: "http" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + assert.strictEqual(failure._tag, "PullRequestUnavailableError"); }), ); -it.effect("asks for no search when the reader has typed nothing", () => +it.effect("resolves a project's repository identity when its shell has none cached", () => Effect.gen(function* () { - const asked: Array = []; + const resolved = project({ + id: "web", + title: "web", + workspaceRoot: "/web", + repository: "acme/web", + }); const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], + projects: [{ ...resolved, repositoryIdentity: null }], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - asked.push(input.query); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, + getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], + resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null), }); - yield* service.list({ state: "open" }); + const summary = yield* service.summary( + { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 }, + { recoverTransientFailure: false }, + ); - assert.deepStrictEqual(asked, [undefined]); + assert.strictEqual(summary.number, 7); }), ); -it.effect("asks another checkout who is signed in when the first one cannot answer", () => +it.effect("routes a hosted reference to another repository through a project on that host", () => Effect.gen(function* () { - const asked: string[] = []; + const seen: Array<{ cwd: string; repository: string; host: string }> = []; const service = yield* makeService({ projects: [ - project({ - id: "p1", - title: "t3code (stale worktree)", - workspaceRoot: "/gone", - repository: "pingdotgg/t3code", - }), - project({ - id: "p2", - title: "t3code", - workspaceRoot: "/healthy", - repository: "pingdotgg/t3code", - }), + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => { - asked.push(input.cwd); - return input.cwd === "/gone" - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getViewer", - reason: "failed", - detail: "not a git repository", - }), - ) - : Effect.succeed("bilal"); - }, - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); + return changeRequest(7, "2026-07-02T00:00:00Z"); }), }), ], }); - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(asked, ["/gone", "/healthy"]); - assert.strictEqual(result.entries.length, 1); - assert.strictEqual(result.providers[0]?.configured, true); - }), -); - -it.effect("refuses to ask for a review on a host that cannot, before any call is made", () => - Effect.gen(function* () { - let asked = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: { request: false, listCandidates: false }, - }, - getViewerPermissions: () => { - asked = true; - return Effect.die("must not be called"); - }, - setReviewerRequest: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.requestReviewers({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - reviewers: [{ id: "octocat", kind: "user" }], - requested: true, - }), + const summary = yield* service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, + { recoverTransientFailure: false }, ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot ask somebody for a review."); - assert.isFalse(asked); + assert.strictEqual(summary.number, 7); + assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); }), ); -it.effect("refuses the candidate list on a host that has no such list to give", () => +it.effect("routes Azure reads and writes through the requested organization's checkout", () => Effect.gen(function* () { + const seen: string[] = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - 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"), + 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", }), - ], - }); - - 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, + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); }), - setReviewerRequest: () => { - sent = true; - return Effect.void; - }, }), ], }); - - const error = yield* Effect.flip( - service.requestReviewers({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - reviewers: [{ id: "octocat", kind: "user" }], - requested: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "You need write access on this repository to ask for a review."); - assert.isFalse(sent); + const reference = { + projectId: "org-a" 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("keeps the menu from a viewer who may not ask, which is all it is for", () => +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: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => + project({ + id: repository, + title: repository, + workspaceRoot: `/${repository}`, + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + repository, + }), + ), + target, + ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: [], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); }), - 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."); + 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("hands the host's own candidate list back, and asks for it with the change request", () => +it.effect("refuses Azure cross-organization reads and writes without its checkout", () => Effect.gen(function* () { - let askedFor: number | null = null; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ + id: "org-a", + title: "org-a", + workspaceRoot: "/org-a", + repository: "org-a/project/_git/web", + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + }), + ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - 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, - }); - }, + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), + runAction: () => Effect.die("must not modify the wrong organization"), }), ], }); - - 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"], + 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("refuses a label change on a host that has not said it takes one", () => +it.effect("prefers a matching checkout over a non-repository project's directory", () => Effect.gen(function* () { - let changed = false; + const seen: string[] = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }), + project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - setLabels: () => { - changed = true; - return Effect.void; - }, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(input.cwd); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), }), ], }); - - const error = yield* Effect.flip( - service.setLabels({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - labels: ["bug"], - applied: true, - }), + yield* service.summary( + { + projectId: "inbox" as ProjectId, + host: "github.com", + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, ); + assert.deepStrictEqual(seen, ["/api"]); + }), +); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot change the labels"); - assert.isFalse(changed); +// 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("refuses a label change this viewer may not make, and says what access it takes", () => +it.effect("refuses a review request this viewer may not make, and says what access it takes", () => Effect.gen(function* () { - let changed = false; + let sent = false; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - labels: true, - }, getViewerPermissions: () => Effect.succeed({ - actions: [], + actions: ["ready", "draft", "close", "reopen"], comment: true, - resolve: false, + resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, - labels: false, }), - listLabelCandidates: () => Effect.die("must not be called"), - setLabels: () => { - changed = true; + setReviewerRequest: () => { + sent = true; return Effect.void; }, }), ], }); - const listError = yield* Effect.flip( - service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - assert.include(listError.message, "You need triage access on this repository"); - const error = yield* Effect.flip( - service.setLabels({ + service.requestReviewers({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1, - labels: ["bug"], - applied: true, + reviewers: [{ id: "octocat", kind: "user" }], + requested: true, }), ); - assert.include(error.message, "You need triage access on this repository"); - assert.isFalse(changed); - }), -); - -it.effect("hands a label change to the host, and reads the labels back for the menu", () => - Effect.gen(function* () { - let received: { labels: ReadonlyArray; applied: boolean } | null = null; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - labels: true, - }, - listLabelCandidates: () => - Effect.succeed({ - candidates: [{ name: "bug", color: null, description: null, isApplied: false }], - truncated: false, - }), - setLabels: (input) => { - received = { labels: input.labels, applied: input.applied }; - return Effect.void; - }, - }), - ], - }); - - const list = yield* service.labelCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - }); - assert.deepStrictEqual( - list.candidates.map((label) => label.name), - ["bug"], - ); - - yield* service.setLabels({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - labels: ["bug"], - applied: false, - }); - assert.deepStrictEqual(received, { labels: ["bug"], applied: false }); - }), -); - -it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }); - }, - }), - ], - }); - - yield* Effect.all([service.list({ state: "open" }), service.list({ state: "open" })], { - concurrency: "unbounded", - }); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 1); - yield* service.list({ state: "all" }); - assert.strictEqual(hostCalls, 2); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "You need write access on this repository to ask for a review."); + assert.isFalse(sent); }), ); @@ -4050,107 +1958,8 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = { 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], - ); + assert.strictEqual(viewerCalls, 1); + assert.strictEqual(listCalls, 3); }), ); @@ -4252,85 +2061,6 @@ it.effect( }), ); -it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () => - Effect.gen(function* () { - const calls: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: (input) => - Effect.sync(() => { - calls.push(`${input.repository}/${input.number}`); - return { ...hostedChangeRequest("body"), number: input.number }; - }), - }), - ], - }); - const refs = [ - { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, - { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }, - { projectId: "p2" as ProjectId, repository: "acme/docs", number: 3 }, - ]; - const readAll = Effect.forEach(refs, (ref) => service.summary({ ...ref, allowStale: false })); - yield* readAll; - yield* service.invalidate({ reference: { ...refs[0]!, host: "github.com" } }); - yield* readAll; - assert.deepStrictEqual(calls, ["acme/web/1", "acme/web/2", "acme/docs/3", "acme/web/1"]); - yield* service.comment({ ...refs[0]!, body: "hello" }); - yield* readAll; - assert.deepStrictEqual(calls.slice(4), ["acme/web/1"]); - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* readAll; - assert.deepStrictEqual(calls.slice(5), ["acme/web/1", "acme/web/2"]); - }), -); - -it.effect( - "keeps matching PR numbers on different hosts separate and refreshes the serving project", - () => - Effect.gen(function* () { - const hosts: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "public", workspaceRoot: "/a", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/b", - repository: "acme/web", - host: "enterprise.test", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: (input) => - Effect.sync(() => { - hosts.push(input.host); - return hostedChangeRequest("body"); - }), - }), - ], - }); - const own = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const other = { ...own, host: "enterprise.test" }; - const readBoth = Effect.all([service.summary(own), service.summary(other)]); - yield* readBoth; - yield* service.invalidate({ reference: { ...own, host: "github.com" } }); - yield* readBoth; - assert.deepStrictEqual(hosts, ["github.com", "enterprise.test", "github.com"]); - yield* service.invalidate({ reference: own }); - yield* readBoth; - assert.deepStrictEqual(hosts.slice(3), ["github.com"]); - yield* service.refreshAfterTurn("p2" as ProjectId); - yield* readBoth; - assert.deepStrictEqual(hosts.slice(4), ["enterprise.test"]); - }), -); - it.effect("does not revive old summaries when project epochs are evicted", () => Effect.gen(function* () { let title = "old"; @@ -4353,44 +2083,6 @@ it.effect("does not revive old summaries when project epochs are evicted", () => }), ); -it.effect("explicit and turn invalidations make the next listing ask the host again", () => - Effect.gen(function* () { - let hostCalls = 0; - let viewerCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => { - viewerCalls += 1; - return Effect.succeed("bilal"); - }, - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ items: [], truncated: false, continues: false }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - yield* service.invalidate({}); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - assert.strictEqual(viewerCalls, 2); - - yield* service.invalidate({ reference }); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - yield* service.refreshAfterTurn("p1" as ProjectId); - const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.list({ state: "open" }); - assert.isAbove(refresh, 0); - assert.strictEqual(hostCalls, 3); - }), -); - it.effect("close and reopen notify subscribed readers after invalidating their cached state", () => Effect.scoped( Effect.gen(function* () { @@ -4444,77 +2136,6 @@ it.effect("close and reopen notify subscribed readers after invalidating their c ), ); -it.effect("explicit invalidation refreshes origin readers after a routed host mutation", () => - Effect.scoped( - Effect.gen(function* () { - let state: "open" | "closed" = "open"; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), - }), - ], - }); - yield* service.refreshAfterTurn(reference.projectId); - let revision = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.invalidate({ reference }); - assert.strictEqual( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), - revision, - ); - assert.strictEqual((yield* service.summary(reference)).state, "open"); - - for (const nextState of ["closed", "open"] as const) { - const refreshed = yield* service.subscribeRefreshes.pipe( - Stream.drop(1), - Stream.take(1), - Stream.mapEffect((nextRevision) => - service - .summary(reference) - .pipe(Effect.map((summary) => ({ revision: nextRevision, state: summary.state }))), - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - state = nextState; - yield* service.invalidate({ reference }, { notifyReaders: true }); - const result = Option.getOrThrow(yield* Fiber.join(refreshed)); - assert.strictEqual(result.state, nextState); - assert.isAbove(result.revision, revision); - revision = result.revision; - } - }), - ), -); - -it.effect("does not cache a failed listing", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => { - hostCalls += 1; - return hostCalls === 1 ? Effect.fail(requestFailed) : Effect.succeed("bilal"); - }, - }), - ], - }); - - const error = yield* Effect.flip(service.list({ state: "open" })); - assert.strictEqual(error._tag, "PullRequestOperationError"); - const second = yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - assert.strictEqual(second.providers[0]?.configured, true); - }), -); - it.effect("reads a host's repositories in one search, and files the rows back under each", () => Effect.gen(function* () { const asked: Array> = []; @@ -4717,36 +2338,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 = []; @@ -4800,58 +2391,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* () { @@ -4885,184 +2424,26 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis const coldSummary = yield* service.summary(reference).pipe(Effect.forkChild()); yield* Deferred.await(summaryStarted); - yield* service.detail(reference); - assert.strictEqual((yield* service.diff(reference)).patch, "old patch"); - revision = "2026-07-02T00:01:00Z"; - patch = "new patch"; - yield* TestClock.adjust("61 seconds"); - yield* service.detail(reference); - yield* Effect.yieldNow; - assert.strictEqual((yield* service.detail(reference)).updatedAt, revision); - yield* Deferred.succeed(releaseSummary, undefined); - yield* Fiber.join(coldSummary); - yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual((yield* service.diff(reference)).patch, "new patch"); - assert.strictEqual(diffCalls, 2); - - revision = "2026-07-02T00:02:00Z"; - patch = "summary-discovered patch"; - yield* TestClock.adjust("61 seconds"); - yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual((yield* service.diff(reference)).patch, patch); - assert.strictEqual(diffCalls, 3); - }), -); - -it.effect("keeps the rows when the line counts cannot be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: () => Effect.fail(requestFailed), - }), - ], - }); - - const result = yield* service.listStats({ - refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], - }); - - assert.deepStrictEqual(result.stats, []); - }), -); - -it.effect( - "serves core detail without waiting for activity, and shares activity between clients", - () => - Effect.gen(function* () { - let coreCalls = 0; - let activityCalls = 0; - let statsCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: () => { - statsCalls += 1; - return Effect.succeed([]); - }, - getChangeRequest: () => { - coreCalls += 1; - return Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "Ready before the conversation", - changedFiles: 2, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }); - }, - getChangeRequestActivity: () => { - activityCalls += 1; - return Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: true, - reviewThreadsTruncated: true, - reviewThreads: [], - commits: [], - }); - }, - }), - ], - }); - - const core = yield* service.detail(reference); - assert.strictEqual(core.body, "Ready before the conversation"); - assert.strictEqual(coreCalls, 1); - assert.strictEqual(activityCalls, 0); - - const counts = yield* service.listStats({ refs: [reference] }); - assert.strictEqual(statsCalls, 0); - assert.deepStrictEqual(counts.stats, [ - { - ...reference, - additions: core.additions, - deletions: core.deletions, - }, - ]); - - const activities = yield* Effect.all( - [service.activity(reference), service.activity(reference)], - { - concurrency: 2, - }, - ); - assert.strictEqual(activityCalls, 1); - assert.isTrue(activities.every((activity) => activity.reviewThreadsTruncated === true)); - - yield* service.invalidate({ reference }); - yield* service.activity(reference); - assert.strictEqual(activityCalls, 2); - }), -); - -it.effect("shares linked summaries and reuses them for display without asking the host again", () => - Effect.gen(function* () { - let calls = 0; - let failing = false; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.sync(() => { - calls += 1; - return failing; - }).pipe( - Effect.tap(() => Effect.yieldNow), - Effect.flatMap((shouldFail) => - shouldFail - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestSummary", - reason: "failed", - detail: "HTTP 504", - }), - ) - : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - ), - ), - }), - ], - }); - - yield* Effect.all( - [ - service.summary(reference, { recoverTransientFailure: false }), - service.summary(reference, { recoverTransientFailure: false }), - ], - { concurrency: "unbounded" }, - ); - assert.strictEqual(calls, 1); - + yield* service.detail(reference); + assert.strictEqual((yield* service.diff(reference)).patch, "old patch"); + revision = "2026-07-02T00:01:00Z"; + patch = "new patch"; yield* TestClock.adjust("61 seconds"); - failing = true; - const strict = yield* Effect.flip(service.summary({ ...reference, allowStale: false })); - assert.strictEqual(strict._tag, "PullRequestOperationError"); - - const stale = yield* service.summary(reference); - assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); - assert.strictEqual(calls, 2); + yield* service.detail(reference); + yield* Effect.yieldNow; + assert.strictEqual((yield* service.detail(reference)).updatedAt, revision); + yield* Deferred.succeed(releaseSummary, undefined); + yield* Fiber.join(coldSummary); + yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual((yield* service.diff(reference)).patch, "new patch"); + assert.strictEqual(diffCalls, 2); - yield* service.invalidate({ reference }); - const invalidated = yield* Effect.flip(service.summary(reference)); - assert.strictEqual(invalidated._tag, "PullRequestOperationError"); + revision = "2026-07-02T00:02:00Z"; + patch = "summary-discovered patch"; + yield* TestClock.adjust("61 seconds"); + yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual((yield* service.diff(reference)).patch, patch); + assert.strictEqual(diffCalls, 3); }), ); @@ -5257,64 +2638,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", () => @@ -5357,124 +2680,97 @@ it.effect( }), ); -it.effect("reuses an observed merged state for strict settlement reads", () => +it.effect("notifies linked threads when detail changes draft status in either direction", () => Effect.gen(function* () { + let isDraft = true; + let 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("merged body", 4), - state: "merged", - updatedAt: "2026-07-03T00:00:00Z", - }), - getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), + Effect.sync(() => ({ ...hostedChangeRequest("body"), isDraft, updatedAt })), }), ], }); + const changes = yield* service.subscribeStateChanges; + const read = () => service.detail({ ...reference, allowStale: false }); + yield* read(); + yield* read(); + isDraft = false; + yield* service.invalidate({ reference }); + yield* read(); + yield* read(); + isDraft = true; + yield* service.invalidate({ reference }); + yield* read(); - yield* service.detail(reference); + isDraft = false; + updatedAt = "2026-07-01T00:00:00Z"; + yield* service.invalidate({ reference }); + yield* read(); - const summary = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(summary.state, "merged"); - assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); + // A different PR ends collection after all the synchronous notifications above. + yield* service.reportState({ reference: { ...reference, number: 2 }, state: "open" }); + const events = yield* Stream.runCollect( + changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), + ); + assert.deepStrictEqual(events, [ + { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, + { host: "github.com", repository: "acme/web", number: 1 }, + ]); }), ); -it.effect("announces state a detail read sees first or newly", () => +it.effect("deduplicates routed PR reports without dropping draft changes", () => Effect.gen(function* () { - let detail = { - state: "open" as "open" | "closed" | "merged", - updatedAt: "2026-07-02T00:00:00Z", - }; - let summaryState: "open" | "merged" = "open"; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), - getChangeRequestSummary: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-04T00:00:00Z"), - state: summaryState, - }), - }), - ], - }); - const announced: Array = []; - yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => - Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), - ).pipe(Effect.forkChild({ startImmediately: true })); - const readDetail = Effect.gen(function* () { - yield* service.invalidate({ reference }); - yield* service.detail(reference); - yield* Effect.yieldNow; + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); - - 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); + const changes = yield* service.subscribeStateChanges; + for (const isDraft of [true, true, false, false, true]) { + yield* service.reportState({ reference, state: "open", isDraft }); + } + yield* service.reportState({ reference, state: "open" }); + yield* service.reportState({ reference, state: "open" }); + yield* service.reportState({ reference: { ...reference, number: 2 }, state: "open" }); + const events = yield* Stream.runCollect( + changes.pipe(Stream.takeUntil((event) => event.number === 2, { excludeLast: true })), + ); + assert.deepStrictEqual( + events.map((event) => event.number), + [1, 1, 1, 1], + ); }), ); -it.effect("announces a reported state without trusting it", () => +it.effect("reuses an observed merged state for strict settlement reads", () => 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 }), + getChangeRequest: () => + Effect.succeed({ + ...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 note = (state: "open" | "closed" | "merged") => - service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow)); - const readDetail = Effect.gen(function* () { - yield* service.invalidate({ reference }); - yield* service.detail(reference); - yield* Effect.yieldNow; - }); - yield* readDetail; - yield* note("closed"); - yield* note("closed"); - assert.strictEqual(announced.length, 2); - yield* TestClock.adjust("5 minutes"); - yield* note("closed"); - assert.strictEqual(announced.length, 3); - - yield* note("open"); - assert.strictEqual(announced.length, 4); - - yield* note("merged"); - detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 6); + 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"); }), ); @@ -5506,230 +2802,31 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary }); const first = yield* service.detail(reference); - assert.strictEqual(first.title, "Change request 1"); - - summaryTitle = "merged title"; - summaryState = "merged"; - yield* TestClock.adjust("61 seconds"); - const settled = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(settled.title, "merged title"); - assert.strictEqual(settled.state, "merged"); - - yield* TestClock.adjust("16 seconds"); - const stale = yield* service.detail(reference); - assert.strictEqual(stale.title, "Change request 1"); - yield* Effect.yieldNow; - - const display = yield* service.summary(reference); - assert.strictEqual(display.title, "merged title"); - assert.strictEqual(display.state, "merged"); - assert.strictEqual(detailCalls, 2); - - summaryTitle = "updated after merge"; - yield* TestClock.adjust("61 seconds"); - assert.strictEqual((yield* service.summary(reference)).title, "merged title"); - const refreshed = yield* service.summary({ ...reference, allowStale: false }); - assert.strictEqual(refreshed.title, "updated after merge"); - assert.strictEqual(refreshed.state, "merged"); - }), -); - -it.effect("does not let a still-cached detail overwrite a fresher linked summary", () => - Effect.gen(function* () { - let summaryTitle = "old title"; - let summaryState: "open" | "merged" = "open"; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)), - getChangeRequestSummary: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - title: summaryTitle, - state: summaryState, - }), - }), - ], - }); - - const first = yield* service.detail(reference); - assert.strictEqual(first.title, "Change request 1"); - - summaryTitle = "merged title"; - summaryState = "merged"; - const settled = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(settled.state, "merged"); - - const cached = yield* service.detail(reference); - assert.strictEqual(cached.title, "Change request 1"); - yield* Effect.yieldNow; - - const display = yield* service.summary(reference); - assert.strictEqual(display.title, "merged title"); - assert.strictEqual(display.state, "merged"); - }), -); - -it.effect("tells the client when the host has no pull request under that number", () => - Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequest", - reason: "not-found", - detail: "Pull request not found. Check the PR number or URL and try again.", - }), - ), - }), - ], - }); - - const error = yield* Effect.flip(service.detail(reference)); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found"); - }), -); - -it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => - Effect.gen(function* () { - let failing = false; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - failing - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequest", - reason: "failed", - detail: "spawn gh EAGAIN", - }), - ) - : Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "last good body", - changedFiles: 2, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }), - }), - ], - }); - - yield* service.detail(reference); - yield* TestClock.adjust("61 seconds"); - failing = true; - const strict = yield* Effect.flip(service.detail({ ...reference, allowStale: false })); - assert.strictEqual(strict._tag, "PullRequestOperationError"); - const stale = yield* service.detail(reference); - assert.strictEqual(stale.body, "last good body"); - - yield* service.invalidate({ reference }); - const invalidated = yield* Effect.flip(service.detail(reference)); - assert.strictEqual(invalidated._tag, "PullRequestOperationError"); - }), -); - -it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => - Effect.gen(function* () { - const detailWith = (autoMergeEnabled: boolean | undefined) => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }), - }), - }), - ], - }); - return yield* service.detail({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }); - }); - - assert.strictEqual((yield* detailWith(true)).autoMergeEnabled, true); - assert.strictEqual((yield* detailWith(false)).autoMergeEnabled, false); - assert.isUndefined((yield* detailWith(undefined)).autoMergeEnabled); - }), -); - -it.effect("narrows the rows of a host that ignored the filters it was handed", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/a", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true }, - changeRequest(2, "2026-07-01T00:00:00Z"), - ], - truncated: false, - continues: false, - }), - }), - ], - }); + assert.strictEqual(first.title, "Change request 1"); - const result = yield* service.list({ state: "open", filters: { draft: "hide" } }); + summaryTitle = "merged title"; + summaryState = "merged"; + yield* TestClock.adjust("61 seconds"); + const settled = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(settled.title, "merged title"); + assert.strictEqual(settled.state, "merged"); - assert.deepStrictEqual( - result.entries.map((entry) => entry.number), - [2], - ); + yield* TestClock.adjust("16 seconds"); + const stale = yield* service.detail(reference); + assert.strictEqual(stale.title, "Change request 1"); + yield* Effect.yieldNow; + + const display = yield* service.summary(reference); + assert.strictEqual(display.title, "merged title"); + assert.strictEqual(display.state, "merged"); + assert.strictEqual(detailCalls, 2); + + summaryTitle = "updated after merge"; + yield* TestClock.adjust("61 seconds"); + assert.strictEqual((yield* service.summary(reference)).title, "merged title"); + const refreshed = yield* service.summary({ ...reference, allowStale: false }); + assert.strictEqual(refreshed.title, "updated after merge"); + assert.strictEqual(refreshed.state, "merged"); }), ); @@ -5778,45 +2875,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) => @@ -5961,61 +3019,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({ @@ -6042,260 +3045,24 @@ it.effect("judges the review filter only on a host that summarises its reviews", ], truncated: false, continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(3, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const none = yield* service.list({ state: "open", filters: { review: "none" } }); - assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); - - const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); - assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); - }), -); - -it.effect("sends only the words a rewrite carries", () => - Effect.gen(function* () { - const received: Array<{ title?: string | undefined; body?: string | undefined }> = []; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateChangeRequest: (input) => { - received.push({ title: input.title, body: input.body }); - return Effect.void; - }, - }), - ], - }); - - yield* service.update({ ...reference, title: "A better title" }); - yield* service.update({ ...reference, body: "" }); - yield* service.update({ ...reference, title: "Both", body: "at once" }); - - assert.deepStrictEqual(received, [ - { title: "A better title", body: undefined }, - { title: undefined, body: "" }, - { title: "Both", body: "at once" }, - ]); - }), -); - -it.effect("refuses a rewrite that changes nothing, before any call is made", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateChangeRequest: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.update({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "Nothing was changed."); - }), -); - -it.effect("refuses to rewrite anything on a host that never claimed it", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - updateChangeRequest: () => Effect.die("must not be called"), - updateComment: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - - const rewriteRefused = yield* Effect.flip(service.update({ ...reference, title: "New" })); - const commentRefused = yield* Effect.flip( - service.updateComment({ - ...reference, - commentId: "IC_1", - kind: "issue-comment", - body: "New", - }), - ); - - assert.include(rewriteRefused.message, "cannot rewrite a change request."); - assert.include(commentRefused.message, "cannot rewrite a comment."); - }), -); - -it.effect("passes a rewritten remark through with the id and kind it arrived under", () => - Effect.gen(function* () { - let received: { id: string; kind: string; body: string } | null = null; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateComment: (input) => { - received = { id: input.commentId, kind: input.kind, body: input.body }; - return Effect.void; - }, - }), - ], - }); - - yield* service.updateComment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - commentId: "PRRC_1", - kind: "review-comment", - body: "Second thoughts", - }); - - assert.deepStrictEqual(received, { - id: "PRRC_1", - kind: "review-comment", - body: "Second thoughts", - }); - }), -); - -it.effect("refuses a remark rewritten into nothing but whitespace", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateComment: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.updateComment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - commentId: "IC_1", - kind: "issue-comment", - body: " \n ", - }), - ); - - assert.include(error.message, "A comment cannot be empty."); - }), -); - -it.effect("forgets the cached detail after a rewrite or terminal turn", () => - Effect.gen(function* () { - let coreCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => { - coreCalls += 1; - return Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }); - }, - }), - ], - }); - - yield* service.detail(reference); - yield* service.update({ ...reference, title: "Renamed" }); - yield* service.detail(reference); - assert.strictEqual(coreCalls, 2); - - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* service.detail(reference); - assert.strictEqual(coreCalls, 3); - }), -); - -it.effect("names the signed-in account in the detail, and says nothing where the host cannot", () => - Effect.gen(function* () { - const detailFrom = (provider: PullRequestProviderApi) => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [provider], - }); - return yield* service.detail({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }); - }); - const readable = fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, + }), + }), + fakeProvider(SourceControlProviderKind.make("gitlab"), { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(3, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), }), + ], }); - const named = yield* detailFrom(readable); - const unnamed = yield* detailFrom({ - ...readable, - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), - }); + const none = yield* service.list({ state: "open", filters: { review: "none" } }); + assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); - assert.strictEqual(named.viewer, "bilal"); - assert.strictEqual(unnamed.viewer, undefined); + const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); + assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); }), ); @@ -6358,166 +3125,6 @@ it.effect("partial host viewed writes invalidate marks but keep the diff cached" }), ); -it.effect("keeps the diff cached across a file being ticked off", () => - Effect.gen(function* () { - let diffReads = 0; - let viewedReads = 0; - let state: "viewed" | "dismissed" = "viewed"; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - viewedFiles: "host", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getDiff: () => { - diffReads += 1; - return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null }); - }, - getFilesViewed: () => { - viewedReads += 1; - return Effect.succeed({ - files: [{ path: "src/a.ts", state }], - truncated: false, - }); - }, - setFilesViewed: () => Effect.void, - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - - yield* service.diff(reference); - yield* service.filesViewed(reference); - yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }); - yield* service.diff(reference); - yield* service.filesViewed(reference); - - assert.strictEqual(diffReads, 1); - assert.strictEqual(viewedReads, 2); - - state = "dismissed"; - yield* service.invalidate({ reference, filesViewedOnly: true }); - yield* service.diff(reference); - assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - assert.strictEqual(diffReads, 1); - assert.strictEqual(viewedReads, 3); - }), -); - -it.effect("returns large diff slices intact without retaining them in either cache", () => - Effect.gen(function* () { - let reads = 0; - const patch = "\u{1f4bb}".repeat(140_000); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - reads += 1; - return { patch, truncated: false, nextCursor: "2" }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - for (const input of [ - reference, - { ...reference, cursor: "2" }, - { ...reference, commit: "a".repeat(40) }, - ]) { - const before = reads; - assert.deepStrictEqual(yield* service.diff(input), { - patch, - truncated: false, - nextCursor: "2", - }); - assert.deepStrictEqual(yield* service.diff(input), { - patch, - truncated: false, - nextCursor: "2", - }); - assert.strictEqual(reads, before + 2); - } - }), -); - -it.effect("caches a small replacement after releasing a large diff", () => - Effect.gen(function* () { - let reads = 0; - const largePatch = "x".repeat(300_000); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - reads += 1; - return { - patch: reads === 1 ? largePatch : "@@ small replacement", - truncated: false, - nextCursor: null, - }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - assert.strictEqual((yield* service.diff(reference)).patch, largePatch); - assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement"); - assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement"); - assert.strictEqual(reads, 2); - }), -); - -it.effect("releases an expired diff nobody reads again", () => - Effect.gen(function* () { - NodeV8.setFlagsFromString("--expose-gc"); - const collect = NodeVM.runInNewContext("gc") as () => void; - const settle = Effect.promise(async () => { - // A weak reference keeps its target for the rest of the job that created it. - await new Promise((resolve) => setImmediate(resolve)); - collect(); - }); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => Effect.sync(() => ({ patch: "@@", truncated: false, nextCursor: null })), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const read = (input: Parameters[0]) => - Effect.map(service.diff(input), (result) => new WeakRef(result)); - const page = yield* read(reference); - const commit = yield* read({ ...reference, commit: "a".repeat(40) }); - - yield* TestClock.adjust("9 minutes"); - yield* settle; - assert.isDefined(page.deref()); - assert.isDefined(commit.deref()); - - yield* TestClock.adjust("2 minutes"); - yield* settle; - assert.isUndefined(page.deref()); - assert.isUndefined(commit.deref()); - }), -); - const environmentViewedProvider = ( revisions: Map, asked: Array>, @@ -6710,197 +3317,60 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi name: "Forgejo", baseUrl: "https://forge.example:3000", }, - }, - provider: undefined as never, - }), - }); - const first = { - projectId: "p1" as ProjectId, - host: "forge.example:3000", - repository: "reviewer/first", - number: 1, - }; - const second = { ...first, repository: "reviewer/second" }; - const files = [{ path: "same.ts", viewed: true }]; - yield* service.setFilesViewed({ ...first, files }); - assert.deepStrictEqual((yield* service.filesViewed(second)).files, []); - yield* service.setFilesViewed({ ...second, files }); - yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] }); - assert.deepStrictEqual((yield* service.filesViewed(second)).files, [ - { path: "same.ts", state: "viewed" }, - ]); - - projects.push( - project({ - id: "p2", - title: "second repository", - workspaceRoot: "/second", - repository: "reviewer/second", - provider: SourceControlProviderKind.make("forgejo"), - host: "forge.example", - remoteUrl: "https://forge.example:3000/reviewer/second.git", - }), - ); - assert.deepStrictEqual( - (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files, - [{ path: "same.ts", state: "viewed" }], - ); - projects.push( - project({ - id: "ssh", - title: "second repository over SSH", - workspaceRoot: "/ssh", - repository: "reviewer/second", - provider: SourceControlProviderKind.make("forgejo"), - host: "ssh.forge.example", - remoteUrl: "git@ssh.forge.example:reviewer/second.git", - }), - ); - assert.deepStrictEqual( - (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files, - [{ path: "same.ts", state: "viewed" }], - ); - }), -); - -it.effect("keeps viewed files itself for a host that keeps none of its own", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService( - new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]), - asked, - ); - - const empty = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual(empty, { files: [], truncated: false }); - assert.deepStrictEqual(asked, []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/b.ts", viewed: true }, - ], - }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - assert.strictEqual(marked.truncated, false); - assert.deepStrictEqual( - asked.map((paths) => [...paths].toSorted()), - [["src/a.ts", "src/b.ts"]], - ); - }), -); - -it.effect("holds what a whole-change answer carried, so the next tick reads nothing", () => - Effect.gen(function* () { - const asked: Array> = []; - const head = new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(head, []), - getFileRevisions: (input) => { - asked.push(input.paths); - return Effect.succeed({ revisions: head, complete: true }); - }, - }, - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - - assert.deepStrictEqual(asked, [["src/a.ts"]]); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - - yield* TestClock.adjust("2 minutes"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); - }), -); - -it.effect("reads the marks without asking the host what the head has every time", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("20 seconds"); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(marked.files, [{ path: "src/a.ts", state: "viewed" }]); - assert.deepStrictEqual(asked, [["src/a.ts"]]); - }), -); - -it.effect("answers the marks from what it last heard while it asks the host again", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([["src/a.ts", "blob-a"]]); - const service = yield* environmentViewedService(revisions, asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + }, + provider: undefined as never, + }), }); - revisions.set("src/a.ts", "blob-a-again"); - yield* TestClock.adjust("90 seconds"); - const held = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(held.files, [{ path: "src/a.ts", state: "viewed" }]); - assert.strictEqual(asked.length, 2); - - yield* TestClock.adjust("20 seconds"); - const caught = yield* service.filesViewed(GITLAB_REFERENCE); + const first = { + projectId: "p1" as ProjectId, + host: "forge.example:3000", + repository: "reviewer/first", + number: 1, + }; + const second = { ...first, repository: "reviewer/second" }; + const files = [{ path: "same.ts", viewed: true }]; + yield* service.setFilesViewed({ ...first, files }); + assert.deepStrictEqual((yield* service.filesViewed(second)).files, []); + yield* service.setFilesViewed({ ...second, files }); + yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] }); + assert.deepStrictEqual((yield* service.filesViewed(second)).files, [ + { path: "same.ts", state: "viewed" }, + ]); - assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]); - assert.strictEqual(asked.length, 2); + projects.push( + project({ + id: "p2", + title: "second repository", + workspaceRoot: "/second", + repository: "reviewer/second", + provider: SourceControlProviderKind.make("forgejo"), + host: "forge.example", + remoteUrl: "https://forge.example:3000/reviewer/second.git", + }), + ); + assert.deepStrictEqual( + (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files, + [{ path: "same.ts", state: "viewed" }], + ); + projects.push( + project({ + id: "ssh", + title: "second repository over SSH", + workspaceRoot: "/ssh", + repository: "reviewer/second", + provider: SourceControlProviderKind.make("forgejo"), + host: "ssh.forge.example", + remoteUrl: "git@ssh.forge.example:reviewer/second.git", + }), + ); + assert.deepStrictEqual( + (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files, + [{ path: "same.ts", state: "viewed" }], + ); }), ); -it.effect("asks the host about a file it has not been asked about before", () => +it.effect("keeps viewed files itself for a host that keeps none of its own", () => Effect.gen(function* () { const asked: Array> = []; const service = yield* environmentViewedService( @@ -6911,16 +3381,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( @@ -6930,44 +3401,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"]], ); }), ); @@ -7001,26 +3438,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(), []); @@ -7069,71 +3486,6 @@ it.effect("leaves a mark alone when the host could not say what the head has of }), ); -it.effect("keeps a file cleared that the press could not learn a version for", () => - Effect.gen(function* () { - const revisions = new Map([["src/past-the-cut.ts", "blob-b"]]); - const unreadable = new Set(["src/past-the-cut.ts"]); - const service = yield* environmentViewedService(revisions, [], unreadable); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/past-the-cut.ts", viewed: true }], - }); - unreadable.delete("src/past-the-cut.ts"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/past-the-cut.ts", state: "viewed" }, - ]); - }), -); - -it.effect("keeps the version it last heard when a later read of the head stops short", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([["src/a.ts", "blob-a"]]); - const unreadable = new Set(); - const service = yield* environmentViewedService(revisions, asked, unreadable); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - - unreadable.add("src/a.ts"); - yield* TestClock.adjust("90 seconds"); - yield* service.filesViewed(GITLAB_REFERENCE); - yield* TestClock.adjust("20 seconds"); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - }), -); - -it.effect("re-asks what the head has of a marked file after a whole-workspace refresh", () => - Effect.gen(function* () { - const revisions = new Map([["src/a.ts", "blob-a"]]); - const service = yield* environmentViewedService(revisions, []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({}); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - }), -); - it.effect("forgets what the head had of a marked file once a mutation moves the head", () => Effect.gen(function* () { const revisions = new Map([["src/a.ts", "blob-a"]]); @@ -7152,57 +3504,6 @@ it.effect("forgets what the head had of a marked file once a mutation moves the }), ); -it.effect("still reports its own marks when the host will not say what the head has", () => - Effect.gen(function* () { - let answering = true; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - viewedFiles: "environment", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getFilesViewed: () => Effect.die("the host keeps no marks of its own"), - setFilesViewed: () => Effect.die("the host keeps no marks of its own"), - getFileRevisions: (input) => - answering - ? Effect.succeed({ - revisions: new Map(input.paths.map((path) => [path, "blob-a"] as const)), - }) - : Effect.fail(requestFailed), - }), - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - answering = false; - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - }), -); - it.effect("finishes two presses on one file in the order they were made", () => Effect.gen(function* () { const held = yield* Deferred.make(); @@ -7318,114 +3619,23 @@ it.effect("keeps the marks of two Azure repositories of the same name apart", () }); assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); - }), -); - -it.effect("keeps environment marks apart from another change request's", () => - Effect.gen(function* () { - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); - - assert.deepStrictEqual(other.files, []); - }), -); - -it.effect("bounds the paths one change request's held revisions carry", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map(), asked); - const batch = (prefix: string, count: number) => - Array.from({ length: count }, (_, index) => ({ - path: `${prefix}/${String(index).padStart(4, "0")}.ts`, - viewed: true, - })); - const press = (prefix: string, count: number) => - service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) }); - - yield* press("a", MAX_FILE_REVISION_PATHS / 2); - yield* press("b", MAX_FILE_REVISION_PATHS / 2); - yield* press("c", 1); - const pressed = asked.length; - - yield* service.filesViewed(GITLAB_REFERENCE); - - assert.strictEqual(asked.length, pressed + 1); - assert.ok(asked.at(-1)?.includes("a/0000.ts")); - }), -); - -it.effect("keeps the marked paths when a whole-change answer is wider than the cap", () => - Effect.gen(function* () { - const head = new Map( - Array.from( - { length: MAX_FILE_REVISION_PATHS + 178 }, - (_, index) => - [`src/f${String(index).padStart(4, "0")}.ts`, `blob-${String(index)}`] as const, - ), - ); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(head, []), - getFileRevisions: () => Effect.succeed({ revisions: head, complete: true }), - }, - ], - }); - const ticked = ["src/f0000.ts", "src/f0500.ts"]; - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: ticked.map((path) => ({ path, viewed: true })), - }); - for (const path of ticked) head.set(path, "blob-moved"); - yield* TestClock.adjust("11 minutes"); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/f0000.ts", state: "dismissed" }, - { path: "src/f0500.ts", state: "dismissed" }, - ], - ); + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); }), ); -it.effect("keeps the change request being ticked through, not the one pressed first", () => +it.effect("keeps environment marks apart from another change request's", () => Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - const press = (number: number) => - service.setFilesViewed({ - ...GITLAB_REFERENCE, - number, - files: [{ path: "src/a.ts", viewed: true }], - }); + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); - yield* press(1); - for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) { - yield* press(2 + filled); - yield* press(1); - } + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); - assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY); + assert.deepStrictEqual(other.files, []); }), ); @@ -7464,40 +3674,6 @@ it.effect("keeps one reader's marks on a host that names nobody", () => }), ); -it.effect("puts a listing and a press for one host on a single viewer lookup", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.gen(function* () { - viewerLookups += 1; - yield* Effect.yieldNow; - return "bilal"; - }), - }, - ], - }); - - yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], { - concurrency: 2, - }); - - assert.strictEqual(viewerLookups, 1); - }), -); - it.effect("carries a bounded number of its own marks and says it held more", () => Effect.gen(function* () { const asked: Array> = []; @@ -7521,248 +3697,6 @@ it.effect("carries a bounded number of its own marks and says it held more", () }), ); -it.effect("records a press while the host is backing off", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.sync(() => { - viewerLookups += 1; - return "bilal"; - }), - getFileRevisions: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getFileRevisions", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("11 minutes"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - - assert.deepStrictEqual( - [...(yield* service.filesViewed(GITLAB_REFERENCE)).files].toSorted((left, right) => - left.path.localeCompare(right.path), - ), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - assert.strictEqual(viewerLookups, 2); - }), -); - -it.effect("asks who is reading through a pause only for the press that is waiting on it", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.sync(() => { - viewerLookups += 1; - return "bilal"; - }), - listChangeRequests: () => - Effect.succeed({ items: [], truncated: false, continues: true }), - getFileRevisions: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getFileRevisions", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - assert.strictEqual(viewerLookups, 1); - yield* TestClock.adjust("11 minutes"); - - const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - assert.strictEqual(listed._tag, "PullRequestOperationError"); - assert.strictEqual(viewerLookups, 1); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.strictEqual(viewerLookups, 2); - }), -); - -it.effect("does not ask a paused host who is reading again after the ask failed", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.suspend(() => { - viewerLookups += 1; - return Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getViewer", - reason: "failed", - detail: "glab exited with status 1", - }), - ); - }), - listChangeRequests: () => - Effect.succeed({ items: [], truncated: false, continues: true }), - runAction: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "runAction", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); - - yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); - assert.strictEqual(viewerLookups, 1); - yield* Effect.flip(service.runAction({ ...GITLAB_REFERENCE, action: "merge" })); - - yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - yield* TestClock.adjust("11 minutes"); - yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - assert.strictEqual(viewerLookups, 1); - }), -); - -it.effect("refuses the marks when the host could not be asked who is reading", () => - Effect.gen(function* () { - let answering = true; - const service = yield* environmentViewedServiceWithViewer( - new Map([["src/a.ts", "blob-a"]]), - () => - answering - ? Effect.succeed("bilal") - : Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getViewer", - reason: "failed", - detail: "glab exited with status 1", - }), - ), - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - answering = false; - yield* TestClock.adjust("11 minutes"); - - const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); - const write = yield* Effect.flip( - service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }), - ); - assert.strictEqual(read._tag, "PullRequestOperationError"); - assert.strictEqual(write._tag, "PullRequestOperationError"); - - answering = true; - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - }), -); - -it.effect("refuses to track viewed files on a host that does not", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getFilesViewed: () => Effect.die("must not be called"), - setFilesViewed: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; - - const read = yield* Effect.flip(service.filesViewed(reference)); - const write = yield* Effect.flip( - service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }), - ); - - assert.strictEqual(read._tag, "PullRequestOperationError"); - assert.strictEqual(write._tag, "PullRequestOperationError"); - }), -); - it.effect("keeps Azure continuation cursors separate for repositories with the same name", () => Effect.gen(function* () { const seen: string[] = []; @@ -7800,65 +3734,6 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ); -it.effect.each([ - { action: "merge", environmentValue: true, projectValue: false, expected: false }, - { action: "merge", environmentValue: false, projectValue: true, expected: true }, - { action: "merge", environmentValue: true, projectValue: undefined, expected: true }, - { action: "merge", environmentValue: false, projectValue: undefined, expected: false }, - { action: "enable-auto-merge", environmentValue: true, projectValue: false, expected: false }, - { action: "enable-auto-merge", environmentValue: false, projectValue: true, expected: true }, - { action: "enable-auto-merge", environmentValue: true, projectValue: undefined, expected: true }, -] as const)( - "resolves agent credits for $action with environment=$environmentValue and project=$projectValue", - ({ action, environmentValue, projectValue, expected }) => - Effect.gen(function* () { - const calls: boolean[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), - ], - settings: { - ...DEFAULT_SERVER_SETTINGS, - removeAgentCreditsOnMerge: environmentValue, - projectSettingsOverrides: - projectValue === undefined - ? {} - : { - ["p1" as ProjectId]: { removeAgentCreditsOnMerge: projectValue }, - }, - }, - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - actions: ["merge", "enable-auto-merge"], - }, - getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "enable-auto-merge"], - comment: true, - resolve: true, - verdicts: ["comment"], - requestReviewers: false, - }), - runAction: (input) => - Effect.sync(() => { - calls.push(input.removeAgentCreditsOnMerge === true); - }), - }), - ], - }); - yield* service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action, - }); - assert.deepStrictEqual(calls, [expected]); - }), -); - it.effect("pauses uncached GitLab activity after only the reactions endpoint is rate limited", () => Effect.gen(function* () { let commandCalls = 0; @@ -8014,47 +3889,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/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f0c2b580064ad..3c6c9b333fbfe 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -102,7 +102,10 @@ export interface PullRequestMergeEvent extends PullRequestRef { readonly mergedAt: string; } -type PullRequestReading = Pick; +type PullRequestReading = Pick< + PullRequestSummary, + "state" | "isDraft" | "updatedAt" | "observedAt" +>; function supersedesReading(current: PullRequestReading | undefined, next: PullRequestReading) { if (current === undefined) return true; @@ -2891,10 +2894,12 @@ export const make = Effect.gen(function* () { } detailStates.set(scope, { state: next.state, + isDraft: next.isDraft, updatedAt: next.updatedAt, observedAt: next.observedAt, }); - return current?.state !== next.state && ref.host !== undefined + return (current?.state !== next.state || current?.isDraft !== next.isDraft) && + ref.host !== undefined ? PubSub.publish(stateChanges, { host: ref.host, repository: ref.repository, @@ -2906,22 +2911,34 @@ export const make = Effect.gen(function* () { // must never outrank a reading this environment made itself. const reportedStates = new Map< string, - { readonly state: PullRequestState; readonly atMs: number } + { + readonly state: PullRequestState; + readonly isDraft: boolean | undefined; + readonly atMs: number; + } >(); - const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => + const reportState: PullRequestService["Service"]["reportState"] = ({ + reference, + state, + isDraft, + }) => Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( Effect.flatMap(([ref, nowMs]) => Effect.suspend(() => { const scope = refScope(ref); const reported = reportedStates.get(scope); - if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) + if ( + reported?.state === state && + reported.isDraft === isDraft && + nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS + ) return Effect.void; reportedStates.delete(scope); if (reportedStates.size >= REF_EPOCH_CAPACITY) { const oldest = reportedStates.keys().next().value; if (oldest !== undefined) reportedStates.delete(oldest); } - reportedStates.set(scope, { state, atMs: nowMs }); + reportedStates.set(scope, { state, isDraft, atMs: nowMs }); return ref.host === undefined ? Effect.void : PubSub.publish(stateChanges, { diff --git a/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts b/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts deleted file mode 100644 index ecb347d77a867..0000000000000 --- 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 3db791492c6d2..0000000000000 --- 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 353c10eb52602..2f483490e86ed 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 233013ac7146d..69479b1ccfeed 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 f172a1fe567e2..fc2913e3efd43 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.fork.test.ts b/apps/server/src/relay/AgentAwarenessRelay.fork.test.ts new file mode 100644 index 0000000000000..08a0e68cb7305 --- /dev/null +++ b/apps/server/src/relay/AgentAwarenessRelay.fork.test.ts @@ -0,0 +1,514 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { + EnvironmentId, + EventId, + MessageId, + NodeId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadShell, + ProjectId, + ProviderInstanceId, + ProviderDriverKind, + RunId, + RuntimeRequestId, + ThreadId, +} from "@t3tools/contracts"; +import { RelayAgentActivityState } from "@t3tools/contracts/relay"; + +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 Queue from "effect/Queue"; +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 FetchHttpClient from "effect/http/FetchHttpClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "../cloud/config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts"; + +const THREAD_ID = ThreadId.make("relay-thread"); +const PROJECT_ID = ProjectId.make("relay-project"); +const NOW = "2026-09-04T12:00:00.000Z"; + +function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { + return { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + runtimeMode: "full-access", + interactionMode: "default", + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: THREAD_ID, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + activeRunId: null, + latestVisibleMessage: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + lastVisitedAt: null, + deletedAt: null, + branch: null, + linkedPullRequest: null, + status: "running", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + latestRunId: null, + latestRunRequestedAt: null, + latestRunStartedAt: null, + latestRunCompletedAt: null, + latestUserMessageAt: null, + createdAt: DateTime.makeUnsafe(NOW), + updatedAt: DateTime.makeUnsafe(NOW), + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + ...overrides, + }; +} + +const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) }); +const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload)); +const unused = () => Effect.die("Unexpected test dependency call"); + +const makeTestRelay = Effect.fnUntraced(function* ( + options: { + readonly respond?: (attempt: number) => Response; + readonly failSecretRead?: (name: string) => boolean; + readonly linked?: boolean; + readonly readShell?: (threadId: ThreadId) => Effect.Effect; + readonly domainEvents?: Stream.Stream; + readonly readRecords?: ThreadManagementService.ThreadManagementService["Service"]["getThreadRecords"]; + readonly projectionStore?: ProjectionStore.ProjectionStoreV2["Service"]; + } = {}, +) { + const values = new Map( + options.linked === false + ? [] + : [ + [PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")], + [RELAY_URL_SECRET, new TextEncoder().encode("https://relay.example.test")], + [RELAY_ISSUER_SECRET, new TextEncoder().encode("https://relay.example.test")], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, new TextEncoder().encode("credential-1")], + ], + ); + const secretReads: string[] = []; + const secrets = ServerSecretStore.ServerSecretStore.of({ + get: (name) => + Effect.suspend(() => { + secretReads.push(name); + if (options.failSecretRead?.(name)) { + return Effect.fail( + new ServerSecretStore.SecretStoreReadError({ + resource: name, + cause: "temporary read failure", + }), + ); + } + return Effect.succeed(Option.fromUndefinedOr(values.get(name))); + }), + create: (name, value) => Effect.sync(() => void values.set(name, value)), + set: (name, value) => Effect.sync(() => void values.set(name, value)), + remove: (name) => Effect.sync(() => void values.delete(name)), + getOrCreateRandom: unused, + }); + const currentShell = yield* Ref.make(shell()); + const shellReads: ThreadId[] = []; + const catchUp = { shellSnapshotReads: 0 }; + const threads = ThreadManagementService.ThreadManagementService.of({ + getThreadShell: (threadId) => + Effect.sync(() => shellReads.push(threadId)).pipe( + Effect.andThen(options.readShell?.(threadId) ?? Ref.get(currentShell)), + ), + getShellSnapshot: () => + Effect.sync(() => { + catchUp.shellSnapshotReads += 1; + return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; + }), + readShellSnapshot: unused, + ensureLegacyTranscript: unused, + dispatch: unused, + searchThread: () => Effect.die("unused"), + searchThreadStream: () => Stream.empty, + getThreadHistoryPage: () => Effect.die("unused"), + getTimelinePage: () => Effect.die("Unused timeline read"), + getMessageCount: () => Effect.die("unused message count"), + getTurnItem: () => Effect.die("unused turn item read"), + getThreadRecords: options.readRecords ?? (() => Effect.die("unused record read")), + getThreadProjection: unused, + getCheckpointContext: unused, + getThreadSnapshot: unused, + getThreadTranscript: unused, + getThreadSnapshotWindow: unused, + getProjectThreadRecords: () => Effect.die("unused project record read"), + getProjectThread: unused, + listProjectThreads: unused, + sendToThread: unused, + assertProviderInstanceEnabled: unused, + isProviderInstanceEnabledForProject: unused, + hasReceipt: unused, + waitForThread: unused, + settleAfterRun: unused, + settleThread: unused, + interruptThread: unused, + requestUserInput: unused, + respondToPendingRequest: unused, + stopWorkflow: () => Effect.die("unused stopWorkflow"), + stopDelegatedTasks: unused, + getThreadEventSequence: unused, + recoverDelegatedTask: unused, + delegatedTaskResultPending: unused, + streamStoredEvents: Stream.empty, + streamStoredEventsFrom: () => Stream.empty, + streamDomainEvents: options.domainEvents ?? Stream.empty, + }); + const publications: Array<{ + readonly url: string; + readonly authorization: string | null; + readonly state: RelayAgentActivityState | null; + }> = []; + const fetch: typeof globalThis.fetch = Object.assign( + ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const body = init?.body; + if (typeof body !== "string" && !(body instanceof Uint8Array)) { + return Promise.reject(new Error("Expected a serialized activity publish payload")); + } + const payload = decodePublishPayload( + typeof body === "string" ? body : new TextDecoder().decode(body), + ); + publications.push({ + url: String(input), + authorization: new Headers(init?.headers).get("authorization"), + state: payload.state, + }); + return Promise.resolve( + options.respond?.(publications.length) ?? Response.json({ ok: true, deliveries: [] }), + ); + }, + { preconnect: () => {} }, + ); + const relay = yield* AgentAwarenessRelay.make.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + Effect.provideService(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("relay-environment")), + getDescriptor: unused(), + }), + Effect.provideService(ProjectService.ProjectService, { + create: unused, + bootstrap: unused, + update: unused, + delete: unused, + getByWorkspaceRoot: unused, + snapshot: Effect.succeed({ projects: [] } as never), + getShell: unused, + listShells: unused, + getById: () => + Effect.succeed( + Option.some({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }), + ), + }), + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.provide( + Layer.merge( + NodeCrypto.layer, + options.projectionStore === undefined + ? ProjectionStore.layerMemory + : Layer.succeed(ProjectionStore.ProjectionStoreV2, options.projectionStore), + ), + ), + ); + return { relay, secrets, secretReads, currentShell, shellReads, publications, catchUp }; +}); + +describe("AgentAwarenessRelay", () => { + it.effect.each([false, true])( + "settles a routed relay request, hidden within the window=%s", + (hide) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const parentId = ThreadId.make("settling-parent"); + const parent = yield* Ref.make( + shell({ + id: parentId, + status: "interrupted", + activeRunId: null, + activityRunStatus: null, + }), + ); + const child = shell({ + pendingRuntimeRequest: { + id: RuntimeRequestId.make("settling-request"), + kind: "user_input", + createdAt: now, + routedTo: parentId, + }, + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }); + const { relay, publications } = yield* makeTestRelay({ + readShell: (id) => (id === parentId ? Ref.get(parent) : Effect.succeed(child)), + }); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 0); + yield* TestClock.adjust("2999 millis"); + yield* relay.drain; + assert.equal(publications.length, 0); + if (hide) { + yield* Ref.update(parent, (current) => ({ ...current, status: "running" as const })); + yield* relay.publishThread(THREAD_ID); + } + yield* TestClock.adjust("1 milli"); + yield* relay.drain; + assert.equal(publications.length, hide ? 0 : 1); + if (!hide) assert.equal(publications[0]?.state?.phase, "waiting_for_input"); + yield* TestClock.adjust("10 seconds"); + yield* relay.drain; + assert.equal(publications.length, hide ? 0 : 1); + }), + ); + + it.effect("publishes an unrouted input immediately", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set( + currentShell, + shell({ + pendingRuntimeRequest: { + id: RuntimeRequestId.make("unrouted"), + kind: "user_input", + createdAt: yield* DateTime.now, + }, + }), + ); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + assert.equal(publications[0]?.state?.phase, "waiting_for_input"); + }), + ); + + it.effect.each([ + { answered: false, queuedResume: false }, + { answered: false, queuedResume: true }, + { answered: true, queuedResume: false }, + ])( + "re-evaluates child input on a parent-only event, answered=$answered, queuedResume=$queuedResume", + ({ answered, queuedResume }) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* Queue.unbounded(); + const pulls = yield* Queue.unbounded(); + const { relay, publications, shellReads } = yield* makeTestRelay({ + projectionStore: store, + readShell: (threadId) => store.getThreadShell(threadId).pipe(Effect.orDie), + readRecords: (threadId, fields, filter) => + store.getThreadRecords(threadId, fields, filter).pipe(Effect.orDie), + domainEvents: Stream.fromEffectRepeat( + Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), + ), + }); + const parentId = ThreadId.make("orchestrator"); + const childId = ThreadId.make("routed-child"); + const runId = RunId.make("parent-run"); + const requestId = RuntimeRequestId.make("child-question"); + const now = yield* DateTime.now; + const parent = { ...shell(), id: parentId, lastVisitedAt: null }; + const child = { + ...parent, + id: childId, + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent" as const, + }, + }; + const run = { + id: runId, + threadId: parentId, + ordinal: 1, + providerInstanceId: parent.providerInstanceId, + modelSelection: parent.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("parent-message"), + rootNodeId: null, + activeAttemptId: null, + status: "running" as const, + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + const request = { + id: requestId, + nodeId: NodeId.make("question-node"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + routedTo: parentId, + responseCapability: { type: "message" as const }, + createdAt: now, + resolvedAt: null, + }; + const seed: OrchestrationV2DomainEvent[] = [ + { + id: EventId.make("parent-created"), + type: "thread.created", + threadId: parentId, + occurredAt: now, + payload: parent, + }, + { + id: EventId.make("child-created"), + type: "thread.created", + threadId: childId, + occurredAt: now, + payload: child, + }, + { + id: EventId.make("parent-running"), + type: "run.updated", + threadId: parentId, + occurredAt: now, + payload: run, + }, + { + id: EventId.make("child-request"), + type: "runtime-request.updated", + threadId: childId, + occurredAt: now, + payload: request, + }, + { + id: EventId.make("delegated-child"), + type: "subagent.updated", + threadId: parentId, + occurredAt: now, + payload: { + id: NodeId.make("delegated-task"), + threadId: parentId, + runId, + parentNodeId: NodeId.make("root"), + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: parent.providerInstanceId, + providerThreadId: null, + childThreadId: childId, + nativeTaskRef: null, + prompt: "Inspect", + title: null, + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + ]; + for (const event of seed) yield* store.apply(event); + yield* relay.publishThread(childId); + assert.equal(publications.length, 0); + if (answered) + yield* store.apply({ + id: EventId.make("child-answered"), + type: "runtime-request.updated", + threadId: childId, + occurredAt: now, + payload: { ...request, status: "resolved", resolvedAt: now }, + }); + yield* relay.start(); + yield* Queue.take(pulls); + for (const id of ["parent-stopped", "parent-stopped-again"]) { + const event: OrchestrationV2DomainEvent = { + id: EventId.make(id), + type: "run.updated", + threadId: parentId, + occurredAt: now, + payload: { ...run, status: "interrupted", completedAt: now }, + }; + yield* store.apply(event); + yield* Queue.offer(events, event); + yield* Queue.take(pulls); + yield* relay.drain; + yield* TestClock.adjust("3 seconds"); + yield* relay.drain; + } + const inputs = publications.filter( + (publication) => publication.state?.threadId === childId, + ); + assert.equal(inputs.length, answered ? 0 : 1); + if (answered) assert.equal(shellReads.filter((id) => id === childId).length, 1); + if (!answered) assert.equal(inputs[0]?.state?.phase, "waiting_for_input"); + if (!answered) { + // Resuming the parent removes its child's owner card without the tombstone debounce. + const resumed = { + id: EventId.make("parent-resumed"), + type: queuedResume ? "run.created" : "run.updated", + threadId: parentId, + occurredAt: now, + payload: queuedResume + ? { + ...run, + id: RunId.make("queued-parent-run"), + ordinal: 2, + status: "queued", + startedAt: null, + } + : run, + } satisfies OrchestrationV2DomainEvent; + yield* store.apply(resumed); + yield* Queue.offer(events, resumed); + yield* Queue.take(pulls); + yield* relay.drain; + const childPublications = publications.filter( + (publication) => publication.url === inputs[0]?.url, + ); + assert.equal(childPublications.length, 2); + assert.isNull(childPublications[1]?.state); + } + }).pipe(Effect.scoped, Effect.provide(ProjectionStore.layerMemory)), + ); +}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 3750eb64bb1c0..6c4e042f80e8b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -10,25 +10,20 @@ import { type OrchestrationV2TurnItem, ProjectId, ProviderInstanceId, - ProviderDriverKind, 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"; 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 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"; @@ -147,8 +142,6 @@ const makeTestRelay = Effect.fnUntraced(function* ( readonly linked?: boolean; readonly readShell?: (threadId: ThreadId) => Effect.Effect; readonly domainEvents?: Stream.Stream; - readonly readRecords?: ThreadManagementService.ThreadManagementService["Service"]["getThreadRecords"]; - readonly projectionStore?: ProjectionStore.ProjectionStoreV2["Service"]; } = {}, ) { const values = new Map( @@ -185,6 +178,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( const shellReads: ThreadId[] = []; const catchUp = { shellSnapshotReads: 0 }; const threads = ThreadManagementService.ThreadManagementService.of({ + requestUserInput: unused, + respondToPendingRequest: unused, getThreadShell: (threadId) => Effect.sync(() => shellReads.push(threadId)).pipe( Effect.andThen(options.readShell?.(threadId) ?? Ref.get(currentShell)), @@ -203,7 +198,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), getTurnItem: () => Effect.die("unused turn item read"), - getThreadRecords: options.readRecords ?? (() => Effect.die("unused record read")), + getThreadRecords: () => Effect.die("unused record read"), getThreadProjection: unused, getCheckpointContext: unused, getThreadSnapshot: unused, @@ -220,8 +215,6 @@ const makeTestRelay = Effect.fnUntraced(function* ( settleAfterRun: unused, settleThread: unused, interruptThread: unused, - requestUserInput: unused, - respondToPendingRequest: unused, stopWorkflow: () => Effect.die("unused stopWorkflow"), stopDelegatedTasks: unused, getThreadEventSequence: unused, @@ -290,58 +283,12 @@ const makeTestRelay = Effect.fnUntraced(function* ( ), }), Effect.provideService(FetchHttpClient.Fetch, fetch), - Effect.provide( - Layer.merge( - NodeCrypto.layer, - options.projectionStore === undefined - ? ProjectionStore.layerMemory - : Layer.succeed(ProjectionStore.ProjectionStoreV2, options.projectionStore), - ), - ), + Effect.provide(NodeCrypto.layer), ); return { relay, secrets, secretReads, currentShell, shellReads, publications, catchUp }; }); 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(); @@ -389,29 +336,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(); @@ -429,22 +353,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(); @@ -467,710 +375,74 @@ 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.each([false, true])( - "settles a routed relay request, hidden within the window=%s", - (hide) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const parentId = ThreadId.make("settling-parent"); - const parent = yield* Ref.make( - shell({ - id: parentId, - status: "interrupted", - activeRunId: null, - activityRunStatus: null, - }), - ); - const child = shell({ - pendingRuntimeRequest: { - id: RuntimeRequestId.make("settling-request"), - kind: "user_input", - createdAt: now, - routedTo: parentId, - }, - lineage: { - rootThreadId: parentId, - parentThreadId: parentId, - relationshipToParent: "subagent", - }, - }); - const { relay, publications } = yield* makeTestRelay({ - readShell: (id) => (id === parentId ? Ref.get(parent) : Effect.succeed(child)), - }); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 0); - yield* TestClock.adjust("2999 millis"); - yield* relay.drain; - assert.equal(publications.length, 0); - if (hide) { - yield* Ref.update(parent, (current) => ({ ...current, status: "running" as const })); - yield* relay.publishThread(THREAD_ID); - } - yield* TestClock.adjust("1 milli"); - yield* relay.drain; - assert.equal(publications.length, hide ? 0 : 1); - if (!hide) assert.equal(publications[0]?.state?.phase, "waiting_for_input"); - yield* TestClock.adjust("10 seconds"); - yield* relay.drain; - assert.equal(publications.length, hide ? 0 : 1); - }), - ); - - it.effect("publishes an unrouted input immediately", () => - Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay(); - yield* Ref.set( - currentShell, - shell({ - pendingRuntimeRequest: { - id: RuntimeRequestId.make("unrouted"), - kind: "user_input", - createdAt: yield* DateTime.now, - }, - }), - ); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 1); - assert.equal(publications[0]?.state?.phase, "waiting_for_input"); - }), - ); - - it.effect.each([ - { answered: false, queuedResume: false }, - { answered: false, queuedResume: true }, - { answered: true, queuedResume: false }, - ])( - "re-evaluates child input on a parent-only event, answered=$answered, queuedResume=$queuedResume", - ({ answered, queuedResume }) => + it.effect.each(["completed", "failed"] as const)( + "publishes a tombstone when a %s thread is settled through a domain event", + (status) => Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; const events = yield* Queue.unbounded(); const pulls = yield* Queue.unbounded(); - const { relay, publications, shellReads } = yield* makeTestRelay({ - projectionStore: store, - readShell: (threadId) => store.getThreadShell(threadId).pipe(Effect.orDie), - readRecords: (threadId, fields, filter) => - store.getThreadRecords(threadId, fields, filter).pipe(Effect.orDie), + const { relay, currentShell, publications } = yield* makeTestRelay({ domainEvents: Stream.fromEffectRepeat( Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), ), }); - const parentId = ThreadId.make("orchestrator"); - const childId = ThreadId.make("routed-child"); - const runId = RunId.make("parent-run"); - const requestId = RuntimeRequestId.make("child-question"); - const now = yield* DateTime.now; - const parent = { ...shell(), id: parentId, lastVisitedAt: null }; - const child = { - ...parent, - id: childId, - lineage: { - rootThreadId: parentId, - parentThreadId: parentId, - relationshipToParent: "subagent" as const, - }, - }; - const run = { - id: runId, - threadId: parentId, - ordinal: 1, - providerInstanceId: parent.providerInstanceId, - modelSelection: parent.modelSelection, - providerThreadId: null, - userMessageId: MessageId.make("parent-message"), - rootNodeId: null, - activeAttemptId: null, - status: "running" as const, - requestedAt: now, - startedAt: now, - completedAt: null, - checkpointId: null, - contextHandoffId: null, - }; - const request = { - id: requestId, - nodeId: NodeId.make("question-node"), - providerTurnId: null, - nativeRequestRef: null, - kind: "user_input" as const, - status: "pending" as const, - routedTo: parentId, - responseCapability: { type: "message" as const }, - createdAt: now, - resolvedAt: null, - }; - const seed: OrchestrationV2DomainEvent[] = [ - { - id: EventId.make("parent-created"), - type: "thread.created", - threadId: parentId, - occurredAt: now, - payload: parent, - }, - { - id: EventId.make("child-created"), - type: "thread.created", - threadId: childId, - occurredAt: now, - payload: child, - }, - { - id: EventId.make("parent-running"), - type: "run.updated", - threadId: parentId, - occurredAt: now, - payload: run, - }, - { - id: EventId.make("child-request"), - type: "runtime-request.updated", - threadId: childId, - occurredAt: now, - payload: request, - }, - { - id: EventId.make("delegated-child"), - type: "subagent.updated", - threadId: parentId, - occurredAt: now, - payload: { - id: NodeId.make("delegated-task"), - threadId: parentId, - runId, - parentNodeId: NodeId.make("root"), - origin: "app_owned", - createdBy: "agent", - driver: ProviderDriverKind.make("codex"), - providerInstanceId: parent.providerInstanceId, - providerThreadId: null, - childThreadId: childId, - nativeTaskRef: null, - prompt: "Inspect", - title: null, - model: null, - status: "running", - result: null, - startedAt: now, - completedAt: null, - updatedAt: now, - }, - }, - ]; - for (const event of seed) yield* store.apply(event); - yield* relay.publishThread(childId); - assert.equal(publications.length, 0); - if (answered) - yield* store.apply({ - id: EventId.make("child-answered"), - type: "runtime-request.updated", - threadId: childId, - occurredAt: now, - payload: { ...request, status: "resolved", resolvedAt: now }, - }); yield* relay.start(); yield* Queue.take(pulls); - for (const id of ["parent-stopped", "parent-stopped-again"]) { - const event: OrchestrationV2DomainEvent = { - id: EventId.make(id), - type: "run.updated", - threadId: parentId, - occurredAt: now, - payload: { ...run, status: "interrupted", completedAt: now }, - }; - yield* store.apply(event); - yield* Queue.offer(events, event); - yield* Queue.take(pulls); - yield* relay.drain; - yield* TestClock.adjust("3 seconds"); - yield* relay.drain; - } - const inputs = publications.filter( - (publication) => publication.state?.threadId === childId, - ); - assert.equal(inputs.length, answered ? 0 : 1); - if (answered) assert.equal(shellReads.filter((id) => id === childId).length, 1); - if (!answered) assert.equal(inputs[0]?.state?.phase, "waiting_for_input"); - if (!answered) { - // Resuming the parent removes its child's owner card without the tombstone debounce. - const resumed = { - id: EventId.make("parent-resumed"), - type: queuedResume ? "run.created" : "run.updated", - threadId: parentId, - occurredAt: now, - payload: queuedResume - ? { - ...run, - id: RunId.make("queued-parent-run"), - ordinal: 2, - status: "queued", - startedAt: null, - } - : run, - } satisfies OrchestrationV2DomainEvent; - yield* store.apply(resumed); - yield* Queue.offer(events, resumed); - yield* Queue.take(pulls); - yield* relay.drain; - const childPublications = publications.filter( - (publication) => publication.url === inputs[0]?.url, - ); - assert.equal(childPublications.length, 2); - assert.isNull(childPublications[1]?.state); - } - }).pipe(Effect.scoped, Effect.provide(ProjectionStore.layerMemory)), - ); - - 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); - }), - ); + yield* relay.publishThread(THREAD_ID); + const terminal = shell({ status }); + yield* Ref.set(currentShell, terminal); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications[1]?.state?.phase, status); - 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 }), + 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); - 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* Queue.take(pulls); yield* relay.drain; - yield* TestClock.adjust("1 minute"); + assert.equal(publications.length, 2); + yield* TestClock.adjust("5 seconds"); 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"); - }), + assert.equal(publications.length, 3); + assert.equal(publications[2]?.state, null); + }).pipe(Effect.scoped), ); - it.effect("bounds retry attempts and resets the budget for a newer update", () => + it.effect("keeps settled live work until its monitor finishes", () => 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" })); + const { relay, currentShell, publications } = yield* makeTestRelay(); 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: [] }), + const held = shell({ + status: "completed", + settledOverride: "settled", + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" }], }); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); + yield* Ref.set(currentShell, held); 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); - }), - ); + assert.equal(publications[0]?.state?.phase, "running"); - 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* Ref.set(currentShell, { ...held, pendingBackgroundTasks: [] }); 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", - (status) => - Effect.gen(function* () { - const events = yield* Queue.unbounded(); - const pulls = yield* Queue.unbounded(); - const { relay, currentShell, publications } = yield* makeTestRelay({ - domainEvents: Stream.fromEffectRepeat( - Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), - ), - }); - 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); - 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], - ); - - yield* Ref.set(currentShell, { - ...active, - settledOverride: "settled", - status: status === "running" ? "completed" : status, - pendingRuntimeRequest: null, - pendingBackgroundTasks: [], - }); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.deepEqual( - publications.map(({ state }) => state?.phase ?? null), - [phase, null], - ); - }), - ); - - it.effect("confirms a first completed state and respects disabling during confirmation", () => - 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 }), - }), - ); - 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); - 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); - }), - ); it.effect("publishes a held completion when its background item ends, not on tool output", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -1178,8 +450,6 @@ describe("AgentAwarenessRelay", () => { const pulls = yield* Queue.unbounded(); const { relay, shellReads, publications } = yield* makeTestRelay({ readShell: (threadId) => store.getThreadShell(threadId).pipe(Effect.orDie), - readRecords: (threadId, fields, filter) => - store.getThreadRecords(threadId, fields, filter).pipe(Effect.orDie), domainEvents: Stream.fromEffectRepeat( Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), ), @@ -1332,69 +602,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 97b37410108ff..d45f48b490c04 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -80,7 +80,7 @@ function isTurnItemPayload( ); } -export function shouldPublishAgentAwarenessEvent( +function shouldPublishAgentAwarenessEvent( event: Pick & { readonly payload?: unknown }, ): boolean { if ( @@ -387,7 +387,7 @@ export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const threads = yield* ThreadManagement.ThreadManagementService; - const projections = yield* ProjectionStore.ProjectionStoreV2; + const projections = yield* Effect.serviceOption(ProjectionStore.ProjectionStoreV2); const projects = yield* ProjectService.ProjectService; const crypto = yield* Crypto.Crypto; const scope = yield* Effect.scope; @@ -889,7 +889,7 @@ export const make = Effect.gen(function* () { event.type !== "thread.metadata-updated" ) return; - const children = yield* projections.getPendingRoutedRequestThreadIds(threadId); + const children = Option.isSome(projections) ? yield* projections.value.getPendingRoutedRequestThreadIds(threadId) : []; yield* Effect.forEach(children, enqueueThreadPublish, { discard: true }); }), ), diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts index 50c8ea2b76324..616643c6e1e9e 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 a911c2b05d3d6..ddd31817c4ce5 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 0b855eaef5ffd..25c119ed47f9c 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 44bb9c7c20029..46af99cb9dfd6 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 5ec3664bf214b..6f4a019a8ac78 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 374564bb7d88e..a8943a35f32df 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 1f30be9477188..c79b7f466f128 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; +import { afterEach, assert, describe, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -16,56 +16,6 @@ describe("ResourceMonitorBinary", () => { vi.restoreAllMocks(); }); - it.effect("skips Linux libc detection on Windows", () => - Effect.gen(function* () { - const getReport = vi.spyOn(process.report, "getReport").mockImplementation(() => { - throw new Error("Linux libc detection must not run on Windows"); - }); - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const binaryPath = `${baseDir}/t3-resource-monitor.exe`; - yield* fileSystem.writeFileString(binaryPath, "binary"); - - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(HostProcess.Architecture, "arm64"), - Effect.provideService(HostProcess.Environment, { - T3CODE_RESOURCE_MONITOR_PATH: binaryPath, - }), - ); - - assert.equal(yield* service.resolve, binaryPath); - expect(getReport).not.toHaveBeenCalled(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect.skipIf(windowsHost)("resolves an executable override", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const binaryPath = `${baseDir}/t3-resource-monitor`; - yield* fileSystem.writeFileString(binaryPath, "binary"); - yield* fileSystem.chmod(binaryPath, 0o755); - - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HostProcess.Architecture, "x64"), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), - Effect.provideService(HostProcess.Environment, { - T3CODE_RESOURCE_MONITOR_PATH: binaryPath, - }), - ); - - assert.equal(yield* service.resolve, binaryPath); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect.skipIf(windowsHost)("resolves an executable override on an unsupported platform", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -115,6 +65,7 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + // regression: #16838 a directory resolved as the resource monitor binary it.effect("rejects a directory override", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -137,24 +88,6 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("rejects unsupported platform and architecture pairs", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "freebsd"), - Effect.provideService(HostProcess.Architecture, "ia32"), - Effect.provideService(HostProcess.Environment, {}), - ); - const error = yield* Effect.flip(service.resolve); - - assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("rejects bundled glibc binaries on musl Linux hosts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts index c4273d7336aca..c12e2e44ed7eb 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,13 +13,20 @@ 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"; import * as ResourceAttribution from "./ResourceAttribution.ts"; import * as ResourceTelemetry from "./ResourceTelemetry.ts"; +// ResourceTelemetry classifies process.pid as the server, so fixture PIDs are +// offsets from it. Fixed PIDs collide whenever the test worker is assigned one. +const SERVER_PID = process.pid; +const CODEX_PID = SERVER_PID + 1; +const SHORT_LIVED_CHILD_PID = SERVER_PID + 2; +const ELECTRON_PID = SERVER_PID + 3; +const MONITOR_PID = SERVER_PID + 4; + function processSample( input: Partial & Pick, @@ -50,14 +56,14 @@ function nativeSnapshot(input: { }): ResourceMonitorSnapshotEvent { const processes = [ processSample({ - pid: process.pid, + pid: SERVER_PID, ppid: 1, startTimeMs: 100, cpuTimeMs: input.sequence * 10, }), processSample({ - pid: 4_242, - ppid: process.pid, + pid: CODEX_PID, + ppid: SERVER_PID, startTimeMs: 200, name: "codex", command: "codex app-server", @@ -65,7 +71,7 @@ function nativeSnapshot(input: { ioWriteBytes: input.childWriteBytes, }), processSample({ - pid: 5_000, + pid: ELECTRON_PID, ppid: 1, startTimeMs: 300, name: "electron", @@ -73,8 +79,8 @@ function nativeSnapshot(input: { cpuTimeMs: input.sequence * 20, }), processSample({ - pid: 9_000, - ppid: process.pid, + pid: MONITOR_PID, + ppid: SERVER_PID, startTimeMs: 400, name: "t3-resource-monitor", command: "t3-resource-monitor", @@ -111,7 +117,7 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot type: "desktopTelemetry", sequence: 1, sampledAtUnixMs, - electronPid: 5_000, + electronPid: ELECTRON_PID, power: { source: "electron-main", idle: "false", @@ -127,7 +133,7 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot speedLimitPercent: Option.some(90), electronProcesses: [ { - pid: 5_000, + pid: ELECTRON_PID, creationTimeMs: 300, type: "Browser", name: "electron", @@ -154,17 +160,17 @@ describe("ResourceTelemetry", () => { childWriteBytes: index * 100, }); const processes = sample.processes - .filter((entry) => entry.pid === process.pid || entry.pid === 4_242) + .filter((entry) => entry.pid === SERVER_PID || entry.pid === CODEX_PID) .map((entry) => - entry.pid === 4_242 + entry.pid === CODEX_PID ? { ...entry, startTimeMs: index } : { ...entry, ioWriteBytes: index * 100 }, ); if (index === 0 || index === 2) { processes.push( processSample({ - pid: 4_243, - ppid: process.pid, + pid: SHORT_LIVED_CHILD_PID, + ppid: SERVER_PID, startTimeMs: 500, cpuTimeMs: index === 0 ? 100 : 350, ioWriteBytes: index === 0 ? 1_000 : 5_000, @@ -192,18 +198,20 @@ describe("ResourceTelemetry", () => { expect(history.topProcesses).toHaveLength(sampleCount + 2); expect( - history.topProcesses.find((entry) => entry.identity.pid === process.pid), + history.topProcesses.find((entry) => entry.identity.pid === SERVER_PID), ).toMatchObject({ sampleCount, cpuTimeMs: (sampleCount - 1) * 10, ioWriteBytes: (sampleCount - 1) * 100, }); - const children = history.topProcesses.filter((entry) => entry.identity.pid === 4_242); + const children = history.topProcesses.filter((entry) => entry.identity.pid === CODEX_PID); expect(children).toHaveLength(sampleCount); for (const child of children) { expect(child).toMatchObject({ sampleCount: 1, cpuTimeMs: 0, ioWriteBytes: 0 }); } - expect(history.topProcesses.find((entry) => entry.identity.pid === 4_243)).toMatchObject({ + expect( + history.topProcesses.find((entry) => entry.identity.pid === SHORT_LIVED_CHILD_PID), + ).toMatchObject({ sampleCount: 2, cpuTimeMs: 250, ioWriteBytes: 4_000, @@ -216,87 +224,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", () => @@ -307,7 +234,7 @@ describe("ResourceTelemetry", () => { sampledAtUnixMs, childCpuTimeMs: 100, childWriteBytes: 1_000, - externalProcesses: [{ pid: 5_000, startTimeMs: 300 }], + externalProcesses: [{ pid: ELECTRON_PID, startTimeMs: 300 }], }); const desktop = { ...desktopSnapshot(sampledAtUnixMs), @@ -336,9 +263,9 @@ describe("ResourceTelemetry", () => { return yield* telemetry.refresh; }).pipe(Effect.provide(layerTelemetry)); - expect(snapshot.processes.find((entry) => entry.identity.pid === 5_000)?.category).toBe( - "electron-main", - ); + expect( + snapshot.processes.find((entry) => entry.identity.pid === ELECTRON_PID)?.category, + ).toBe("electron-main"); expect(snapshot.groups.electron.processStarts).toBe(1); expect(snapshot.groups.backend.processStarts).toBe(3); }), @@ -405,73 +332,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 +399,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 fff4588c8468e..236c0893bd7a8 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 5590813fed865..bfca83e2d0f35 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"; @@ -149,39 +148,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; @@ -327,45 +293,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 981269d52c70b..0000000000000 --- 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 e989768188d03..f5973969747f7 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 63c1526f1ca0e..4cabb35393361 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 4a1a58505c5cb..0000000000000 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { expect, it } from "@effect/vitest"; -import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import { afterEach, vi } from "vite-plus/test"; - -import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; -import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; -import * as Scheduler from "../scheduling/Scheduler.ts"; -import * as SecretRequests from "../secrets/SecretRequests.ts"; -import { ServerActivation } from "../serverActivation.ts"; -import * as ScheduledTaskService from "./ScheduledTaskService.ts"; - -afterEach(() => vi.unstubAllEnvs()); - -const dependencies = Layer.mergeAll( - NodeCrypto.layer, - Scheduler.layer, - Layer.mock(ThreadLaunchService.ThreadLaunchService)({ - launch: () => Effect.die("synthetic dispatch failure"), - }), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - isScratchProject: () => Effect.succeed(false), - }), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), -); -const serviceLayer = ScheduledTaskService.layer.pipe(Layer.provide(dependencies)); -const decodeInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); -const input = { - commandId: "interval-duration", - title: "Interval review", - prompt: "Review the open pull requests.", - enabled: true, - projectId: "project-interval-duration", - workspaceStrategy: { type: "root" }, - modelSelection: { instanceId: "codex", model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", -}; - -it.effect.each([ - { - zone: "America/New_York", - from: "2026-03-08T06:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-03-08T08:30:00.000Z", - }, - { - zone: "America/New_York", - from: "2026-11-01T05:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-11-01T07:30:00.000Z", - }, - { - zone: "America/New_York", - from: "2026-11-01T06:30:00.000Z", - everyMs: 60_000, - expected: "2026-11-01T06:31:00.000Z", - }, - { - zone: "UTC", - from: "2026-11-01T06:30:00.000Z", - everyMs: 60_000, - expected: "2026-11-01T06:31:00.000Z", - }, - { - zone: "Asia/Tokyo", - from: "2026-03-08T06:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-03-08T08:30:00.000Z", - }, -] as const)("persists elapsed interval $zone from $from", ({ zone, from, everyMs, expected }) => - Effect.gen(function* () { - vi.stubEnv("TZ", zone); - yield* TestClock.setTime(Date.parse(from)); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const decoded = yield* decodeInput({ ...input, schedule: { type: "interval", everyMs } }); - const created = yield* service.upsert(decoded); - expect(created.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - - const paused = yield* service.setEnabled({ id: created.task.id, enabled: false }); - expect(paused.task.nextRunAt).toBeNull(); - const resumed = yield* service.setEnabled({ id: created.task.id, enabled: true }); - expect(resumed.task.nextRunAt).toBe(expected); - - // Exercise completion rescheduling without starting any provider process. - const completed = yield* service.runNow({ id: created.task.id }); - expect(completed.task.lastRunStatus).toBe("failed"); - expect(completed.threadId).toBeNull(); - expect(completed.task.runCount).toBe(1); - expect(completed.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - }).pipe(Effect.provide(serviceLayer)); - }).pipe( - // Keep the real scheduler parked while exercising finite service actions. - Effect.provideService(ServerActivation, Effect.never), - Effect.provide(SqlitePersistence.layerMemory), - ), -); - -it.effect.each([ - { from: "2026-03-07T23:30:00.000Z", expected: "2026-03-08T13:00:00.000Z" }, - { from: "2026-10-31T23:30:00.000Z", expected: "2026-11-01T14:00:00.000Z" }, -] as const)("keeps fixed-time schedules on the local calendar from $from", ({ from, expected }) => - Effect.gen(function* () { - vi.stubEnv("TZ", "America/New_York"); - yield* TestClock.setTime(Date.parse(from)); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const decoded = yield* decodeInput({ - ...input, - schedule: { type: "fixed_time", timeOfDay: "09:00" }, - }); - const created = yield* service.upsert(decoded); - expect(created.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - }).pipe(Effect.provide(serviceLayer)); - }).pipe( - Effect.provideService(ServerActivation, Effect.never), - Effect.provide(SqlitePersistence.layerMemory), - ), -); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index bcb110a5da71b..09e1d7674044f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -116,3 +116,54 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () => }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("keeps the task list order when a toggle or edit rewrites updated_at", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = (id: string) => ({ + id, + title: id, + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: "project-stable-order", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const listedIds = service + .list() + .pipe(Effect.map(({ tasks }) => tasks.map((task) => String(task.id)))); + + const older = yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:older"))); + yield* TestClock.adjust("1 second"); + yield* service.upsert(yield* decodeUpsertInput(input("scheduled-task:newer"))); + const order = ["scheduled-task:newer", "scheduled-task:older"]; + expect(yield* listedIds).toEqual(order); + + yield* TestClock.adjust("1 second"); + const toggled = yield* service.setEnabled({ id: older.task.id, enabled: false }); + expect(toggled.task.updatedAt > older.task.updatedAt).toBe(true); + expect(yield* listedIds).toEqual(order); + + yield* TestClock.adjust("1 second"); + const edit = { ...input(older.task.id), requireExisting: true, title: "Edited" }; + yield* service.upsert(yield* decodeUpsertInput({ ...edit, enabled: false })); + expect(yield* listedIds).toEqual(order); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 9c99efcc1e0fa..7696b7fc1f4bd 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"; @@ -55,15 +57,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; @@ -95,7 +120,7 @@ class WebhookDeliverySkipped extends Data.TaggedError("WebhookDeliverySkipped")< }> {} interface RateWindow { - readonly accepted: ReadonlyArray; + readonly accepted: ReadonlyArray<{ readonly at: number }>; readonly rejectedLogged: boolean; } @@ -110,12 +135,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"; @@ -123,7 +150,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" } @@ -136,6 +163,9 @@ export type WebhookTriggerResult = const encodeRepositoryTarget = Schema.encodeSync(Schema.fromJsonString(RepositoryWorkTarget)); const decodeRepositoryTarget = Schema.decodeEffect(Schema.fromJsonString(RepositoryWorkTarget)); +const webhookFilters = (filters: ReadonlyArray | undefined) => + filters?.length ? { filters } : {}; + const decodeTask = Schema.decodeUnknownEffect(ScheduledTask); const decodeTaskId = Schema.decodeUnknownOption(ScheduledTaskId); const decodeScheduleJson = Schema.decodeUnknownEffect( @@ -194,6 +224,7 @@ interface WebhookDeliveryRow { readonly signature_verified: number; readonly missing_fields_json: string; readonly rendered_prompt: string | null; + readonly pending_window: number; readonly error: string | null; } @@ -393,6 +424,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 @@ -426,7 +467,9 @@ export const layer = Layer.effect( webhook_token, webhook_secret FROM scheduled_tasks - ORDER BY updated_at DESC, task_id ASC + -- Not updated_at: toggles, runs and edits rewrite it, and clients render + -- this order as-is, so rows would move under the pointer. + ORDER BY created_at DESC, task_id ASC `; const listRows = Effect.fn("ScheduledTaskService.listRows")(function* () { @@ -617,11 +660,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} @@ -663,9 +707,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; @@ -676,12 +721,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", { @@ -695,7 +743,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; @@ -729,6 +781,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 ( @@ -754,7 +812,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 = @@ -854,8 +927,9 @@ export const layer = Layer.effect( }); const result = yield* Effect.exit(dispatch); - 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; @@ -874,7 +948,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, @@ -882,11 +956,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); @@ -963,7 +1058,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; @@ -977,7 +1076,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' `; }), @@ -1050,6 +1153,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 = @@ -1168,6 +1285,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); @@ -1267,6 +1399,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, }); @@ -1275,11 +1408,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 }), ), @@ -1289,15 +1429,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 { @@ -1312,6 +1456,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; @@ -1323,10 +1485,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* () { @@ -1357,15 +1516,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} ) `; }), @@ -1384,13 +1556,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 [ @@ -1398,7 +1572,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 [ @@ -1408,11 +1582,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, ) { @@ -1429,6 +1627,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, @@ -1576,16 +1851,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"); @@ -1619,6 +1897,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 })), @@ -1631,85 +2025,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 ae8594a03f0a1..65cecbcf43286 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 0000000000000..21d40b25ac5ed --- /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.test.ts b/apps/server/src/scheduledTasks/webhookFilter.test.ts new file mode 100644 index 0000000000000..1194a1aed5177 --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { findFailedWebhookFilter } from "./webhookFilter.ts"; +import type { WebhookRequest } from "./webhookTemplate.ts"; + +const issueComment: WebhookRequest = { + method: "POST", + path: "/api/hooks/task-1", + query: "", + headers: { "content-type": "application/json", "x-github-event": "issue_comment" }, + bodyText: JSON.stringify({ + action: "created", + issue: { number: 7, labels: [{ name: "bug" }, { name: "needs more info" }], assignees: [] }, + comment: { user: { login: "reporter", type: "User" } }, + }), +}; + +describe("findFailedWebhookFilter", () => { + it("matches a wildcard path when any element matches", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.issue.labels[].name", op: "equals", value: "needs more info" }, + { path: "body.issue.labels[].name", op: "in", value: ["needs more info", "triage"] }, + { path: "body.issue.labels[].name", op: "startsWith", value: "needs" }, + { path: "body.issue.labels[].name", op: "exists" }, + ], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "equals", value: "wontfix" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + }); + + it("fails notEquals on a wildcard path when any element equals the value", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "wontfix" }], + issueComment, + ), + ); + }); + + it("treats an empty or missing wildcard list as nothing to match", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "exists" }], + issueComment, + ), + { index: 0, path: "body.issue.assignees[].login" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "notEquals", value: "anyone" }], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter([{ path: "body.issue.number[].x", op: "exists" }], issueComment), + { index: 0, path: "body.issue.number[].x" }, + ); + }); + + it("still compares scalars without a wildcard", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.action", op: "equals", value: "created" }, + { path: "body.comment.user.type", op: "notEquals", value: "Bot" }, + ], + issueComment, + ), + ); + // A list without a wildcard in the path only satisfies exists. + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels", op: "equals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels" }, + ); + }); +}); diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts new file mode 100644 index 0000000000000..882f300364288 --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -0,0 +1,65 @@ +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; +} + +function matchesScalar(filter: ScheduledTaskWebhookFilter, text: string): boolean { + 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); + case "exists": + return true; + } +} + +/** + * Placeholders render null as missing, so filters treat it the same way. A + * path with a `[]` wildcard resolves to a list: `equals`, `in` and + * `startsWith` match when any element does, `notEquals` only when none equals + * the value, and `exists` when the list has an element. + */ +function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { + if (Array.isArray(value) && /\[\]/.test(filter.path)) { + const texts = value.map(scalarText).filter((text): text is string => text !== undefined); + if (filter.op === "notEquals") return texts.every((text) => text !== filter.value); + return texts.some((text) => matchesScalar(filter, text)); + } + if (filter.op === "exists") return value !== undefined && value !== null; + const text = scalarText(value); + if (text === undefined) return false; + return matchesScalar(filter, text); +} + +/** 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 31261660c6235..5ef605be0ab42 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 ce67fa157d334..2566349d273b6 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 6f8f3474fb51c..6aa23e12b0363 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( @@ -209,6 +165,23 @@ describe("renderWebhookPrompt", () => { ); }); + it("renders a [] wildcard as the list of matching values", () => { + const rendered = renderWebhookPrompt( + "Labels: {{body.pull_request.labels[]}} Names: {{body.reviewers[].login}}", + { + ...githubPullRequest, + bodyText: JSON.stringify({ + pull_request: { labels: ["bug", "ui"] }, + reviewers: [{ login: "ada" }, { name: "no login" }, { login: "bob" }], + }), + }, + "id", + true, + ); + assert.equal(rendered.prompt, 'Labels: [\n "bug",\n "ui"\n] Names: [\n "ada",\n "bob"\n]'); + assert.deepEqual(rendered.missing, []); + }); + it("does not resolve inherited object properties", () => { const rendered = renderWebhookPrompt( "{{body.constructor}}{{body.__proto__}}{{headers.constructor}}{{headers.__proto__}}", diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index b129704f0abe3..84e2599dc0d75 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -83,12 +83,30 @@ function parseBody(request: WebhookRequest): unknown { } } +const WILDCARD = /^(.*)\[\]$/; + +/** + * Walks `segments` from `root`. A segment ending in `[]` names an array and + * the rest of the path applies to each element, so `labels[].name` resolves + * to the list of every label's name. Elements without the rest of the path + * are dropped; a wildcard on a non-array resolves to undefined. + */ function lookup(root: unknown, segments: ReadonlyArray): unknown { let current = root; - for (const segment of segments) { + for (const [index, segment] of segments.entries()) { if (current === null || typeof current !== "object") return undefined; - if (!Object.hasOwn(current, segment)) return undefined; - current = (current as Record)[segment]; + const wildcard = WILDCARD.exec(segment); + const key = wildcard ? wildcard[1]! : segment; + if (!Object.hasOwn(current, key)) return undefined; + current = (current as Record)[key]; + if (!wildcard) continue; + if (!Array.isArray(current)) return undefined; + const rest = segments.slice(index + 1); + return current.flatMap((element) => { + const value = rest.length === 0 ? element : lookup(element, rest); + if (value === undefined) return []; + return rest.some((part) => WILDCARD.test(part)) && Array.isArray(value) ? value : [value]; + }); } return current; } @@ -112,16 +130,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 +162,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 +179,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 b5c2b676084c0..330128bfb1a00 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 089b12f991ec9..0000000000000 --- 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 b751b0b205c52..ffb8cd314d873 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 dc41b884ab36c..0000000000000 --- 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 28ba41125b57f..0000000000000 --- 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 70b4cd86b2cdc..75c35a61b03a4 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 92230268f20d0..94a5222e1331f 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -2,7 +2,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { DEFAULT_SERVER_SETTINGS, - DEFAULT_MODEL, ProjectId, ProviderInstanceId, ProviderDriverKind, @@ -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", @@ -252,33 +244,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* () { @@ -342,21 +307,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 de567b9e24ad1..f579f0685714c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -175,7 +175,7 @@ export 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, }); @@ -245,7 +245,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"; @@ -415,36 +415,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 86b1cd093212c..abafbc7e24f62 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -4,12 +4,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as References from "effect/References"; import * as Runtime from "effect/Runtime"; -import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; @@ -19,8 +16,6 @@ import * as ProcessRunner from "./processRunner.ts"; import * as ServerOwnership from "./serverOwnership.ts"; import * as ServerRuntimeState from "./serverRuntimeState.ts"; -const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError); - const startBesideBootService = Effect.fn("test.startBesideBootService")(function* (input: { readonly platform: "darwin" | "linux"; readonly outputs: Readonly>; @@ -132,11 +127,6 @@ const startBesideBootService = Effect.fn("test.startBesideBootService")(function return { result, t3Home }; }); -interface CapturedLog { - readonly message: unknown; - readonly annotations: Readonly>; -} - describe("server ownership", () => { // An empty home, so no boot service unit installed on this machine is read. const layerOwnership = Layer.mergeAll( @@ -206,8 +196,8 @@ describe("server ownership", () => { const state = yield* makeState; yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, - // @effect-diagnostics-next-line globalDateInEffect:off - Legacy identity uses the real process start time, not TestClock. - state: { ...state, startedAt: new Date().toISOString() }, + // Any record time after this process started proves the PID was not reused. + state: { ...state, startedAt: "2100-01-01T00:00:00.000Z" }, }); const refused = yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)).pipe( Effect.flip, @@ -407,187 +397,3 @@ describe("server ownership", () => { ); }); }); - -describe("serverRuntimeState", () => { - it.effect("persists and reads the runtime state", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "runtime", "server.json"); - const state: ServerRuntimeState.PersistedServerRuntimeState = { - version: 1, - pid: 123, - host: "127.0.0.1", - port: 4_971, - origin: "http://127.0.0.1:4971", - devUrl: "http://localhost:5733/", - startedAt: "2026-06-20T00:00:00.000Z", - }; - - yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state }); - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); - - assert.deepEqual(Option.getOrThrow(restored), state); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("records the dev web URL when the server fronts a dev server", () => - Effect.gen(function* () { - const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: new URL("http://localhost:5733") }, - port: 13_773, - }); - - assert.equal(state.devUrl, "http://localhost:5733/"); - assert.equal(state.origin, "http://127.0.0.1:13773"); - - const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - }); - assert.isFalse("devUrl" in withoutDev); - }), - ); - - it.effect("marks a service-supervised server so CLIs can tell it from a manual one", () => - Effect.gen(function* () { - const managed = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - serviceManaged: true, - }); - const manual = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - }); - - assert.isTrue(managed.serviceManaged); - assert.isFalse("serviceManaged" in manual); - }), - ); - - it.effect("treats a missing runtime state file as absent", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState( - path.join(root, "missing.json"), - ); - - assert.isTrue(Option.isNone(restored)); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("preserves malformed state decode failures", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "server.json"); - yield* fileSystem.writeFileString(statePath, "{not json"); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); - - assert.isTrue(Option.isNone(restored)); - assert.equal(logs[0]?.message, `Failed to decode server runtime state at ${statePath}.`); - const error = logs[0]?.annotations.cause; - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "decode"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to decode server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "SchemaError" }); - } - }).pipe( - Effect.provide( - Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }); - - it.effect("preserves runtime state read failures", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "server.json"); - yield* fileSystem.makeDirectory(statePath); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); - - assert.isTrue(Option.isNone(restored)); - assert.equal(logs[0]?.message, `Failed to read server runtime state at ${statePath}.`); - const error = logs[0]?.annotations.cause; - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "read"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to read server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - } - }).pipe( - Effect.provide( - Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }); - - it.effect("preserves runtime state persistence failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const blockedDirectory = path.join(root, "not-a-directory"); - const statePath = path.join(blockedDirectory, "server.json"); - yield* fileSystem.writeFileString(blockedDirectory, "blocked"); - - const error = yield* ServerRuntimeState.persistServerRuntimeState({ - path: statePath, - state: { - version: 1, - pid: 123, - port: 4_971, - origin: "http://127.0.0.1:4971", - startedAt: "2026-06-20T00:00:00.000Z", - }, - }).pipe(Effect.flip); - - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "persist"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to persist server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/apps/server/src/serverSettings.fork.test.ts b/apps/server/src/serverSettings.fork.test.ts new file mode 100644 index 0000000000000..635f53523bf63 --- /dev/null +++ b/apps/server/src/serverSettings.fork.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId, ServerSettings } from "@t3tools/contracts"; + +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 Schema from "effect/Schema"; + +import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; + +import * as ServerConfig from "./config.ts"; +import * as SqlitePersistence from "./persistence/Sqlite.ts"; + +import * as ServerSettingsModule from "./serverSettings.ts"; +import { EnvironmentToolkit } from "./mcp/toolkits/environment/tools.ts"; + +const decodeAgentPreferences = Schema.decodeUnknownEffect( + EnvironmentToolkit.tools.t3_environment_preferences_update.parametersSchema, +); +const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); + +const layerServerSettings = () => + ServerSettingsModule.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + +it.effect("agents cannot change user-only policies through environment preferences", () => + Effect.gen(function* () { + const decoded = yield* decodeAgentPreferences({ + issueUpkeepRequiresApproval: false, + dontAskMode: true, + enableProviderUpdateChecks: false, + }); + assert.deepEqual(decoded, { enableProviderUpdateChecks: false }); + }), +); + +it.effect("test settings layer reads and updates dont-ask mode from its current state", () => + Effect.gen(function* () { + const settings = yield* ServerSettingsModule.ServerSettingsService; + const initial = DEFAULT_SERVER_SETTINGS.dontAskMode; + + assert.equal(yield* settings.getDontAskMode, initial); + yield* settings.updateSettingsWith((current) => ({ dontAskMode: !current.dontAskMode })); + assert.equal(yield* settings.getDontAskMode, !initial); + yield* settings.updateSettingsWith((current) => ({ dontAskMode: !current.dontAskMode })); + assert.equal(yield* settings.getDontAskMode, initial); + }).pipe(Effect.provide(ServerSettingsModule.layerTest())), +); + +it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("issue upkeep approval defaults off and persists the user's choice", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + assert.isFalse((yield* service.getSettings).issueUpkeepRequiresApproval); + assert.isTrue( + (yield* service.updateSettings({ issueUpkeepRequiresApproval: true })) + .issueUpkeepRequiresApproval, + ); + const saved = yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath)); + assert.isTrue(saved.issueUpkeepRequiresApproval); + assert.isFalse( + (yield* service.updateSettings({ issueUpkeepRequiresApproval: false })) + .issueUpkeepRequiresApproval, + ); + }).pipe(Effect.provide(layerServerSettings())), + ); + + it.effect("loads a saved agentBrowserTabLimits off", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + yield* fs.writeFileString(config.settingsPath, `{ "agentBrowserTabLimits": false }`); + + assert.isFalse((yield* service.getSettings).agentBrowserTabLimits); + }).pipe(Effect.provide(layerServerSettings())), + ); +}); + +it.effect.each([ + { saved: { autoResumeLimitedThreads: true }, mode: "wait", resume: true, snooze: false }, + { saved: { snoozeLimitedThreads: true }, mode: "wait", resume: false, snooze: true }, + { saved: {}, mode: "off", resume: false, snooze: false }, + { + saved: { usageLimitRecovery: "unknown", snoozeLimitedThreads: true }, + mode: "wait", + resume: false, + snooze: true, + }, +])("migrates saved limit recovery tolerantly: %j", (testCase) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + yield* fs.writeFileString(config.settingsPath, JSON.stringify(testCase.saved)); + const settings = yield* service.getSettings; + assert.equal(settings.usageLimitRecovery, testCase.mode); + assert.equal(settings.autoResumeLimitedThreads, testCase.resume); + assert.equal(settings.snoozeLimitedThreads, testCase.snooze); + const changed = yield* service.updateSettings({ + usageLimitRecovery: "switch", + usageLimitFallbackModels: [ + { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", + options: [{ id: "effort", value: "high" }], + }, + ], + }); + assert.isFalse(changed.autoResumeLimitedThreads); + assert.isFalse(changed.snoozeLimitedThreads); + assert.equal(changed.usageLimitFallbackModels[0]?.options?.[0]?.value, "high"); + assert.equal((yield* service.getSettings).usageLimitRecovery, "switch"); + const off = yield* service.updateSettings({ + usageLimitRecovery: "off", + autoResumeLimitedThreads: true, + }); + assert.equal(off.usageLimitRecovery, "off"); + assert.isFalse(off.autoResumeLimitedThreads); + }).pipe(Effect.provide(layerServerSettings().pipe(Layer.provideMerge(NodeServices.layer)))), +); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index cde426feb09b1..ac4e0d394284a 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"; @@ -32,13 +29,8 @@ import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; -import { EnvironmentToolkit } from "./mcp/toolkits/environment/tools.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; -const decodeAgentPreferences = Schema.decodeUnknownEffect( - EnvironmentToolkit.tools.t3_environment_preferences_update.parametersSchema, -); -const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); @@ -101,61 +93,7 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi `; }); -it.effect("agents cannot change user-only policies through environment preferences", () => - Effect.gen(function* () { - const decoded = yield* decodeAgentPreferences({ - issueUpkeepRequiresApproval: false, - dontAskMode: true, - enableProviderUpdateChecks: false, - }); - assert.deepEqual(decoded, { enableProviderUpdateChecks: false }); - }), -); - -it.effect("test settings layer reads and updates dont-ask mode from its current state", () => - Effect.gen(function* () { - const settings = yield* ServerSettingsModule.ServerSettingsService; - const initial = DEFAULT_SERVER_SETTINGS.dontAskMode; - - assert.equal(yield* settings.getDontAskMode, initial); - yield* settings.updateSettingsWith((current) => ({ dontAskMode: !current.dontAskMode })); - assert.equal(yield* settings.getDontAskMode, !initial); - yield* settings.updateSettingsWith((current) => ({ dontAskMode: !current.dontAskMode })); - assert.equal(yield* settings.getDontAskMode, initial); - }).pipe(Effect.provide(ServerSettingsModule.layerTest())), -); - it.layer(NodeServices.layer)("server settings", (it) => { - it.effect("issue upkeep approval defaults off and persists the user's choice", () => - Effect.gen(function* () { - const service = yield* ServerSettingsModule.ServerSettingsService; - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - assert.isFalse((yield* service.getSettings).issueUpkeepRequiresApproval); - assert.isTrue( - (yield* service.updateSettings({ issueUpkeepRequiresApproval: true })) - .issueUpkeepRequiresApproval, - ); - const saved = yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath)); - assert.isTrue(saved.issueUpkeepRequiresApproval); - assert.isFalse( - (yield* service.updateSettings({ issueUpkeepRequiresApproval: false })) - .issueUpkeepRequiresApproval, - ); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("loads a saved agentBrowserTabLimits off", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const service = yield* ServerSettingsModule.ServerSettingsService; - yield* fs.writeFileString(config.settingsPath, `{ "agentBrowserTabLimits": false }`); - - assert.isFalse((yield* service.getSettings).agentBrowserTabLimits); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; @@ -274,6 +212,29 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("moves an idle compaction mode saved in the legacy switch to its own field", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + yield* fs.writeFileString(config.settingsPath, `{ "compactIdleThreads": "off" }`); + + const settings = yield* service.getSettings; + // Shipped clients decode the switch as a boolean and drop the config otherwise. + assert.strictEqual(settings.compactIdleThreads, false); + assert.equal(settings.idleCompaction, "off"); + }).pipe(Effect.provide(layerServerSettings())), + ); + + it.effect("tells clients it reads idle compaction modes before any settings are saved", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const settings = yield* service.getSettings; + assert.equal(settings.idleCompaction, "onReturn"); + assert.strictEqual(settings.compactIdleThreads, false); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -296,154 +257,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; @@ -466,23 +279,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", () => @@ -749,33 +545,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; @@ -811,31 +580,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", () => @@ -989,32 +733,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", () => @@ -1081,76 +799,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; @@ -1277,20 +925,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; @@ -1312,22 +946,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; @@ -1386,108 +1004,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("saves a writing style whose other fields are defaults", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - yield* serverSettings.updateSettings({ - sourceControlWritingStyle: { mode: "conventional_commits" }, - }); - yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.deepEqual(JSON.parse(raw), { - addProjectBaseDirectory: "~/Development", - sourceControlWritingStyle: { mode: "conventional_commits" }, - }); - assert.deepEqual((yield* serverSettings.getSettings).sourceControlWritingStyle, { - mode: "conventional_commits", - customInstructions: "", - followChangeRequestTemplates: true, - }); - }).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", @@ -1825,69 +1341,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", () => @@ -2373,46 +1826,3 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); }); - -it.effect.each([ - { saved: { autoResumeLimitedThreads: true }, mode: "wait", resume: true, snooze: false }, - { saved: { snoozeLimitedThreads: true }, mode: "wait", resume: false, snooze: true }, - { saved: {}, mode: "off", resume: false, snooze: false }, - { - saved: { usageLimitRecovery: "unknown", snoozeLimitedThreads: true }, - mode: "wait", - resume: false, - snooze: true, - }, -])("migrates saved limit recovery tolerantly: %j", (testCase) => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const service = yield* ServerSettingsModule.ServerSettingsService; - yield* fs.writeFileString(config.settingsPath, JSON.stringify(testCase.saved)); - const settings = yield* service.getSettings; - assert.equal(settings.usageLimitRecovery, testCase.mode); - assert.equal(settings.autoResumeLimitedThreads, testCase.resume); - assert.equal(settings.snoozeLimitedThreads, testCase.snooze); - const changed = yield* service.updateSettings({ - usageLimitRecovery: "switch", - usageLimitFallbackModels: [ - { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-5-5", - options: [{ id: "effort", value: "high" }], - }, - ], - }); - assert.isFalse(changed.autoResumeLimitedThreads); - assert.isFalse(changed.snoozeLimitedThreads); - assert.equal(changed.usageLimitFallbackModels[0]?.options?.[0]?.value, "high"); - assert.equal((yield* service.getSettings).usageLimitRecovery, "switch"); - const off = yield* service.updateSettings({ - usageLimitRecovery: "off", - autoResumeLimitedThreads: true, - }); - assert.equal(off.usageLimitRecovery, "off"); - assert.isFalse(off.autoResumeLimitedThreads); - }).pipe(Effect.provide(layerServerSettings().pipe(Layer.provideMerge(NodeServices.layer)))), -); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a0fa8906814b1..ce55cb1a153f3 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -20,6 +20,8 @@ import { ServerSettings, ServerSettingsError, type ServerSettingsPatch, + globalIdleCompaction, + withIdleCompaction, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -122,6 +124,7 @@ const normalizeServerSettings = ( encodeServerSettings(settings).pipe( Effect.flatMap(decodeServerSettings), Effect.map(foldProviderInstanceEnabledFlags), + Effect.map((next) => withIdleCompaction(next, globalIdleCompaction(next))), Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), Effect.mapError( (cause) => @@ -714,6 +717,10 @@ const make = Effect.gen(function* () { settings = decoded.value; } } + // Settings saved by a server that kept the mode in `compactIdleThreads` move + // it to `idleCompaction`, so that switch goes to clients as a boolean. The + // defaults gain the field too, which tells clients this server reads modes. + settings = withIdleCompaction(settings, globalIdleCompaction(settings)); // A file that failed to decode must stay on disk for the user to repair. const settingsFileTrusted = Option.isNone(parseFailure); diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index faba5b2707147..70550f2379272 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -14,7 +14,6 @@ import { Launcher, main, readServiceEnvironment, - PREPARED_TIMEOUT_MS, type BackupSpaceProbe, readServiceState, writeServiceState, @@ -26,7 +25,6 @@ import { SERVICE_ENV_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_RESTART_PENDING_FILE, - SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; const spawnedChildren = vi.hoisted((): Array => []); @@ -175,22 +173,6 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("durably replaces and strictly reads one state document", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - const state = { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "0.0.31", - } as const; - - yield* Effect.promise(() => writeServiceState(statePath, state)); - assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), state); - }), - ); - it.effect("a fresh launcher clears a restart deferred by t3 update", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -271,34 +253,6 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("serializes shutdown with launcher recovery", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - yield* writeFakeRuntime( - fs, - path, - path.join(root, "runtime", "versions", "1.0.0"), - "setInterval(() => {}, 1_000);\n", - ); - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - }), - ); - - const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); - const running = launcher.run(); - const stopping = launcher.stop("SIGTERM"); - assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); - yield* Effect.promise(() => stopping); - yield* Effect.promise(() => running); - }), - ); - it.effect("commits only after the trial reports prepared", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -428,82 +382,6 @@ if (context.update?.status === "pending") { }), ); - it.effect.each([ - { prepares: true, status: "committed", activeVersion: "1.1.0" }, - { prepares: false, status: "rolled-back", activeVersion: "1.0.0" }, - ] as const)( - "gives a trial the full prepared window (prepares: $prepares)", - ({ prepares, status, activeVersion }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-slow-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - const databasePath = path.join(root, "userdata", "state.sqlite"); - yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "before trial"); - const childSource = ` -const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); -if (context.update?.status !== "pending") process.exit(0); -process.on("message", (message) => { - if (message.type === "test-prepare") process.send({ type: "prepared", updateId: context.update.id }); - if (message.type === "committed") process.exit(0); -}); -setInterval(() => {}, 1_000); -`; - for (const version of ["1.0.0", "1.1.0"]) { - yield* writeFakeRuntime( - fs, - path, - path.join(root, "runtime", "versions", version), - childSource, - ); - } - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - update: { - id: "update-1", - fromVersion: "1.0.0", - targetVersion: "1.1.0", - dbPath: databasePath, - status: "pending", - }, - }), - ); - - // Effect schedules on setImmediate, so faking only timeouts leaves it running. - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); - const launcher = new Launcher( - root, - yield* Effect.promise(() => readServiceState(statePath)), - ); - const running = launcher.run().then( - () => Promise.reject(new Error("launcher unexpectedly completed")), - () => Promise.resolve(), - ); - yield* Effect.promise(() => - vi.waitFor(() => assert.equal(vi.getTimerCount(), 1), { timeout: 10_000 }), - ); - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(121_000)); - if (prepares) spawnedChildren.at(-1)!.send({ type: "test-prepare" }); - else yield* Effect.promise(() => vi.advanceTimersByTimeAsync(PREPARED_TIMEOUT_MS)); - yield* Effect.promise(() => running); - - const state = yield* Effect.promise(() => readServiceState(statePath)); - assert.equal(state.activeVersion, activeVersion); - assert.equal(state.update?.status, status); - if (!prepares) { - assert.equal( - state.update?.status === "rolled-back" ? state.update.reason : undefined, - "prepared-timeout", - ); - } - }), - ); - it.effect.each(["file", "symlink", "dangling symlink"] as const)( "restores the database when a migrating trial exits, with the database a %s", (kind) => @@ -764,41 +642,4 @@ setInterval(() => {}, 1_000); assert.include(blocker ?? "", "The server was not stopped"); }), ); - - it.effect("fails the backup check when the database cannot be inspected", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-blocker-" }); - const error = yield* Effect.promise(() => - databaseBackupBlocker(root, path.join(root, "missing.sqlite"), { - readFreeBytes: () => Promise.resolve(0), - }).then( - () => undefined, - (cause: unknown) => cause, - ), - ); - assert.equal((error as NodeJS.ErrnoException | undefined)?.code, "ENOENT"); - }), - ); - - it.effect("recognizes a clone into the backup directory on this filesystem", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-clone-" }); - const databasePath = path.join(root, "userdata", "state.sqlite"); - yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "database"); - const blocker = yield* Effect.promise(() => - databaseBackupBlocker(root, databasePath, { readFreeBytes: () => Promise.resolve(0) }), - ); - assert.include( - blocker ?? "", - // oxlint-disable-next-line t3code/no-global-process-runtime -- mirrors the standalone launcher's own platform check. - process.platform === "darwin" ? "1.0 GB needed, 0.0 GB free" : "GB needed, 0.0 GB free", - ); - assert.deepEqual(yield* fs.readDirectory(path.join(root, "runtime", "db-backup")), []); - }), - ); }); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index e57c4b1490596..9926159be2855 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -37,7 +37,7 @@ const HANDOFF_DELAY_MS = 2_000; * Migrations count toward it and can scale with the database: tens of GB on a cold page cache * take minutes. A missed window rolls back the database, so the next attempt pays the same cost. */ -export const PREPARED_TIMEOUT_MS = 30 * 60_000; +const PREPARED_TIMEOUT_MS = 30 * 60_000; const TERMINATE_GRACE_MS = 5_000; // Matches SERVER_EXIT_CODE_STATE_DIR_OWNED in contracts. The standalone // launcher stays independent of the Effect-backed contracts package. diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a2c1eaceee00..38a5f112c382b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -4,15 +4,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -254,68 +251,6 @@ it.effect("lists pull requests with Bitbucket state and source branch query para }).pipe(Effect.provide(layer)); }); -it.effect("lists closed pull requests with both closed Bitbucket states", () => { - const { execute, layer } = makeLayer({ - response: () => - Response.json({ - values: [], - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.listPullRequests({ - cwd: "/repo", - headSelector: "feature/closed", - state: "closed", - limit: 10, - }); - - assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ - ["pagelen", "10"], - ["sort", "-updated_on"], - [ - "q", - 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', - ], - ["state", "DECLINED"], - ["state", "SUPERSEDED"], - ]); - }).pipe(Effect.provide(layer)); -}); - -it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { - const { execute, layer } = makeLayer({ - response: () => - Response.json({ - values: [], - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.listPullRequests({ - cwd: "/repo", - headSelector: "feature/all", - state: "all", - limit: 10, - }); - - assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ - ["pagelen", "10"], - ["sort", "-updated_on"], - [ - "q", - 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', - ], - ["state", "OPEN"], - ["state", "MERGED"], - ["state", "DECLINED"], - ["state", "SUPERSEDED"], - ]); - }).pipe(Effect.provide(layer)); -}); - it.effect("reads repository clone URLs and default branch", () => { const { layer } = makeLayer({ response: (request) => @@ -428,27 +363,6 @@ it.effect("prefers an explicit repository and uses context when the repository i }).pipe(Effect.provide(layer)); }); -it.effect("discovers the repository from cwd remotes when the repository is omitted", () => { - const { execute, resolve, listRemotes, layer } = makeLayer({ - response: (request) => - Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const branch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(branch, "main"); - assert.deepStrictEqual(resolve.mock.calls, [[{ cwd: "/repo" }]]); - assert.deepStrictEqual(listRemotes.mock.calls, [["/repo"]]); - assert.isTrue( - execute.mock.calls.every(([request]) => - request.url.startsWith("https://api.test.local/2.0/repositories/pingdotgg/t3code"), - ), - ); - }).pipe(Effect.provide(layer)); -}); - it.effect( "prefers the Bitbucket branching model development branch as the default PR target", () => { @@ -510,57 +424,6 @@ it.effect( }, ); -it.effect( - "falls back to the repository main branch when the Bitbucket branching model is unavailable", - () => { - const { layer } = makeLayer({ - response: (request) => - request.url.endsWith("/branching-model") - ? Response.json({ error: { message: "Not found" } }, { status: 404 }) - : Response.json(repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(defaultBranch, "main"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect("creates repositories through the Bitbucket REST API", () => { - const { execute, layer } = makeLayer({ - response: () => Response.json(repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const cloneUrls = yield* bitbucket.createRepository({ - cwd: "/repo", - repository: "pingdotgg/t3code", - visibility: "private", - }); - - assert.deepStrictEqual(cloneUrls, { - nameWithOwner: "pingdotgg/t3code", - url: "https://bitbucket.org/pingdotgg/t3code.git", - sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", - }); - - const request = execute.mock.calls[0]?.[0]; - assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); - assert.strictEqual(request?.method, "POST"); - assert.ok(request); - const rawBody = (request.body as { readonly body?: Uint8Array }).body; - assert.ok(rawBody); - assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { - scm: "git", - is_private: true, - }); - }).pipe(Effect.provide(layer)); -}); - it.effect("creates pull requests using the official REST payload shape", () => { const { execute, layer } = makeLayer({ response: () => Response.json(bitbucketPullRequest), @@ -603,24 +466,6 @@ it.effect("creates pull requests using the official REST payload shape", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); -it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { - const { layer } = makeLayer({ - response: () => Response.json({ username: "bitbucket-user" }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const auth = yield* bitbucket.probeAuth; - - assert.deepStrictEqual(auth, { - status: "authenticated", - account: Option.some("bitbucket-user"), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }); - }).pipe(Effect.provide(layer)); -}); - it.effect("prefers credentials saved in settings over the environment, without a restart", () => { const { execute, layer } = makeLayer({ response: () => Response.json({ username: "bitbucket-user" }), @@ -703,42 +548,6 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm }).pipe(Effect.provide(layer)); }); -it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { - const transportCause = new Error("socket reset by peer"); - let requestFailure: HttpClientError.HttpClientError | undefined; - const { layer } = makeLayer({ - response: () => Response.json({}), - requestFailure: (request) => { - requestFailure = new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - cause: transportCause, - }), - }); - return requestFailure; - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.getPullRequest({ - cwd: "/repo", - reference: "42", - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", - ); - assert.strictEqual(error.cause, requestFailure); - assert.strictEqual(requestFailure?.cause, transportCause); - }).pipe(Effect.provide(layer)); -}); - it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { const responseBody = '{"error":{"message":"credential=secret-value"}}'; const { layer } = makeLayer({ @@ -764,175 +573,6 @@ it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { }).pipe(Effect.provide(layer)); }); -it.effect("keeps a 429 Retry-After time on the response error", () => { - const { layer } = makeLayer({ - response: () => new Response("busy", { status: 429, headers: { "Retry-After": "120" } }), - }); - - return Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .request({ method: "GET", url: "/repositories/acme/web" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseError); - assert.strictEqual(error.status, 429); - assert.strictEqual(error.retryAt, 121_000); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { - const cause = new Error("response stream failed"); - const { layer } = makeLayer({ - response: () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(cause), - }), - { status: 502 }, - ), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .getPullRequest({ cwd: "/repo", reference: "42" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.status, 502); - assert.instanceOf(error.cause, HttpClientError.HttpClientError); - assert.strictEqual(error.cause.cause, cause); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", - ); - }).pipe(Effect.provide(layer)); -}); - -it.effect("keeps the 429 retry time when the response body cannot be read", () => { - const { layer } = makeLayer({ - response: () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(new Error("response stream failed")), - }), - { status: 429, headers: { "Retry-After": "120" } }, - ), - }); - - return Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .request({ method: "GET", url: "/repositories/acme/web" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); - assert.strictEqual(error.status, 429); - assert.strictEqual(error.retryAt, 121_000); - }).pipe(Effect.provide(layer)); -}); - -it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { - const { git, layer } = makeLayer({ - response: () => - Response.json({ - ...bitbucketPullRequest, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.checkoutPullRequest({ - cwd: "/repo", - context: { - provider: { - kind: SourceControlProviderKind.make("bitbucket"), - name: "Bitbucket", - baseUrl: "https://bitbucket.org", - }, - remoteName: "origin", - remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", - }, - reference: "42", - force: true, - }); - - assert.strictEqual(git.ensureRemote.mock.calls.length, 0); - assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { - cwd: "/repo", - remoteName: "origin", - remoteBranch: "feature/source-control", - localBranch: "feature/source-control", - }); - assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { - cwd: "/repo", - branch: "feature/source-control", - remoteName: "origin", - remoteBranch: "feature/source-control", - }); - assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { - cwd: "/repo", - refName: "feature/source-control", - }); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves Git checkout failures without deriving the domain message from them", () => { - const gitCause = new GitCommandError({ - operation: "fetchRemoteBranch", - command: "git fetch origin feature/source-control", - cwd: "/repo", - detail: "remote rejected the request", - }); - const { layer } = makeLayer({ - response: () => - Response.json({ - ...bitbucketPullRequest, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - git: { - fetchRemoteBranch: () => Effect.fail(gitCause), - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.checkoutPullRequest({ - cwd: "/repo", - reference: "42", - force: true, - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.reference, "42"); - assert.strictEqual( - error.message, - "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", - ); - assert.strictEqual(error.cause, gitCause); - }).pipe(Effect.provide(layer)); -}); - it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { @@ -1096,79 +736,3 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ), ); - -const stalledResponseBody = (status: number): Response => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("{")); - }, - }), - { status, headers: { "content-type": "application/json" } }, - ); - -it.effect.each([200, 503])( - "times out a stalled Bitbucket JSON body after HTTP %s headers", - (status) => { - const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect.each([200, 503])( - "times out a stalled Bitbucket raw body after HTTP %s headers", - (status) => { - const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.request({ method: "GET", url: "/user" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "request"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect("times out before JSON headers arrive", () => { - const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - }).pipe(Effect.provide(layer)); -}); - -it.effect("times out before raw response headers arrive", () => { - const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.request({ method: "GET", url: "/user" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "request"); - }).pipe(Effect.provide(layer)); -}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index eefa6534cbede..aad475ab54afa 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -2,14 +2,13 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -import type * as Context from "effect/Context"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient } from "effect/http"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; @@ -30,7 +29,6 @@ import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; -import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -98,138 +96,6 @@ const processOutput = ( }); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); - -it.effect("reports implemented tools separately from locally available executables", () => { - const processMock = { - run: (input: VcsProcess.VcsProcessInput) => { - if (input.command === "git") { - return Effect.succeed(processOutput("git version 2.51.0\n")); - } - if (input.command === "gh" && input.args[0] === "--version") { - return Effect.succeed(processOutput("gh version 2.83.0\n")); - } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { - return Effect.succeed( - processOutput( - encodeJson({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "juliusmarminge", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - } satisfies Partial; - const layerTest = SourceControlDiscovery.layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-discovery-", - }), - ), - Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), - Layer.provide( - layerSourceControlProviderRegistryTest({ - process: processMock, - bitbucket: { - probeAuth: Effect.succeed({ - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some( - "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", - ), - }), - }, - }), - ), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; - const result = yield* discovery.discover; - - assert.deepStrictEqual( - result.versionControlSystems.map((item) => ({ - kind: item.kind, - implemented: item.implemented, - status: item.status, - })), - [ - { kind: "git", implemented: true, status: "available" }, - { kind: "jj", implemented: false, status: "missing" }, - ], - ); - assert.deepStrictEqual( - result.sourceControlProviders.map((item) => ({ - kind: item.kind, - status: item.status, - auth: item.auth.status, - account: item.auth.account, - })), - [ - { - kind: SourceControlProviderKind.make("github"), - status: "available", - auth: "authenticated", - account: Option.some("juliusmarminge"), - }, - { - kind: SourceControlProviderKind.make("gitlab"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("azure-devops"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("bitbucket"), - status: "available", - auth: "unauthenticated", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("forgejo"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("gitcafe"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - ], - ); - const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); - assert.ok(bitbucket); - assert.strictEqual(bitbucket.executable, undefined); - }).pipe(Effect.provide(layerTest)); -}); it.effect("probes provider authentication without exposing token details", () => { const processMock = { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index dc5a6505170bd..80738fcf38a25 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -18,7 +18,6 @@ import { DEFAULT_SERVER_SETTINGS, VcsProcessSpawnError, VcsProcessTimeoutError, - VcsRepositoryDetectionError, } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; @@ -167,97 +166,6 @@ function makeRegistry(input: { ); } -it.effect("routes GitHub remotes to the GitHub provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "github"); - }), -); - -it.effect("routes directly by provider kind for remote-first workflows", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [], - }); - - const provider = yield* registry.get(SourceControlProviderKind.make("github")); - - assert.strictEqual(provider.kind, "github"); - }), -); - -it.effect("includes the request cwd when an unregistered provider is used", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ remotes: [] }); - const provider = yield* registry.get(SourceControlProviderKind.make("unknown")); - - const error = yield* provider - .getChangeRequest({ cwd: "/repo", reference: "#42" }) - .pipe(Effect.flip); - - assert.strictEqual(error.provider, "unknown"); - assert.strictEqual(error.operation, "getChangeRequest"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.reference, "#42"); - }), -); - -it.effect("lists no change requests for a repository on an unrecognized host", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://git.example.internal/team/repo.git" }], - }); - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "unknown"); - assert.deepStrictEqual( - yield* provider.listChangeRequests({ cwd: "/repo", headSelector: "master", state: "all" }), - [], - ); - }), -); - -it.effect("retains VCS detection failures with structured cwd context", () => - Effect.gen(function* () { - const cause = new VcsRepositoryDetectionError({ - operation: "resolve", - cwd: "/repo", - detail: "raw VCS detection failure", - cause: new Error("raw nested failure"), - }); - const registry = yield* makeRegistry({ - remotes: [], - resolve: () => Effect.fail(cause), - }); - - const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); - - assert.strictEqual(error.provider, "unknown"); - assert.strictEqual(error.operation, "detectProvider"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.detail, "Failed to detect source control provider."); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.message), false); - }), -); - -it.effect("routes GitLab remotes to the GitLab provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "gitlab"); - }), -); - it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => Effect.gen(function* () { const registry = yield* makeRegistry({ @@ -340,30 +248,6 @@ it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports }), ); -it.effect("routes Bitbucket remotes to the Bitbucket provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "bitbucket"); - }), -); - -it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "azure-devops"); - }), -); - it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index fca680111c83e..14615a39856d6 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -7,21 +7,20 @@ 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 { + GitCommandError, + SourceControlProviderError, + type SourceControlCloneRepositoryInput, +} 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 +101,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; @@ -415,6 +246,98 @@ it.effect("reports clone progress from git's stderr and keeps its error text on }).pipe(Effect.provide(NodeServices.layer)), ); +function rejectingSsh( + urls: Array, + httpsFails: boolean, + reason: GitCommandError["reason"] = "authentication_failed", +) { + return { + execute: (input: GitVcsDriver.ExecuteGitInput) => + Effect.gen(function* () { + const url = input.args.at(-2)!; + urls.push(url); + if (url.startsWith("https://") && !httpsFails) return processOutput(); + yield* ( + input.progress?.onStderrLine?.( + url.startsWith("https://") + ? "fatal: could not read Username for 'https://github.com': terminal prompts disabled" + : "git@github.com: Permission denied (publickey).", + ) ?? Effect.void + ); + return yield* new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + reason, + detail: "Git command exited with a non-zero status.", + }); + }), + } satisfies Partial; +} + +const cloneInTemporaryDirectory = ( + git: Partial, + input: Omit, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-clone-fallback-" }) + .pipe(Effect.orDie); + return yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service.cloneRepository({ + ...input, + destinationPath: path.join(parent, "t3code"), + }); + }).pipe(Effect.provide(layer({ git }).pipe(Layer.orDie))); + }).pipe(Effect.provide(NodeServices.layer)); + +it.effect("retries a clone over HTTPS when the SSH login is refused", () => + Effect.gen(function* () { + const urls: Array = []; + const result = yield* cloneInTemporaryDirectory(rejectingSsh(urls, false), { + remoteUrl: CLONE_URLS.sshUrl, + }); + + assert.deepStrictEqual(urls, [CLONE_URLS.sshUrl, "https://github.com/octocat/t3code.git"]); + assert.strictEqual(result.remoteUrl, "https://github.com/octocat/t3code.git"); + }), +); + +it.effect("reports both failures when the HTTPS retry fails too", () => + Effect.gen(function* () { + const urls: Array = []; + const error = yield* Effect.flip( + cloneInTemporaryDirectory(rejectingSsh(urls, true), { remoteUrl: CLONE_URLS.sshUrl }), + ); + + assert.strictEqual(urls.length, 2); + assert.strictEqual( + error.detail, + "git@github.com: Permission denied (publickey). Cloning over HTTPS instead also failed: fatal: could not read Username for 'https://github.com': terminal prompts disabled", + ); + }), +); + +it.effect.each([ + ["the caller asked for SSH", "ssh", "authentication_failed"], + ["git failed for another reason", undefined, "remote_unreachable"], +] as const)("does not retry over HTTPS when %s", ([, protocol, reason]) => + Effect.gen(function* () { + const urls: Array = []; + yield* Effect.flip( + cloneInTemporaryDirectory(rejectingSsh(urls, false, reason), { + remoteUrl: CLONE_URLS.sshUrl, + ...(protocol === undefined ? {} : { protocol }), + }), + ); + + assert.deepStrictEqual(urls, [CLONE_URLS.sshUrl]); + }), +); + it.effect("strips embedded credentials from the remote URL it reports", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -530,113 +453,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 +527,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/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index bca8279ef2e15..64fd0c9eec64f 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -7,6 +7,7 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { + GitCommandError, SourceControlProviderError, SourceControlRepositoryError, type GitCommandFailureReason, @@ -31,8 +32,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { cloneUrlForProtocol, cloneUrlProtocol } from "@t3tools/shared/cloneUrl"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); +const isGitCommandError = Schema.is(GitCommandError); const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); export class SourceControlRepositoryService extends Context.Service< @@ -169,6 +172,27 @@ function publishPushFailureDetail(input: { } } +/** + * Adding a project clones over SSH for most hosts, and a machine with no SSH + * key there would fail every time, while the host's HTTPS credential helper + * may be set up. A clone whose SSH login was refused is retried once over + * HTTPS, unless the caller asked for SSH. A looked-up repository has the + * provider's own HTTPS URL; any other URL is rewritten, which is refused + * where it would have to guess. + */ +function httpsFallbackUrl( + input: SourceControlCloneRepositoryInput, + prepared: SourceControlPreparedClone, +): string | null { + if (input.protocol === "ssh" || cloneUrlProtocol(prepared.cloneUrl) !== "ssh") return null; + if (prepared.repository?.sshUrl === prepared.cloneUrl) return prepared.repository.url; + return cloneUrlForProtocol(prepared.cloneUrl, "https"); +} + +const isSshRejection = (error: SourceControlRepositoryError) => + isGitCommandError(error.cause) && + (error.cause.reason === "authentication_failed" || error.cause.reason === "host_key_unverified"); + function selectRemoteUrl( urls: SourceControlRepositoryCloneUrls, protocol: SourceControlCloneProtocol | undefined, @@ -312,11 +336,12 @@ export const make = Effect.gen(function* () { } satisfies SourceControlPreparedClone; }); - const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + const runClone = Effect.fn("SourceControlRepositoryService.runClone")(function* ( input: SourceControlCloneRepositoryInput, + destinationPath: string, + cloneUrl: string, options?: SourceControlCloneOptions, ) { - const prepared = yield* prepareClone(input); const onProgress = options?.onProgress; // Git interleaves progress redraws with its real messages on stderr. The // last non-progress lines are what explain a failure ("Repository not @@ -336,14 +361,8 @@ export const make = Effect.gen(function* () { yield* git .execute({ operation: "SourceControlRepositoryService.cloneRepository", - cwd: path.dirname(prepared.destinationPath), - args: [ - "clone", - "--progress", - "--", - prepared.cloneUrl, - path.basename(prepared.destinationPath), - ], + cwd: path.dirname(destinationPath), + args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, // Progress redraws add up on a slow multi-GB clone. The buffered copy // is never read (the tail is kept by hand above), so keep it small @@ -368,10 +387,42 @@ export const make = Effect.gen(function* () { }), ), ); + }); + + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + input: SourceControlCloneRepositoryInput, + options?: SourceControlCloneOptions, + ) { + const prepared = yield* prepareClone(input); + const httpsUrl = httpsFallbackUrl(input, prepared); + const remoteUrl = yield* runClone( + input, + prepared.destinationPath, + prepared.cloneUrl, + options, + ).pipe( + Effect.as(prepared.remoteUrl), + Effect.catch((sshError) => + httpsUrl === null || !isSshRejection(sshError) + ? Effect.fail(sshError) + : runClone(input, prepared.destinationPath, httpsUrl, options).pipe( + Effect.as(redactRemoteUrl(httpsUrl)), + Effect.mapError( + (httpsError) => + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: sshError.provider, + detail: `${sshError.detail} Cloning over HTTPS instead also failed: ${httpsError.detail}`, + cause: httpsError.cause, + }), + ), + ), + ), + ); return { cwd: prepared.destinationPath, - remoteUrl: prepared.remoteUrl, + remoteUrl, repository: prepared.repository, }; }); diff --git a/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts index 1c7c34d2ab806..faba4ffdc5f1b 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 03c01170f158e..1b411c93cf636 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 c37f6849763f4..6f5db469b9139 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.fork.test.ts b/apps/server/src/storageCleanup.fork.test.ts new file mode 100644 index 0000000000000..258dae981241b --- /dev/null +++ b/apps/server/src/storageCleanup.fork.test.ts @@ -0,0 +1,704 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +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 type * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { TestClock } from "effect/testing"; +import * as SqlClient from "effect/sql/SqlClient"; +import { + DEFAULT_SERVER_SETTINGS, + PROJECT_LOCAL_WORKTREES, + type WorktreeKeepWhen, + type StorageCleanupSettings, + type TerminalSummary, +} from "@t3tools/contracts"; + +import * as ServerConfig from "./config.ts"; +import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; +import * as StorageCleanup from "./storageCleanup.ts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as GitManager from "./git/GitManager.ts"; +import * as Settings from "./serverSettings.ts"; +import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; +import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; +import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; +import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAdapterV2.ts"; +import { ServerActivation } from "./serverActivation.ts"; +import { describe, expect, it } from "@effect/vitest"; +import { + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); +const DAY_MS = 24 * 60 * 60 * 1_000; + +function at(offsetMs: number): DateTime.Utc { + return DateTime.makeUnsafe(NOW_MS + offsetMs); +} + +function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { + return { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + worktreePath: null, + activeProviderThreadId: null, + lineage: { + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: null, + relationshipToParent: null, + }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + activeRunId: null, + latestVisibleMessage: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + lastVisitedAt: null, + deletedAt: null, + branch: null, + linkedPullRequest: null, + status: "idle", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + latestRunId: null, + latestRunRequestedAt: null, + latestRunStartedAt: null, + latestRunCompletedAt: null, + latestUserMessageAt: null, + createdAt: at(-30 * DAY_MS), + updatedAt: at(-10 * DAY_MS), + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + ...overrides, + }; +} + +const makeCleanupFixture = ( + location?: "custom" | "project-local", + storageCleanup: StorageCleanupSettings = { + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: 8, + worktreeOnDelete: true, + }, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); + const command = (cwd: string, args: string[]) => + git.execute({ operation: "cleanup-test", cwd, args }); + yield* command(repo, ["init", "-b", "main"]); + yield* command(repo, ["config", "user.email", "cleanup@example.test"]); + yield* command(repo, ["config", "user.name", "Cleanup test"]); + yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n"); + yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n"); + yield* command(repo, ["add", "."]); + yield* command(repo, ["commit", "-m", "initial"]); + yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); + const directory = + location === "custom" + ? yield* fs.makeTempDirectoryScoped({ prefix: "old-custom-worktrees-" }) + : location === "project-local" + ? `${repo}/.worktrees` + : config.worktreesDir; + const worktree = `${directory}/feature`; + yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); + let settings: import("@t3tools/contracts").ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + worktreesDirectory: + location === "custom" + ? directory + : location === "project-local" + ? PROJECT_LOCAL_WORKTREES + : "", + storageCleanup, + }; + let threads = [shell({ branch: "feature", worktreePath: worktree })]; + let storedWorktreePaths: string[] = [worktree]; + let projectRoots = [repo]; + let editBeforeRemoval = false; + let openSessionCwd: string | null = null; + let sessionLive = true; + let fullStatusCalls = 0; + let snapshotReads = 0; + let publishTerminal: Parameters< + TerminalManager.TerminalManager["Service"]["subscribeMetadata"] + >[0]; + const snapshotRead = yield* Deferred.make(); + let events: Stream.Stream = Stream.empty; + const activated = yield* Deferred.make(); + const context = yield* Layer.build(StorageCleanup.layer).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => + Effect.gen(function* () { + if (editBeforeRemoval && input.args.includes("remove")) + yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); + return yield* git.execute(input); + }), + statusDetailsLocal: (...args) => { + fullStatusCalls++; + return git.statusDetailsLocal(...args); + }, + }), + Effect.provideService(Settings.ServerSettingsService, { + getSettings: Effect.sync(() => settings), + subscribeChanges: Effect.succeed(Stream.empty), + } as unknown as Settings.ServerSettingsService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + list: () => Effect.sync(() => projectRoots.map((workspaceRoot) => ({ workspaceRoot }))), + listShells: () => + Effect.sync(() => + projectRoots.map((workspaceRoot, index) => ({ + id: ProjectId.make(`project-${index + 1}`), + workspaceRoot, + })), + ), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectionStore.ProjectionStoreV2, { + getShellSnapshot: (input?: { location?: string }) => + Effect.gen(function* () { + snapshotReads++; + const visible = threads.filter((thread) => thread.deletedAt === null); + const snapshot = { + threads: + input?.location === "archive" + ? [] + : visible.filter((thread) => thread.archivedAt === null), + archivedThreads: + input?.location === "active" + ? [] + : visible.filter((thread) => thread.archivedAt !== null), + }; + yield* Deferred.succeed(snapshotRead, undefined); + return snapshot; + }), + getThreadWorktreePaths: (input?: { includeArchived?: boolean }) => + Effect.sync(() => + threads.flatMap((thread) => + thread.deletedAt === null && + (input?.includeArchived === true || thread.archivedAt === null) && + thread.worktreePath !== null + ? [{ threadId: thread.id, worktreePath: thread.worktreePath }] + : [], + ), + ), + getThreadShell: (threadId: ThreadId) => + Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), + } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), + Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { + isLive: () => Effect.sync(() => sessionLive), + } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), + Effect.provideService(Orchestrator.OrchestratorV2, { + streamDomainEvents: Stream.suspend(() => events), + } as unknown as Orchestrator.OrchestratorV2["Service"]), + Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => + Effect.sync(() => + openSessionCwd !== null && strings.join("").includes("projection_provider_sessions") + ? [ + { + payload_json: JSON.stringify({ + id: "session-1", + driver: "codex", + providerInstanceId: "codex", + status: "ready", + cwd: openSessionCwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: "2026-06-01T12:00:00.000Z", + updatedAt: "2026-06-01T12:00:00.000Z", + lastError: null, + }), + }, + ] + : strings.join("").includes('AS "worktreePath"') + ? [ + ...threads.flatMap((thread) => thread.worktreePath ?? []), + ...storedWorktreePaths, + ].map((worktreePath) => ({ worktreePath })) + : [], + )) as unknown as SqlClient.SqlClient), + Effect.provideService(GitManager.GitManager, { + invalidateStatus: () => Effect.void, + } as unknown as GitManager.GitManager["Service"]), + Effect.provideService(TerminalManager.TerminalManager, { + subscribeMetadata: ( + listener: Parameters[0], + ) => + Effect.sync(() => { + publishTerminal = listener; + return () => {}; + }), + } as unknown as TerminalManager.TerminalManager["Service"]), + Effect.provideService(ServerActivation, Deferred.await(activated)), + ); + return { + service: Context.get(context, StorageCleanup.StorageCleanup), + repo, + editBeforeRemoval: () => { + editBeforeRemoval = true; + }, + fs, + worktree, + config, + command, + enableFiles: () => { + settings = { + ...settings, + storageCleanup: { + ...settings.storageCleanup, + browserArtifactsAfterDays: 8, + logsAfterDays: 8, + }, + }; + }, + setLocation: (directory: string) => { + settings = applyServerSettingsPatch(settings, { worktreesDirectory: directory }); + }, + setSettings: (next: Partial) => { + settings = { ...settings, ...next }; + }, + setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => { + settings = { + ...settings, + storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen }, + }; + }, + disable: () => { + settings = { + ...settings, + storageCleanup: { + ...settings.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: false, + }, + }; + }, + setThreads: (next: typeof threads) => { + threads = next; + }, + setStoredWorktreePaths: (paths: string[]) => { + storedWorktreePaths = paths; + }, + setProjectRoots: (roots: string[]) => { + projectRoots = roots; + }, + setSessionLive: (live: boolean) => { + sessionLive = live; + }, + setOpenSessionCwd: (cwd: string | null) => { + openSessionCwd = cwd; + }, + setTerminals: (terminals: TerminalSummary[]) => + publishTerminal({ type: "snapshot", terminals }), + fullStatusCalls: () => fullStatusCalls, + snapshotReads: () => snapshotReads, + snapshotRead, + currentThreads: () => threads, + activate: (domainEvents: Stream.Stream) => { + events = domainEvents; + return Deferred.succeed(activated, undefined); + }, + }; + }); +const cleanupTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), + Layer.provideMerge(NodeServices.layer), +); +const runCleanupTest = ( + effect: Effect.Effect< + A, + E, + | FileSystem.FileSystem + | ServerConfig.ServerConfig + | GitVcsDriver.GitVcsDriver + | Path.Path + | Scope.Scope + >, +) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); + +describe("storage cleanup reports and local file policies", () => { + it.live("cleans old custom worktrees after switching to project-local mode", () => + runCleanupTest( + Effect.gen(function* () { + const harness = yield* makeCleanupFixture("custom"); + harness.setLocation(PROJECT_LOCAL_WORKTREES); + const report = yield* harness.service.runNow; + expect(report.entries.find((entry) => entry.path === harness.worktree)?.outcome).toBe( + "removed", + ); + expect(yield* harness.fs.exists(harness.worktree)).toBe(false); + }), + ), + ); + + it.live("cleans project-local worktrees after switching back to T3 home", () => + runCleanupTest( + Effect.gen(function* () { + const harness = yield* makeCleanupFixture("project-local"); + harness.setLocation(""); + const report = yield* harness.service.runNow; + expect(report.entries.find((entry) => entry.path === harness.worktree)?.outcome).toBe( + "removed", + ); + expect(yield* harness.fs.exists(harness.worktree)).toBe(false); + }), + ), + ); +}); + +describe("explicit worktree removal", () => { + const request = (worktreePath: string) => ({ + projectId: ProjectId.make("project-1"), + worktreePath, + }); + + it.effect( + "removes a clean recent checkout with cleanup disabled and preserves its branch and thread path", + () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const f = yield* makeCleanupFixture("project-local"); + f.disable(); + f.setThreads([ + shell({ + branch: "feature", + worktreePath: f.worktree, + createdAt: at(0), + latestUserMessageAt: at(0), + latestRunCompletedAt: at(0), + latestRunId: RunId.make("run-1"), + updatedAt: at(0), + }), + ]); + expect((yield* f.service.runNow).entries).toEqual([]); + const result = yield* f.service.removeWorktreeNow(request(f.worktree)); + expect(result).toEqual({ outcome: "removed", reason: "Requested by owner" }); + expect(yield* f.fs.exists(f.worktree)).toBe(false); + expect( + (yield* f.command(f.repo, ["show-ref", "--verify", "refs/heads/feature"])).exitCode, + ).toBe(0); + expect(f.snapshotReads()).toBe(1); + expect(f.currentThreads()[0]).toMatchObject({ + branch: "feature", + worktreePath: f.worktree, + }); + }), + ), + ); + + it.effect("removes a checkout stored through a symlink and `..`, resolving it physically", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const f = yield* makeCleanupFixture("project-local"); + f.disable(); + const sibling = `${f.worktree.slice(0, f.worktree.lastIndexOf("/"))}/sibling`; + yield* f.fs.makeDirectory(sibling); + yield* f.fs.symlink(sibling, `${f.repo}/sibling-link`); + // Physically the checkout; lexically `${f.repo}/`, which doesn't exist. + const alias = `${f.repo}/sibling-link/../${f.worktree.slice(f.worktree.lastIndexOf("/") + 1)}`; + f.setThreads([ + shell({ + branch: "feature", + worktreePath: alias, + createdAt: at(0), + latestUserMessageAt: at(0), + latestRunCompletedAt: at(0), + latestRunId: RunId.make("run-1"), + updatedAt: at(0), + }), + ]); + expect(yield* f.service.removeWorktreeNow(request(alias))).toEqual({ + outcome: "removed", + reason: "Requested by owner", + }); + expect(yield* f.fs.exists(f.worktree)).toBe(false); + }), + ), + ); + + it.effect("keeps the settle-script grace period even when cleanup is explicitly requested", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const f = yield* makeCleanupFixture("project-local"); + f.disable(); + f.setThreads([ + shell({ + branch: "feature", + worktreePath: f.worktree, + settledAt: at(0), + updatedAt: at(0), + }), + ]); + expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual({ + outcome: "kept", + reason: "Settled moments ago", + }); + expect(yield* f.fs.exists(f.worktree)).toBe(true); + }), + ), + ); + + it.live( + "refuses a thread that starts after the snapshot while waiting for the workspace lease", + () => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rootsChecked = yield* Deferred.make(); + let managedRoot: string | undefined; + let checkout: string | undefined; + let checkoutChecked = false; + const f = yield* makeCleanupFixture("project-local").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: (target) => { + if (target === checkout) checkoutChecked = true; + return fs + .exists(target) + .pipe( + Effect.tap(() => + target === managedRoot + ? Deferred.succeed(rootsChecked, undefined) + : Effect.void, + ), + ); + }, + }), + ); + managedRoot = `${f.repo}/.worktrees`; + checkout = f.worktree; + f.disable(); + const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); + yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); + const alias = `${aliasRoot}/repo/.worktrees/feature`; + const leased = yield* Deferred.make(); + const resume = yield* Deferred.make(); + const start = yield* withWorkspaceLease( + alias, + Effect.gen(function* () { + yield* Deferred.succeed(leased, undefined); + yield* Deferred.await(resume); + }), + ).pipe(Effect.forkChild); + yield* Deferred.await(leased); + const removal = yield* f.service + .removeWorktreeNow(request(f.worktree)) + .pipe(Effect.forkChild); + yield* Deferred.await(rootsChecked); + expect(checkoutChecked).toBe(false); + expect(yield* fs.exists(f.worktree)).toBe(true); + f.setThreads([shell({ branch: "feature", worktreePath: alias, status: "running" })]); + yield* Deferred.succeed(resume, undefined); + yield* Fiber.join(start); + expect(yield* Fiber.join(removal)).toEqual({ + outcome: "kept", + reason: "Thread activity or shared worktree changed since check", + }); + expect(yield* f.fs.exists(f.worktree)).toBe(true); + }), + ), + ); + + it.live.each([false, true])( + "refuses removal through either alias of a shared active checkout (subagent: %s)", + (subagent) => + runCleanupTest( + Effect.gen(function* () { + const f = yield* makeCleanupFixture("project-local"); + f.disable(); + const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); + yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); + const alias = `${aliasRoot}/repo/.worktrees/feature`; + f.setThreads([ + shell({ branch: "feature", worktreePath: f.worktree }), + shell({ + id: ThreadId.make("thread-2"), + branch: "feature", + worktreePath: alias, + status: "running", + ...(subagent + ? { + lineage: { + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: ThreadId.make("thread-1"), + relationshipToParent: "subagent" as const, + }, + } + : {}), + }), + ]); + for (const target of [f.worktree, alias]) { + expect(yield* f.service.removeWorktreeNow(request(target))).toEqual({ + outcome: "kept", + reason: subagent ? "Thread is running or has pending work" : "Shared by 2 threads", + }); + } + f.setSettings({ + storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 0 }, + }); + expect((yield* f.service.runNow).entries).toEqual([ + expect.objectContaining({ + outcome: "kept", + reason: subagent ? "Thread is running or has pending work" : "Shared by 2 threads", + }), + ]); + expect(yield* f.fs.exists(f.worktree)).toBe(true); + }), + ), + ); + + it.live.each(["outside", "main"] as const)("refuses the %s checkout", (location) => + runCleanupTest( + Effect.gen(function* () { + const f = yield* makeCleanupFixture("project-local"); + const target = location === "main" ? f.repo : `${f.repo}/outside`; + if (location === "outside") + yield* f.command(f.repo, ["worktree", "add", "-b", "outside", target]); + f.setThreads([ + shell({ branch: location === "main" ? "main" : "outside", worktreePath: target }), + ]); + expect(yield* f.service.removeWorktreeNow(request(target))).toEqual({ + outcome: "kept", + reason: "Outside the managed worktree folder", + }); + expect(yield* f.fs.exists(target)).toBe(true); + expect(yield* f.fs.exists(f.worktree)).toBe(true); + }), + ), + ); + + it.live.each(["idle", "project-checkout", "missing-project-checkout"] as const)( + "checks an aliased checkout against canonical project roots (%s)", + (state) => + runCleanupTest( + Effect.gen(function* () { + const f = yield* makeCleanupFixture("project-local"); + f.disable(); + const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); + yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); + const alias = `${aliasRoot}/repo/.worktrees/feature`; + f.setThreads([shell({ branch: "feature", worktreePath: alias })]); + if (state !== "idle") + f.setProjectRoots([ + f.repo, + state === "project-checkout" ? f.worktree : `${f.worktree}/missing/checkout`, + ]); + expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual( + state === "idle" + ? { outcome: "removed", reason: "Requested by owner" } + : { outcome: "kept", reason: "Contains a project checkout" }, + ); + expect(yield* f.fs.exists(f.worktree)).toBe(state !== "idle"); + }), + ), + ); + + it.live.each(["worktreePath", "cwd", "nested-cwd"] as const)( + "keeps a checkout with an aliased terminal %s", + (location) => + runCleanupTest( + Effect.gen(function* () { + const f = yield* makeCleanupFixture("project-local"); + f.disable(); + const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); + yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); + const alias = `${aliasRoot}/repo/.worktrees/feature`; + yield* f.fs.makeDirectory(`${f.worktree}/dist`); + yield* f.setTerminals([ + { + threadId: "other-thread", + terminalId: "default", + status: "running", + cwd: + location === "worktreePath" ? f.repo : location === "cwd" ? alias : `${alias}/dist`, + worktreePath: location === "worktreePath" ? alias : null, + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Shell", + updatedAt: DateTime.formatIso(at(0)), + }, + ]); + expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual({ + outcome: "kept", + reason: "Open terminal", + }); + expect(yield* f.fs.exists(f.worktree)).toBe(true); + }), + ), + ); + + it.live.each(["tracked.txt", "notes.txt", ".env"])( + "keeps local changes in %s even if automatic cleanup discards untracked files", + (name) => + runCleanupTest( + Effect.gen(function* () { + const f = yield* makeCleanupFixture("project-local"); + f.setPolicy("tracked-changes"); + yield* f.fs.writeFileString(`${f.worktree}/${name}`, "keep me"); + expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toMatchObject({ + outcome: "kept", + reason: expect.stringMatching(/changes|ignored files/), + }); + expect(yield* f.fs.readFileString(`${f.worktree}/${name}`)).toBe("keep me"); + }), + ), + ); + + it.live.each(["physical", "alias", "nested-alias"] as const)( + "keeps an idle checkout with a live provider session (%s)", + (location) => + runCleanupTest( + Effect.gen(function* () { + const f = yield* makeCleanupFixture("project-local"); + f.disable(); + const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); + yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); + const alias = `${aliasRoot}/repo/.worktrees/feature`; + yield* f.fs.makeDirectory(`${f.worktree}/nested`); + f.setOpenSessionCwd( + location === "physical" ? f.worktree : location === "alias" ? alias : `${alias}/nested`, + ); + expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual({ + outcome: "kept", + reason: "Provider session is still open", + }); + expect(yield* f.fs.exists(f.worktree)).toBe(true); + }), + ), + ); +}); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index bd1cc2dce02e3..992e219db9122 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -9,23 +9,17 @@ 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, - PROJECT_LOCAL_WORKTREES, - StorageCleanupReport, type WorktreeKeepWhen, type StorageCleanupSettings, - type TerminalSummary, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "./config.ts"; -import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as StorageCleanup from "./storageCleanup.ts"; -import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Settings from "./serverSettings.ts"; @@ -38,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"; @@ -59,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); @@ -129,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) => { @@ -251,8 +233,7 @@ const allocatedBytes = (fs: FileSystem.FileSystem, file: string) => ), ); -const makeCleanupFixture = ( - location?: "custom" | "project-local", +const cleanupFixture = ( storageCleanup: StorageCleanupSettings = { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 8, @@ -263,7 +244,7 @@ const makeCleanupFixture = ( const fs = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; const git = yield* GitVcsDriver.GitVcsDriver; - const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); + const repo = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" })); const command = (cwd: string, args: string[]) => git.execute({ operation: "cleanup-test", cwd, args }); yield* command(repo, ["init", "-b", "main"]); @@ -274,22 +255,10 @@ const makeCleanupFixture = ( yield* command(repo, ["add", "."]); yield* command(repo, ["commit", "-m", "initial"]); yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); - const directory = - location === "custom" - ? yield* fs.makeTempDirectoryScoped({ prefix: "old-custom-worktrees-" }) - : location === "project-local" - ? `${repo}/.worktrees` - : config.worktreesDir; - const worktree = `${directory}/feature`; + const worktree = `${config.worktreesDir}/feature`; yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); let settings: import("@t3tools/contracts").ServerSettings = { ...DEFAULT_SERVER_SETTINGS, - worktreesDirectory: - location === "custom" - ? directory - : location === "project-local" - ? PROJECT_LOCAL_WORKTREES - : "", storageCleanup, }; let threads = [shell({ branch: "feature", worktreePath: worktree })]; @@ -300,10 +269,6 @@ const makeCleanupFixture = ( let sessionLive = true; let fullStatusCalls = 0; let snapshotReads = 0; - let publishTerminal: Parameters< - TerminalManager.TerminalManager["Service"]["subscribeMetadata"] - >[0]; - const snapshotRead = yield* Deferred.make(); let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); const context = yield* Layer.build(StorageCleanup.layer).pipe( @@ -311,7 +276,12 @@ const makeCleanupFixture = ( ...git, execute: (input) => Effect.gen(function* () { - if (editBeforeRemoval && input.args.includes("remove")) + // Before Git's own clean check: inside `git worktree remove`, or on + // Windows the same status check the removal makes before deleting. + if ( + editBeforeRemoval && + (input.args.includes("remove") || input.args.includes("--porcelain")) + ) yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); return yield* git.execute(input); }), @@ -336,10 +306,10 @@ const makeCleanupFixture = ( } as unknown as ProjectStore.ProjectStoreV2["Service"]), Effect.provideService(ProjectionStore.ProjectionStoreV2, { getShellSnapshot: (input?: { location?: string }) => - Effect.gen(function* () { + Effect.sync(() => { snapshotReads++; const visible = threads.filter((thread) => thread.deletedAt === null); - const snapshot = { + return { threads: input?.location === "archive" ? [] @@ -349,8 +319,6 @@ const makeCleanupFixture = ( ? [] : visible.filter((thread) => thread.archivedAt !== null), }; - yield* Deferred.succeed(snapshotRead, undefined); - return snapshot; }), getThreadWorktreePaths: (input?: { includeArchived?: boolean }) => Effect.sync(() => @@ -401,13 +369,7 @@ const makeCleanupFixture = ( invalidateStatus: () => Effect.void, } as unknown as GitManager.GitManager["Service"]), Effect.provideService(TerminalManager.TerminalManager, { - subscribeMetadata: ( - listener: Parameters[0], - ) => - Effect.sync(() => { - publishTerminal = listener; - return () => {}; - }), + subscribeMetadata: () => Effect.succeed(() => {}), } as unknown as TerminalManager.TerminalManager["Service"]), Effect.provideService(ServerActivation, Deferred.await(activated)), ); @@ -431,9 +393,6 @@ const makeCleanupFixture = ( }, }; }, - setLocation: (directory: string) => { - settings = applyServerSettingsPatch(settings, { worktreesDirectory: directory }); - }, setSettings: (next: Partial) => { settings = { ...settings, ...next }; }, @@ -468,21 +427,14 @@ const makeCleanupFixture = ( setOpenSessionCwd: (cwd: string | null) => { openSessionCwd = cwd; }, - setTerminals: (terminals: TerminalSummary[]) => - publishTerminal({ type: "snapshot", terminals }), fullStatusCalls: () => fullStatusCalls, snapshotReads: () => snapshotReads, - snapshotRead, - currentThreads: () => threads, activate: (domainEvents: Stream.Stream) => { events = domainEvents; return Deferred.succeed(activated, undefined); }, }; }); -const cleanupFixture = (storageCleanup?: StorageCleanupSettings) => - makeCleanupFixture(undefined, storageCleanup); - const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -505,27 +457,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), @@ -984,27 +915,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* () { @@ -1023,76 +933,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) => @@ -1213,100 +1053,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* () { @@ -1408,62 +1154,9 @@ 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", () => { - it.live("cleans old custom worktrees after switching to project-local mode", () => - runCleanupTest( - Effect.gen(function* () { - const harness = yield* makeCleanupFixture("custom"); - harness.setLocation(PROJECT_LOCAL_WORKTREES); - const report = yield* harness.service.runNow; - expect(report.entries.find((entry) => entry.path === harness.worktree)?.outcome).toBe( - "removed", - ); - expect(yield* harness.fs.exists(harness.worktree)).toBe(false); - }), - ), - ); - - it.live("cleans project-local worktrees after switching back to T3 home", () => - runCleanupTest( - Effect.gen(function* () { - const harness = yield* makeCleanupFixture("project-local"); - harness.setLocation(""); - const report = yield* harness.service.runNow; - expect(report.entries.find((entry) => entry.path === harness.worktree)?.outcome).toBe( - "removed", - ); - expect(yield* harness.fs.exists(harness.worktree)).toBe(false); - }), - ), - ); - it.effect("automatically removes an eligible archived thread checkout", () => runCleanupTest( Effect.gen(function* () { @@ -1553,29 +1246,86 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live("falls back to file lengths when link counts and allocated blocks are unavailable", () => + 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 }); + }), + ), + ); + // An agent can write this manifest. Statting its store sent the Windows + // user's NTLM credentials to attacker.test during a background sweep. + it.live.each([ + { storeDir: "\\\\attacker.test\\s" }, + { storeDir: "/local/store", virtualStoreDir: "//attacker.test/s" }, + ])("never stats a network pnpm store %o", (manifest) => runCleanupTest( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { service, worktree } = yield* cleanupFixture().pipe( + const statted: Array = []; + const { service, worktree, setPolicy } = yield* cleanupFixture().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, - stat: (path) => - fs - .stat(path) - .pipe( - Effect.map((stat) => ({ ...stat, nlink: Option.none(), blocks: Option.none() })), - ), + stat: (target) => { + statted.push(target); + return fs.stat(target); + }, }), ); - const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => - fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + setPolicy("tracked-changes"); + yield* fs.makeDirectory(`${worktree}/node_modules`); + yield* fs.writeFileString( + `${worktree}/node_modules/.modules.yaml`, + JSON.stringify(manifest), ); - 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); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(statted.filter((target) => target.includes("attacker.test"))).toEqual([]); }), ), ); @@ -1653,41 +1403,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* () { @@ -1759,27 +1474,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"], @@ -1919,21 +1613,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* () { @@ -1978,40 +1657,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* () { @@ -2089,102 +1734,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)", @@ -2237,379 +1786,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); - }), - ), -); - -describe("explicit worktree removal", () => { - const request = (worktreePath: string) => ({ - projectId: ProjectId.make("project-1"), - worktreePath, - }); - - it.effect( - "removes a clean recent checkout with cleanup disabled and preserves its branch and thread path", - () => - runCleanupTest( - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const f = yield* makeCleanupFixture("project-local"); - f.disable(); - f.setThreads([ - shell({ - branch: "feature", - worktreePath: f.worktree, - createdAt: at(0), - latestUserMessageAt: at(0), - latestRunCompletedAt: at(0), - latestRunId: RunId.make("run-1"), - updatedAt: at(0), - }), - ]); - expect((yield* f.service.runNow).entries).toEqual([]); - const result = yield* f.service.removeWorktreeNow(request(f.worktree)); - expect(result).toEqual({ outcome: "removed", reason: "Requested by owner" }); - expect(yield* f.fs.exists(f.worktree)).toBe(false); - expect( - (yield* f.command(f.repo, ["show-ref", "--verify", "refs/heads/feature"])).exitCode, - ).toBe(0); - expect(f.snapshotReads()).toBe(1); - expect(f.currentThreads()[0]).toMatchObject({ - branch: "feature", - worktreePath: f.worktree, - }); - }), - ), - ); - - it.effect("removes a checkout stored through a symlink and `..`, resolving it physically", () => - runCleanupTest( - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const f = yield* makeCleanupFixture("project-local"); - f.disable(); - const sibling = `${f.worktree.slice(0, f.worktree.lastIndexOf("/"))}/sibling`; - yield* f.fs.makeDirectory(sibling); - yield* f.fs.symlink(sibling, `${f.repo}/sibling-link`); - // Physically the checkout; lexically `${f.repo}/`, which doesn't exist. - const alias = `${f.repo}/sibling-link/../${f.worktree.slice(f.worktree.lastIndexOf("/") + 1)}`; - f.setThreads([ - shell({ - branch: "feature", - worktreePath: alias, - createdAt: at(0), - latestUserMessageAt: at(0), - latestRunCompletedAt: at(0), - latestRunId: RunId.make("run-1"), - updatedAt: at(0), - }), - ]); - expect(yield* f.service.removeWorktreeNow(request(alias))).toEqual({ - outcome: "removed", - reason: "Requested by owner", - }); - expect(yield* f.fs.exists(f.worktree)).toBe(false); - }), - ), - ); - - it.effect("keeps the settle-script grace period even when cleanup is explicitly requested", () => - runCleanupTest( - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const f = yield* makeCleanupFixture("project-local"); - f.disable(); - f.setThreads([ - shell({ - branch: "feature", - worktreePath: f.worktree, - settledAt: at(0), - updatedAt: at(0), - }), - ]); - expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual({ - outcome: "kept", - reason: "Settled moments ago", - }); - expect(yield* f.fs.exists(f.worktree)).toBe(true); - }), - ), - ); - - it.live( - "refuses a thread that starts after the snapshot while waiting for the workspace lease", - () => - runCleanupTest( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const rootsChecked = yield* Deferred.make(); - let managedRoot: string | undefined; - let checkout: string | undefined; - let checkoutChecked = false; - const f = yield* makeCleanupFixture("project-local").pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - exists: (target) => { - if (target === checkout) checkoutChecked = true; - return fs - .exists(target) - .pipe( - Effect.tap(() => - target === managedRoot - ? Deferred.succeed(rootsChecked, undefined) - : Effect.void, - ), - ); - }, - }), - ); - managedRoot = `${f.repo}/.worktrees`; - checkout = f.worktree; - f.disable(); - const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); - yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); - const alias = `${aliasRoot}/repo/.worktrees/feature`; - const leased = yield* Deferred.make(); - const resume = yield* Deferred.make(); - const start = yield* withWorkspaceLease( - alias, - Effect.gen(function* () { - yield* Deferred.succeed(leased, undefined); - yield* Deferred.await(resume); - }), - ).pipe(Effect.forkChild); - yield* Deferred.await(leased); - const removal = yield* f.service - .removeWorktreeNow(request(f.worktree)) - .pipe(Effect.forkChild); - yield* Deferred.await(rootsChecked); - expect(checkoutChecked).toBe(false); - expect(yield* fs.exists(f.worktree)).toBe(true); - f.setThreads([shell({ branch: "feature", worktreePath: alias, status: "running" })]); - yield* Deferred.succeed(resume, undefined); - yield* Fiber.join(start); - expect(yield* Fiber.join(removal)).toEqual({ - outcome: "kept", - reason: "Thread activity or shared worktree changed since check", - }); - expect(yield* f.fs.exists(f.worktree)).toBe(true); - }), - ), - ); - - it.live.each([false, true])( - "refuses removal through either alias of a shared active checkout (subagent: %s)", - (subagent) => - runCleanupTest( - Effect.gen(function* () { - const f = yield* makeCleanupFixture("project-local"); - f.disable(); - const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); - yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); - const alias = `${aliasRoot}/repo/.worktrees/feature`; - f.setThreads([ - shell({ branch: "feature", worktreePath: f.worktree }), - shell({ - id: ThreadId.make("thread-2"), - branch: "feature", - worktreePath: alias, - status: "running", - ...(subagent - ? { - lineage: { - rootThreadId: ThreadId.make("thread-1"), - parentThreadId: ThreadId.make("thread-1"), - relationshipToParent: "subagent" as const, - }, - } - : {}), - }), - ]); - for (const target of [f.worktree, alias]) { - expect(yield* f.service.removeWorktreeNow(request(target))).toEqual({ - outcome: "kept", - reason: subagent ? "Thread is running or has pending work" : "Shared by 2 threads", - }); - } - f.setSettings({ - storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 0 }, - }); - expect((yield* f.service.runNow).entries).toEqual([ - expect.objectContaining({ - outcome: "kept", - reason: subagent ? "Thread is running or has pending work" : "Shared by 2 threads", - }), - ]); - expect(yield* f.fs.exists(f.worktree)).toBe(true); - }), - ), - ); - - it.live.each(["outside", "main"] as const)("refuses the %s checkout", (location) => - runCleanupTest( - Effect.gen(function* () { - const f = yield* makeCleanupFixture("project-local"); - const target = location === "main" ? f.repo : `${f.repo}/outside`; - if (location === "outside") - yield* f.command(f.repo, ["worktree", "add", "-b", "outside", target]); - f.setThreads([ - shell({ branch: location === "main" ? "main" : "outside", worktreePath: target }), - ]); - expect(yield* f.service.removeWorktreeNow(request(target))).toEqual({ - outcome: "kept", - reason: "Outside the managed worktree folder", - }); - expect(yield* f.fs.exists(target)).toBe(true); - expect(yield* f.fs.exists(f.worktree)).toBe(true); - }), - ), - ); - - it.live.each(["idle", "project-checkout", "missing-project-checkout"] as const)( - "checks an aliased checkout against canonical project roots (%s)", - (state) => - runCleanupTest( - Effect.gen(function* () { - const f = yield* makeCleanupFixture("project-local"); - f.disable(); - const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); - yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); - const alias = `${aliasRoot}/repo/.worktrees/feature`; - f.setThreads([shell({ branch: "feature", worktreePath: alias })]); - if (state !== "idle") - f.setProjectRoots([ - f.repo, - state === "project-checkout" ? f.worktree : `${f.worktree}/missing/checkout`, - ]); - expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual( - state === "idle" - ? { outcome: "removed", reason: "Requested by owner" } - : { outcome: "kept", reason: "Contains a project checkout" }, - ); - expect(yield* f.fs.exists(f.worktree)).toBe(state !== "idle"); - }), - ), - ); - - it.live.each(["worktreePath", "cwd", "nested-cwd"] as const)( - "keeps a checkout with an aliased terminal %s", - (location) => - runCleanupTest( - Effect.gen(function* () { - const f = yield* makeCleanupFixture("project-local"); - f.disable(); - const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); - yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); - const alias = `${aliasRoot}/repo/.worktrees/feature`; - yield* f.fs.makeDirectory(`${f.worktree}/dist`); - yield* f.setTerminals([ - { - threadId: "other-thread", - terminalId: "default", - status: "running", - cwd: - location === "worktreePath" ? f.repo : location === "cwd" ? alias : `${alias}/dist`, - worktreePath: location === "worktreePath" ? alias : null, - pid: 1, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: false, - label: "Shell", - updatedAt: DateTime.formatIso(at(0)), - }, - ]); - expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual({ - outcome: "kept", - reason: "Open terminal", - }); - expect(yield* f.fs.exists(f.worktree)).toBe(true); - }), - ), - ); - - it.live.each(["tracked.txt", "notes.txt", ".env"])( - "keeps local changes in %s even if automatic cleanup discards untracked files", - (name) => - runCleanupTest( - Effect.gen(function* () { - const f = yield* makeCleanupFixture("project-local"); - f.setPolicy("tracked-changes"); - yield* f.fs.writeFileString(`${f.worktree}/${name}`, "keep me"); - expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toMatchObject({ - outcome: "kept", - reason: expect.stringMatching(/changes|ignored files/), - }); - expect(yield* f.fs.readFileString(`${f.worktree}/${name}`)).toBe("keep me"); - }), - ), - ); - - it.live.each(["physical", "alias", "nested-alias"] as const)( - "keeps an idle checkout with a live provider session (%s)", - (location) => - runCleanupTest( - Effect.gen(function* () { - const f = yield* makeCleanupFixture("project-local"); - f.disable(); - const aliasRoot = yield* f.fs.makeTempDirectoryScoped({ prefix: "cleanup-alias-" }); - yield* f.fs.symlink(f.repo, `${aliasRoot}/repo`); - const alias = `${aliasRoot}/repo/.worktrees/feature`; - yield* f.fs.makeDirectory(`${f.worktree}/nested`); - f.setOpenSessionCwd( - location === "physical" ? f.worktree : location === "alias" ? alias : `${alias}/nested`, - ); - expect(yield* f.service.removeWorktreeNow(request(f.worktree))).toEqual({ - outcome: "kept", - reason: "Provider session is still open", - }); - expect(yield* f.fs.exists(f.worktree)).toBe(true); - }), - ), - ); -}); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index b8cdb1cca6602..a9bc3d531fe6d 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -34,6 +34,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"; @@ -47,10 +48,12 @@ import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import { canonicalWorkspacePath, physicalWorkspacePath } from "./workspace/workspacePath.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { isNetworkPath } from "@t3tools/shared/path"; const decodeCleanupThread = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJson), @@ -58,6 +61,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 @@ -272,14 +282,40 @@ const make = Effect.gen(function* () { return false; }); + // 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))); + // An agent can write this manifest, and statting a network path makes + // Windows sign in to its host with the user's NTLM credentials. + if (isNetworkPath(manifest.storeDir) || isNetworkPath(manifest.virtualStoreDir ?? "")) { + return null; + } + 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( @@ -1020,27 +1056,25 @@ const make = Effect.gen(function* () { const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); } - yield* cleanupGit( - project.workspaceRoot, - [ - "-c", - "status.showUntrackedFiles=normal", - "worktree", - "remove", - // Git requires force for submodules; recursive checks above cannot make removal atomic. - ...(submodules.force ? ["--force"] : []), - "--", - worktreePath, - ], - "git worktree remove", - ); + yield* removeLinkedWorktree({ + operation: "StorageCleanup.cleanupGit", + cwd: project.workspaceRoot, + worktreePath, + force: submodules.force, + execute: git.execute, + run: (args) => cleanupGit(project.workspaceRoot, [...args], "git worktree remove"), + }); entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderTurnStartService recreates the checkout // from that branch when the thread is resumed. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( - (effect) => withWorkspaceLease(worktreePath, effect), + (effect) => + withWorkspaceLease(worktreePath, effect).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), Effect.catch((error) => Effect.gen(function* () { entries.push({ diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 6a07d9decc6db..1cc66be7b3dfe 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 82226c4413987..8f402b6682f2a 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 11127e19c2d92..0000000000000 --- 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 0be829cbb891e..e19e6a1c89685 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 fac476b15cc79..6fa6c8500817e 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 67b33fe6f9622..d88010f600e35 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 4a668ee5af785..4e0447a1e2c44 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.fork.test.ts b/apps/server/src/terminal/Manager.fork.test.ts new file mode 100644 index 0000000000000..97cc645af7784 --- /dev/null +++ b/apps/server/src/terminal/Manager.fork.test.ts @@ -0,0 +1,337 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + DEFAULT_TERMINAL_ID, + type TerminalEvent, + type TerminalMetadataStreamEvent, + type TerminalOpenInput, +} 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 PlatformError from "effect/PlatformError"; +import * as Path from "effect/Path"; + +import * as Ref from "effect/Ref"; + +import * as Scope from "effect/Scope"; + +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { expect, vi } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; + +import * as TerminalManager from "./Manager.ts"; + +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; + +vi.mock("effect/SynchronizedRef", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, make: vi.fn(original.make) }; +}); + +class FakePtyProcess implements PtyAdapter.PtyProcess { + readonly writes: string[] = []; + readonly resizeCalls: Array<{ cols: number; rows: number }> = []; + readonly killSignals: Array = []; + readonly pid: number; + writeFailure: unknown | undefined; + resizeFailure: unknown | undefined; + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + killed = false; + exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; + onKill: ((signal?: string) => void) | undefined; + + constructor(pid: number) { + this.pid = pid; + } + + write(data: string): void { + if (this.writeFailure !== undefined) { + throw this.writeFailure; + } + this.writes.push(data); + } + + resize(cols: number, rows: number): void { + if (this.resizeFailure !== undefined) { + throw this.resizeFailure; + } + this.resizeCalls.push({ cols, rows }); + } + + kill(signal?: string): void { + this.killed = true; + this.killSignals.push(signal); + this.onKill?.(signal); + } + + onData(callback: (data: string) => void): () => void { + this.dataListeners.add(callback); + return () => { + this.dataListeners.delete(callback); + }; + } + + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { + if (this.exitOnSubscribe) callback(this.exitOnSubscribe); + this.exitListeners.add(callback); + return () => { + this.exitListeners.delete(callback); + }; + } + + emitData(data: string): void { + for (const listener of this.dataListeners) { + listener(data); + } + } + + get exitListenerCount(): number { + return this.exitListeners.size; + } + + emitExit(event: PtyAdapter.PtyExitEvent): void { + for (const listener of this.exitListeners) { + listener(event); + } + } +} + +class FakePtyAdapter { + readonly spawnInputs: PtyAdapter.PtySpawnInput[] = []; + readonly processes: FakePtyProcess[] = []; + readonly spawnFailures: Error[] = []; + private readonly mode: "sync" | "async"; + private nextPid = 9000; + exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; + + constructor(mode: "sync" | "async" = "sync") { + this.mode = mode; + } + + spawn( + input: PtyAdapter.PtySpawnInput, + ): Effect.Effect { + this.spawnInputs.push(input); + const failure = this.spawnFailures.shift(); + if (failure) { + return Effect.fail( + new PtyAdapter.PtySpawnError({ + adapter: "fake", + shell: input.shell, + cause: failure, + }), + ); + } + const process = new FakePtyProcess(this.nextPid++); + process.exitOnSubscribe = this.exitOnSubscribe; + this.processes.push(process); + if (this.mode === "async") { + return Effect.tryPromise({ + try: async () => process, + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "fake", + shell: input.shell, + cause, + }), + }); + } + return Effect.succeed(process); + } +} + +function openInput(overrides: Partial = {}): TerminalOpenInput { + return { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: process.cwd(), + cols: 100, + rows: 24, + ...overrides, + }; +} + +interface CreateManagerOptions { + shellResolver?: () => string; + env?: NodeJS.ProcessEnv; + subprocessInspector?: (terminalPid: number) => Effect.Effect<{ + readonly hasRunningSubprocess: boolean; + readonly childCommand: string | null; + readonly processIds: ReadonlyArray; + }>; + processTable?: Effect.Effect< + ReadonlyArray<{ readonly pid: number; readonly ppid: number; readonly name: string }>, + never + >; + subprocessPollIntervalMs?: number; + processKillGraceMs?: number; + registerTerminalProcesses?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["registerTerminalProcesses"]; + maxRetainedInactiveSessions?: number; + historyByteLimit?: number; + ptyAdapter?: FakePtyAdapter; + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; + managedBinaryCacheDir?: string; + managedBinaryToolsDir?: string; +} + +interface ManagerFixture { + readonly baseDir: string; + readonly logsDir: string; + readonly ptyAdapter: FakePtyAdapter; + readonly manager: TerminalManager.TerminalManager["Service"]; + readonly getEvents: Effect.Effect>; +} + +const createManager = ( + historyLineLimit = 5, + options: CreateManagerOptions = {}, +): Effect.Effect< + ManagerFixture, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner +> => + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); + const logsDir = join(baseDir, "userdata", "logs", "terminals"); + const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); + + const manager = yield* TerminalManager.makeWithOptions({ + logsDir, + historyLineLimit, + ptyAdapter, + ...(options.historyByteLimit !== undefined + ? { historyByteLimit: options.historyByteLimit } + : {}), + ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), + ...(options.subprocessInspector !== undefined + ? { subprocessInspector: options.subprocessInspector } + : {}), + ...(options.processTable !== undefined ? { processTable: options.processTable } : {}), + ...(options.subprocessPollIntervalMs !== undefined + ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } + : {}), + processKillGraceMs: options.processKillGraceMs ?? 1, + ...(options.registerTerminalProcesses !== undefined + ? { registerTerminalProcesses: options.registerTerminalProcesses } + : {}), + ...(options.maxRetainedInactiveSessions !== undefined + ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } + : {}), + ...(options.resolveProviderInstanceEnvironment !== undefined + ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } + : {}), + ...(options.managedBinaryCacheDir === undefined + ? {} + : { + managedBinaryCacheDir: options.managedBinaryCacheDir, + managedBinaryToolsDir: options.managedBinaryToolsDir, + }), + }); + const eventsRef = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribe((event) => + Ref.update(eventsRef, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + return { + baseDir, + logsDir, + join, + ptyAdapter, + manager, + getEvents: Ref.get(eventsRef), + }; + }), + ); + +it.effect( + "launches inline shell commands directly and streams ordered output, input and exit", + () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + subprocessInspector: () => + Effect.succeed({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + }); + const exited = yield* Deferred.make(); + const received: TerminalEvent[] = []; + const unsubscribe = yield* manager.subscribe((event) => + Effect.gen(function* () { + received.push(event); + if (event.type === "exited") yield* Deferred.succeed(exited, undefined); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const metadata: TerminalMetadataStreamEvent[] = []; + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + Effect.sync(() => { + metadata.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + yield* manager.openCommand({ + ...openInput(), + terminalId: "inline-run", + command: { command: "read answer; printf '%s' \"$answer\"; exit 7", language: "bash" }, + }); + expect(ptyAdapter.spawnInputs[0]).toMatchObject({ + shell: "bash", + args: ["-c", "read answer; printf '%s' \"$answer\"; exit 7"], + }); + expect( + metadata.some( + (event) => + event.type === "upsert" && + event.terminal.terminalId === "inline-run" && + event.terminal.inline === true, + ), + ).toBe(true); + yield* manager.open({ ...openInput(), terminalId: "normal-drawer" }); + expect( + metadata.some( + (event) => + event.type === "upsert" && + event.terminal.terminalId === "normal-drawer" && + event.terminal.inline === undefined, + ), + ).toBe(true); + const process = ptyAdapter.processes[0]!; + yield* manager.closeIdle({ threadId: "thread-1" }); + expect(process.killed).toBe(false); + yield* manager.write({ threadId: "thread-1", terminalId: "inline-run", data: "yes\r" }); + yield* manager.write({ threadId: "thread-1", terminalId: "inline-run", data: "\u0003" }); + expect(process.writes).toEqual(["yes\r", "\u0003"]); + process.emitData("answer: yes\r\n"); + process.emitExit({ exitCode: 7, signal: null }); + yield* Deferred.await(exited); + expect( + received + .filter((event) => event.type === "output" || event.type === "exited") + .map((event) => event.type), + ).toEqual(["output", "exited"]); + expect(received.at(-1)).toMatchObject({ type: "exited", exitCode: 7 }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + NodeServices.layer, + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), +); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index fa374007efd2c..413436ea452c5 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,977 +601,140 @@ 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", () => - 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", () => + it.effect("retains plain Unicode and control characters around a split query", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); @@ -1836,207 +751,6 @@ it.layer( }), ); - 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); - - 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 }); - 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" }); - }), - ); - it.effect("strips replayable CSI and DCS traffic while preserving setters", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -2085,54 +799,7 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before after after after after\n"); - }), - ); - - 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); + assert.equal(reopened.history, "before after after after after\n"); }), ); @@ -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"; @@ -2792,52 +1156,6 @@ it.layer( }), ); - 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"); - }), - ); - it.effect("resolves a provider instance environment before spawning", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); @@ -2879,149 +1197,55 @@ it.layer( const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); assert.deepStrictEqual( - error, - new TerminalProviderInstanceNotFoundError({ providerInstanceId }), - ); - expect(ptyAdapter.spawnInputs).toHaveLength(0); - }), - ); - - 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), - getDontAskMode: Effect.fail(settingsError), - updateSettings: () => Effect.fail(settingsError), - updateSettingsWith: () => 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", - driver: "codex", - variable: "CODEX_HOME", - config: { homePath: "/configured/codex" }, - expectedHome: "/configured/codex", - }, - { - name: "Codex shadow home", - driver: "codex", - variable: "CODEX_HOME", - config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, - expectedHome: "/configured/codex-shadow", - }, - { - name: "Claude home", - driver: "claudeAgent", - variable: "CLAUDE_CONFIG_DIR", - config: { homePath: "/configured/claude" }, - expectedHome: "/configured/claude", - }, - ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "configured_home", - env: undefined, - }); - - expect(environment[variable]).toBe(path.resolve(expectedHome)); - }).pipe( - Effect.provide( - ServerSettings.layerTest({ - providerInstances: { - [ProviderInstanceId.make("configured_home")]: { - driver: ProviderDriverKind.make(driver), - environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], - config, - }, - }, - }), - ), - ), - ); - - 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" }, - }, - }, - }), - ), - ), + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), ); - it.effect("resolves the Claude default slot", () => + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ serverSettings, path, - rawProviderInstanceId: "claudeAgent", + rawProviderInstanceId: "configured_home", env: undefined, }); - expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/); + expect(environment[variable]).toBe(path.resolve(expectedHome)); }).pipe( Effect.provide( - ServerSettings.ServerSettingsService.layerTest({ + ServerSettings.layerTest({ providerInstances: { - [ProviderInstanceId.make("claudeAgent")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: "~/.claude-default" }, + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, }, }, }), @@ -3029,41 +1253,6 @@ it.layer( ), ); - 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"); @@ -3192,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(); @@ -3355,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(); @@ -3641,189 +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())), - ); }); - -it.effect( - "launches inline shell commands directly and streams ordered output, input and exit", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - subprocessInspector: () => - Effect.succeed({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), - }); - const exited = yield* Deferred.make(); - const received: TerminalEvent[] = []; - const unsubscribe = yield* manager.subscribe((event) => - Effect.gen(function* () { - received.push(event); - if (event.type === "exited") yield* Deferred.succeed(exited, undefined); - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - const metadata: TerminalMetadataStreamEvent[] = []; - const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => - Effect.sync(() => { - metadata.push(event); - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); - yield* manager.openCommand({ - ...openInput(), - terminalId: "inline-run", - command: { command: "read answer; printf '%s' \"$answer\"; exit 7", language: "bash" }, - }); - expect(ptyAdapter.spawnInputs[0]).toMatchObject({ - shell: "bash", - args: ["-c", "read answer; printf '%s' \"$answer\"; exit 7"], - }); - expect( - metadata.some( - (event) => - event.type === "upsert" && - event.terminal.terminalId === "inline-run" && - event.terminal.inline === true, - ), - ).toBe(true); - yield* manager.open({ ...openInput(), terminalId: "normal-drawer" }); - expect( - metadata.some( - (event) => - event.type === "upsert" && - event.terminal.terminalId === "normal-drawer" && - event.terminal.inline === undefined, - ), - ).toBe(true); - const process = ptyAdapter.processes[0]!; - yield* manager.closeIdle({ threadId: "thread-1" }); - expect(process.killed).toBe(false); - yield* manager.write({ threadId: "thread-1", terminalId: "inline-run", data: "yes\r" }); - yield* manager.write({ threadId: "thread-1", terminalId: "inline-run", data: "\u0003" }); - expect(process.writes).toEqual(["yes\r", "\u0003"]); - process.emitData("answer: yes\r\n"); - process.emitExit({ exitCode: 7, signal: null }); - yield* Deferred.await(exited); - expect( - received - .filter((event) => event.type === "output" || event.type === "exited") - .map((event) => event.type), - ).toEqual(["output", "exited"]); - expect(received.at(-1)).toMatchObject({ type: "exited", exitCode: 7 }); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.merge( - NodeServices.layer, - ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), - ), - ), - ), -); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4064e755b15bb..c9deea9b4d468 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -669,10 +669,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 79f73c2e9060c..66704396e9e76 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 e9b1fe5d4b91c..047c24ae8fc3d 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 b412f3c8502a7..be538491877c2 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/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index 54e1203030ad8..d0f1fafc69687 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 4d898a1bb9d90..0000000000000 --- 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 a9648abf67155..0000000000000 --- 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 454c683896d2b..395a24eeedc8d 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 d6dd27e7d6e8e..7a12b47b2f9e0 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 29ae74e97c045..b57ed29988c76 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 86960fa5d6444..b96be4b88f7c1 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -133,93 +133,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 82c3bd165b198..775d2542d67ec 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 2e97f3ea495cf..0000000000000 --- 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 bf4323a195e9d..ccb4cbfde9fce 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 8821a45a6b281..b8976fca81e7d 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 c517bd6739501..0fd7e4f6b8038 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, 8])( "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts and the old file", (version) => Effect.gen(function* () { @@ -2074,7 +1903,10 @@ describe("UsageService", () => { timestamp: "2026-08-01T10:00:00Z", payload: { type: "token_count", - info: { last_token_usage: { input_tokens: 0, output_tokens: outputTokens } }, + info: { + last_token_usage: { input_tokens: 0, output_tokens: outputTokens }, + total_token_usage: { input_tokens: 100, output_tokens: outputTokens + 30 }, + }, }, }, ] @@ -2100,13 +1932,14 @@ describe("UsageService", () => { yield* first.awaitPersisted; const legacy = yield* Effect.promise(async () => { + const livePath = await NodeFSP.realpath(live); const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { - files: Record }>; + files: Record }>; }; for (const [path, file] of Object.entries(document.files)) { file.r = file.r.map((row) => [ ...row.slice(0, 6), - path === live ? 99 : row[6], + path === livePath ? 99 : row[6], ...row.slice(7, 10), version === 4 ? 0 : 1, ]); @@ -2115,6 +1948,11 @@ describe("UsageService", () => { delete file.cs.lastCumulativeUsage; delete file.cs.malformedRecords; } + if (version === 8 && path === livePath) { + file.r = []; + file.mr = 1; + file.cs.malformedRecords = 1; + } } const text = encodeUnknownJsonString({ ...document, version }); await NodeFSP.rm(cachePath); @@ -2142,7 +1980,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-v9.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, @@ -2338,48 +2176,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 +2313,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 +2372,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 6e1c2e0aef5da..763b81e3a4a8b 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", () => { @@ -414,21 +380,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 b89c1fe702234..08b3f3ae1b8c8 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 58a6046d39a8f..22eec143d8c79 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,17 +162,18 @@ describe("scan cache round trip", () => { expect(restored).toEqual(original); }); - it.each([5, 6])( + it.each([5, 6, 7, 8])( "retains v%s history and compatible positions but invalidates obsolete state", (version) => { const original = cacheWith([["/gone.jsonl", 100, [record({ speed: "fast" })]]]); original.set("/gone-codex.jsonl", { ...original.get("/gone.jsonl")!, provider: "codex", + malformedRecords: 1, 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", @@ -193,6 +192,11 @@ describe("scan cache round trip", () => { malformedRecords: 3, position: position({ state: { sessionId: "session-a" } }), }); + const cleanCodex = { + ...original.get("/gone-codex.jsonl")!, + malformedRecords: 0, + }; + original.set("/clean-codex.jsonl", cleanCodex); const encoded = encodeScanCache(original); const entry = encoded.files["/gone.jsonl"]!; const legacy = { @@ -221,8 +225,17 @@ describe("scan cache round trip", () => { size: -1, position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, }); + expect(restored.get("/clean-codex.jsonl")).toEqual( + version >= 8 + ? cleanCodex + : { + ...cleanCodex, + size: -1, + 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 +246,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 +267,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 +286,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 +305,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 8ae4068f2a00b..3e9f3a92596aa 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 = 9 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +17,10 @@ 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-v9.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v8.json", + "usage-scan-cache-v7.json", "usage-scan-cache-v6.json", "usage-scan-cache-v5.json", "usage-scan-cache.json", @@ -310,9 +312,11 @@ export function decodeScanCache( continue; } // Keep history for deleted rollouts, but re-parse incompatible reducer state. - // Only Codex requires new provenance state in v7. + // v9 repairs skipped initial Codex requests; clean v8 entries can still resume. const legacy = - format.state !== undefined && (version < 6 || (entry.p === "codex" && version < 7)); + format.state !== undefined && + (version < 6 || + (entry.p === "codex" && (version < 8 || (version < 9 && malformedRecords > 0)))); // 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 79014bfc222dd..f965999fb3da8 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -221,6 +221,65 @@ describe("readTranscriptRecords resume", () => { assert.strictEqual(second.tailRecords.length, 0); }); + it.each([ + { mode: "buffered", streamingThresholdBytes: Infinity }, + { mode: "streaming", streamingThresholdBytes: 0 }, + ])( + "resumes an initial Codex carried-total tail without rebilling it ($mode)", + async (options) => { + const path = NodePath.join(dir, "continuation.jsonl"); + const usage = (input: number, output: number) => ({ + input_tokens: input, + output_tokens: output, + }); + const event = (last: unknown, cumulative: unknown) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T10:00:05Z", + payload: { + type: "token_count", + info: { last_token_usage: last, total_token_usage: cumulative }, + }, + }); + const firstLine = event(usage(100, 20), usage(500, 60)); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-6-astra") + codexTierLine("ultrafast") + firstLine, + ); + const first = await readTranscriptRecords(path, TEST_FORMATS.codex, undefined, options); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 0); + assert.deepStrictEqual( + first.tailRecords.map((r) => [r.totals.outputTokens, r.speed]), + [[20, "ultrafast"]], + ); + assert.strictEqual(first.malformedRecords, 0); + + await NodeFSP.appendFile(path, `\n${firstLine}\n${event(usage(10, 2), usage(510, 62))}\n`); + const resumed = await readTranscriptRecords( + path, + TEST_FORMATS.codex, + JSON.parse(JSON.stringify(first.position)), + options, + ); + const full = await readTranscriptRecords(path, TEST_FORMATS.codex, undefined, options); + assert.isNotNull(resumed); + assert.isNotNull(full); + assert.isTrue(resumed.resumed); + assert.deepStrictEqual(resumed.records, full.records); + assert.deepStrictEqual( + resumed.records.map((r) => [r.totals.outputTokens, r.speed]), + [ + [20, "ultrafast"], + [2, "ultrafast"], + ], + ); + assert.deepStrictEqual(resumed.tailRecords, []); + assert.strictEqual(resumed.malformedRecords, 0); + assert.deepStrictEqual(resumed.position, full.position); + }, + ); + it("re-parses from the start when the guard bytes no longer match", async () => { const path = NodePath.join(dir, "claude.jsonl"); await NodeFSP.writeFile(path, claudeLine(1, 5)); @@ -297,12 +356,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 +365,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 d8dfaa27b626c..3a36389ffb704 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.fork.test.ts b/apps/server/src/vcs/GitVcsDriverCore.fork.test.ts new file mode 100644 index 0000000000000..8ce1f9cc6e2a3 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.fork.test.ts @@ -0,0 +1,276 @@ +// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. + +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { assert, it, describe } 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 PlatformError from "effect/PlatformError"; + +import * as Scope from "effect/Scope"; + +import { GitCommandError, PROJECT_LOCAL_WORKTREES } from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; + +import * as GitVcsDriver from "./GitVcsDriver.ts"; + +const layerServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-driver-test-", +}); +const layerTest = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge(NodeServices.layer), +); + +const makeTmpDir = ( + prefix = "git-vcs-driver-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + cwd: string, + relativePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const filePath = pathService.join(cwd, relativePath); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, + env?: NodeJS.ProcessEnv, +): Effect.Effect => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + { readonly initialBranch: string }, + GitCommandError | PlatformError.PlatformError, + GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = yield* git(cwd, ["branch", "--show-current"]); + return { initialBranch }; + }); + +it.layer(layerTest)("GitVcsDriver core integration", (it) => { + describe("resolveDefaultBranchRef", () => { + it.effect.each(["main", "master"])( + "resolves local %s without a remote HEAD from a feature branch", + (branch) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", branch]); + yield* git(cwd, ["remote", "add", "origin", cwd]); + yield* git(cwd, ["checkout", "-b", "worker"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const ref = yield* driver.resolveDefaultBranchRef(cwd); + + assert.equal(ref, branch); + assert.equal( + yield* git(cwd, ["rev-parse", `${ref}^{commit}`]), + yield* git(cwd, ["rev-parse", branch]), + ); + }), + ); + + it.effect.each([ + { remote: "origin", branch: "main", remoteHead: false }, + { remote: "origin", branch: "master", remoteHead: false }, + { remote: "origin", branch: "trunk", remoteHead: true }, + { remote: "upstream", branch: "trunk", remoteHead: true }, + ])("resolves a remote-only default: %j", ({ remote, branch, remoteHead }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "worker"]); + yield* git(cwd, ["remote", "add", remote, cwd]); + yield* git(cwd, ["update-ref", `refs/remotes/${remote}/${branch}`, "HEAD"]); + if (remoteHead) { + yield* git(cwd, [ + "symbolic-ref", + `refs/remotes/${remote}/HEAD`, + `refs/remotes/${remote}/${branch}`, + ]); + } + const driver = yield* GitVcsDriver.GitVcsDriver; + + const ref = yield* driver.resolveDefaultBranchRef(cwd); + + assert.equal(ref, `${remote}/${branch}`); + assert.equal( + yield* git(cwd, ["rev-parse", `${ref}^{commit}`]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + }), + ); + + it.effect("prefers the local default over its remote copy and main/master fallbacks", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["branch", "trunk"]); + yield* git(cwd, ["checkout", "-b", "worker"]); + yield* git(cwd, ["remote", "add", "origin", cwd]); + yield* git(cwd, ["update-ref", "refs/remotes/origin/trunk", "HEAD"]); + yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + assert.equal(yield* driver.resolveDefaultBranchRef(cwd), "trunk"); + }), + ); + + it.effect("falls back to local master when remote HEAD points to a missing default", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "master"]); + yield* git(cwd, ["checkout", "-b", "worker"]); + yield* git(cwd, ["remote", "add", "origin", cwd]); + yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + assert.equal(yield* driver.resolveDefaultBranchRef(cwd), "master"); + }), + ); + + it.effect("fails instead of using the current feature branch or a main tag", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "worker"]); + yield* git(cwd, ["tag", "main"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver.resolveDefaultBranchRef(cwd).pipe(Effect.flip); + + assert.equal(error._tag, "GitCommandError"); + assert.equal(error.operation, "GitVcsDriver.resolveDefaultBranchRef"); + }), + ); + }); + + describe("worktree operations", () => { + it.effect( + "creates project-local worktrees from subfolders and linked checkouts and excludes them once", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const subfolder = path.join(cwd, "finance"); + yield* fs.makeDirectory(subfolder); + const exclude = path.join(cwd, ".git", "info", "exclude"); + yield* fs.writeFileString(exclude, "# preserve existing rules\nlocal.txt"); + const create = (cwd: string, branch: string) => + driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: branch }, + { worktreesDirectory: PROJECT_LOCAL_WORKTREES }, + ); + const first = yield* create(subfolder, "feature/finance"); + assert.equal(first.worktree.path, path.join(cwd, ".worktrees", "feature-finance")); + const second = yield* create(first.worktree.path, "feature/child"); + assert.equal(second.worktree.path, path.join(cwd, ".worktrees", "feature-child")); + assert.equal( + yield* fs.readFileString(exclude), + "# preserve existing rules\nlocal.txt\n.worktrees/\n", + ); + assert.equal(yield* fs.readFileString(path.join(cwd, ".worktrees", ".ignore")), "/*\n"); + assert.equal(yield* fs.exists(path.join(cwd, ".gitignore")), false); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), ""); + }), + ); + + it.effect("suffixes a unique worktree name past taken branches, folders and namespaces", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const local = path.join(cwd, ".worktrees"); + const create = (newRefName: string, directoryName?: string) => + driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName }, + { + worktreesDirectory: PROJECT_LOCAL_WORKTREES, + uniqueName: true, + ...(directoryName ? { directoryName } : {}), + }, + ); + const refNames = (results: ReadonlyArray<{ worktree: { refName: string } }>) => + results.map((result) => result.worktree.refName).toSorted(); + + // A taken branch, a stray folder, and a ref namespace each move to the next suffix. + yield* git(cwd, ["branch", "issue-7-fix"]); + yield* fs.makeDirectory(path.join(local, "issue-7-fix-2"), { recursive: true }); + yield* fs.writeFileString(path.join(local, "issue-7-fix-2", "keep"), ""); + const created = yield* create("issue-7-fix"); + assert.equal(created.worktree.refName, "issue-7-fix-3"); + assert.equal(created.worktree.path, path.join(local, "issue-7-fix-3")); + yield* git(cwd, ["branch", "parser/old"]); + assert.equal((yield* create("parser")).worktree.refName, "parser-2"); + + // Concurrent requests for one name all succeed under distinct suffixes. + const same = yield* Effect.all( + Array.from({ length: 4 }, () => create("same-title")), + { concurrency: "unbounded" }, + ); + assert.deepEqual(refNames(same), [ + "same-title", + "same-title-2", + "same-title-3", + "same-title-4", + ]); + + // Temporary branches sharing a folder name keep their branches; only folders move. + const temporary = yield* Effect.all( + [create("t3/aaaaaaaa", "new-thread"), create("t3/bbbbbbbb", "new-thread")], + { concurrency: "unbounded" }, + ); + assert.deepEqual(refNames(temporary), ["t3/aaaaaaaa", "t3/bbbbbbbb"]); + assert.deepEqual( + temporary.map((result) => path.basename(result.worktree.path)).toSorted(), + ["new-thread", "new-thread-2"], + ); + }), + ); + }); +}); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c1916de4204c4..84861d6dd4ae0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2,30 +2,24 @@ 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 { GitCommandError, - PROJECT_LOCAL_WORKTREES, ReviewDiffPreviewInput, VcsRef, VcsListRefsResult, @@ -35,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, @@ -44,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); @@ -88,6 +80,13 @@ const makeSuccessfulHandle = (stdout: string) => getOutputFd: () => Stream.empty, }); +const makeFailedHandle = (stderr: string) => + ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + stderr: Stream.encodeText(Stream.make(stderr)), + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -159,112 +158,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) => @@ -308,46 +201,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; @@ -366,387 +219,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 = ( @@ -908,119 +381,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) => @@ -1153,42 +513,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* () { @@ -1333,132 +657,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", @@ -1612,154 +810,90 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => }).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("resolveDefaultBranchRef", () => { - it.effect.each(["main", "master"])( - "resolves local %s without a remote HEAD from a feature branch", - (branch) => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "-M", branch]); - yield* git(cwd, ["remote", "add", "origin", cwd]); - yield* git(cwd, ["checkout", "-b", "worker"]); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const ref = yield* driver.resolveDefaultBranchRef(cwd); - - assert.equal(ref, branch); - assert.equal( - yield* git(cwd, ["rev-parse", `${ref}^{commit}`]), - yield* git(cwd, ["rev-parse", branch]), - ); - }), - ); - - it.effect.each([ - { remote: "origin", branch: "main", remoteHead: false }, - { remote: "origin", branch: "master", remoteHead: false }, - { remote: "origin", branch: "trunk", remoteHead: true }, - { remote: "upstream", branch: "trunk", remoteHead: true }, - ])("resolves a remote-only default: %j", ({ remote, branch, remoteHead }) => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "-M", "worker"]); - yield* git(cwd, ["remote", "add", remote, cwd]); - yield* git(cwd, ["update-ref", `refs/remotes/${remote}/${branch}`, "HEAD"]); - if (remoteHead) { - yield* git(cwd, [ - "symbolic-ref", - `refs/remotes/${remote}/HEAD`, - `refs/remotes/${remote}/${branch}`, - ]); - } - const driver = yield* GitVcsDriver.GitVcsDriver; - - const ref = yield* driver.resolveDefaultBranchRef(cwd); - - assert.equal(ref, `${remote}/${branch}`); - assert.equal( - yield* git(cwd, ["rev-parse", `${ref}^{commit}`]), - yield* git(cwd, ["rev-parse", "HEAD"]), - ); - }), - ); - - it.effect("prefers the local default over its remote copy and main/master fallbacks", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "-M", "main"]); - yield* git(cwd, ["branch", "trunk"]); - yield* git(cwd, ["checkout", "-b", "worker"]); - yield* git(cwd, ["remote", "add", "origin", cwd]); - yield* git(cwd, ["update-ref", "refs/remotes/origin/trunk", "HEAD"]); - yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"]); - const driver = yield* GitVcsDriver.GitVcsDriver; - - assert.equal(yield* driver.resolveDefaultBranchRef(cwd), "trunk"); - }), - ); - - it.effect("falls back to local master when remote HEAD points to a missing default", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "-M", "master"]); - yield* git(cwd, ["checkout", "-b", "worker"]); - yield* git(cwd, ["remote", "add", "origin", cwd]); - yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"]); - const driver = yield* GitVcsDriver.GitVcsDriver; - - assert.equal(yield* driver.resolveDefaultBranchRef(cwd), "master"); - }), +describe("rejected git refs", () => { + const remoteUrl = "https://user:token-123@git.example.invalid/org/secret-repo.git"; + const failedPush = `error: failed to push some refs to '${remoteUrl}'`; + const leakedText = [ + "token-123", + "secret-repo", + "git.example.invalid", + "private-branch", + "workflow scope", + ]; + + const failWith = (stderr: string) => { + const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeFailedHandle(stderr))); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge( + Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), ); + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + return yield* driver + .execute({ + operation: "GitVcsDriver.test.rejectedRef", + cwd: "/repo", + args: ["push", "origin", "HEAD"], + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(layer)); + }; + + it.effect.each([ + { + label: "a ref the remote refused", + stderr: [ + "remote: Unable to determine if workflow can be created; workflow scope may be required.", + `To ${remoteUrl}`, + " ! [remote rejected] private-branch -> private-branch (workflow scope may be required.)", + failedPush, + ], + reason: "push_rejected_by_remote" as const, + detail: "The remote rejected the push.", + }, + { + label: "a non-fast-forward push", + stderr: [ + `To ${remoteUrl}`, + " ! [rejected] private-branch -> private-branch (non-fast-forward)", + failedPush, + ], + reason: "push_rejected_non_fast_forward" as const, + detail: "Pull or rebase", + }, + ])("names $label without quoting stderr", ({ stderr, reason, detail }) => + Effect.gen(function* () { + const error = yield* failWith(stderr.join("\n")); - it.effect("fails instead of using the current feature branch or a main tag", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "-M", "worker"]); - yield* git(cwd, ["tag", "main"]); - const driver = yield* GitVcsDriver.GitVcsDriver; + assert.deepInclude(error, { reason }); + assert.include(error.detail, detail); + for (const text of leakedText) assert.notInclude(error.message, text); + }), + ); - const error = yield* driver.resolveDefaultBranchRef(cwd).pipe(Effect.flip); + // A fetch into a diverged local branch prints the same rejected line, but + // never "failed to push some refs". + it.effect("leaves a rejected fetch unclassified", () => + Effect.gen(function* () { + const error = yield* failWith( + [`From ${remoteUrl}`, " ! [rejected] main -> side (non-fast-forward)"].join( + "\n", + ), + ); - assert.equal(error._tag, "GitCommandError"); - assert.equal(error.operation, "GitVcsDriver.resolveDefaultBranchRef"); - }), - ); - }); + assert.equal(error.reason, undefined); + assert.equal(error.detail, "Git command exited with a non-zero status."); + }), + ); +}); +it.layer(layerTest)("GitVcsDriver core integration", (it) => { 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); - }), - ); - it.effect("does not retain git arguments or stderr in command failures", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1792,6 +926,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(); @@ -1823,6 +958,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(); @@ -1830,49 +966,19 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const error = yield* driver .execute({ - operation: "GitVcsDriver.test.notARepository", - cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], - env: { LC_ALL: "C" }, - }) - .pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.test.notARepository", - reason: "not_a_repository", - }); - assert.include(error.message, "(not_a_repository)"); - }), - ); - - 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({ + operation: "GitVcsDriver.test.notARepository", cwd, - refName: "taken-branch", - path: pathService.join(parent, "second"), + args: ["rev-parse", "--abbrev-ref", "HEAD"], + env: { LC_ALL: "C" }, }) .pipe(Effect.flip); - assert.equal(error.detail, "git worktree add failed"); - assert.include(error.message, "git worktree add failed (branch_checked_out_in_worktree)"); + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.notARepository", + reason: "not_a_repository", + }); + assert.include(error.message, "(not_a_repository)"); }), ); @@ -2054,7 +1160,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("does not read remote hook output as a git failure reason", () => + it.effect("reads a hook-declined push from git's rejection line, not the hook's output", () => Effect.gen(function* () { const parent = yield* makeTmpDir(); const pathService = yield* Path.Path; @@ -2083,40 +1189,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }) .pipe(Effect.flip); - assert.notProperty(error, "reason"); - assert.notInclude(error.message, "(authentication_failed)"); - }), - ); - - 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.deepInclude(error, { reason: "push_rejected_by_remote" }); assert.notInclude(error.message, "(authentication_failed)"); }), ); @@ -2190,31 +1263,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", () => { @@ -2340,60 +1388,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(); @@ -2558,21 +1552,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) => @@ -2828,32 +1807,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(); @@ -3184,28 +2137,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(); @@ -3258,6 +2189,66 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reads Changes totals without minimizing and keeps review diffs minimal", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "diff.algorithm", "myers"]); + // Repeated lines make the default and minimal algorithms choose different edits. + const lines = Array.from({ length: 2000 }, (_, index) => index); + yield* writeTextFile( + cwd, + "repeated.txt", + lines.map((index) => `${(index ^ (index >> 3)) % 16}\n`).join(""), + ); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "repeated lines"]); + yield* git(cwd, ["checkout", "-b", "feature/totals"]); + yield* writeTextFile( + cwd, + "repeated.txt", + lines.map((index) => `${(index ^ (index >> 5)) % 16}\n`).join(""), + ); + const defaultStat = yield* git(cwd, ["diff", "--numstat", "HEAD", "--"]); + const minimalStat = yield* git(cwd, ["diff", "--minimal", "--numstat", "HEAD", "--"]); + assert.notEqual(defaultStat, minimalStat); + const [insertions, deletions] = defaultStat.split("\t"); + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: initialBranch, + insertions: Number(insertions), + deletions: Number(deletions), + }); + + const [additions, removals] = minimalStat.split("\t"); + const minimalPatch = yield* git(cwd, [ + "diff", + "--minimal", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "HEAD", + "--", + ]); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + for (const source of preview.sources) { + assert.deepStrictEqual(source.files, [ + { + path: "repeated.txt", + previousPath: null, + additions: Number(additions), + deletions: Number(removals), + }, + ]); + assert.strictEqual(source.diff.trim(), minimalPatch); + } + }), + ); + it.effect.skipIf(!symlinksSupported)( "lists the real repository through a symlink when a decoy repo occupies the lexical common dir", () => @@ -3382,35 +2373,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(); @@ -3496,202 +2458,53 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - 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), - ); - - 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 }, - ]); - - 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, - }); - } + 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; - 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, - ); - } + 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 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); }), ); @@ -4024,32 +2837,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(); @@ -4076,23 +2863,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(); @@ -4184,76 +2954,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", () => { @@ -4532,93 +3232,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect( - "creates project-local worktrees from subfolders and linked checkouts and excludes them once", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - const subfolder = path.join(cwd, "finance"); - yield* fs.makeDirectory(subfolder); - const exclude = path.join(cwd, ".git", "info", "exclude"); - yield* fs.writeFileString(exclude, "# preserve existing rules\nlocal.txt"); - const create = (cwd: string, branch: string) => - driver.createWorktree( - { cwd, path: null, refName: initialBranch, newRefName: branch }, - { worktreesDirectory: PROJECT_LOCAL_WORKTREES }, - ); - const first = yield* create(subfolder, "feature/finance"); - assert.equal(first.worktree.path, path.join(cwd, ".worktrees", "feature-finance")); - const second = yield* create(first.worktree.path, "feature/child"); - assert.equal(second.worktree.path, path.join(cwd, ".worktrees", "feature-child")); - assert.equal( - yield* fs.readFileString(exclude), - "# preserve existing rules\nlocal.txt\n.worktrees/\n", - ); - assert.equal(yield* fs.readFileString(path.join(cwd, ".worktrees", ".ignore")), "/*\n"); - assert.equal(yield* fs.exists(path.join(cwd, ".gitignore")), false); - assert.equal(yield* git(cwd, ["status", "--porcelain"]), ""); - }), - ); - - it.effect("suffixes a unique worktree name past taken branches, folders and namespaces", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - const local = path.join(cwd, ".worktrees"); - const create = (newRefName: string, directoryName?: string) => - driver.createWorktree( - { cwd, path: null, refName: initialBranch, newRefName }, - { - worktreesDirectory: PROJECT_LOCAL_WORKTREES, - uniqueName: true, - ...(directoryName ? { directoryName } : {}), - }, - ); - const refNames = (results: ReadonlyArray<{ worktree: { refName: string } }>) => - results.map((result) => result.worktree.refName).toSorted(); - - // A taken branch, a stray folder, and a ref namespace each move to the next suffix. - yield* git(cwd, ["branch", "issue-7-fix"]); - yield* fs.makeDirectory(path.join(local, "issue-7-fix-2"), { recursive: true }); - yield* fs.writeFileString(path.join(local, "issue-7-fix-2", "keep"), ""); - const created = yield* create("issue-7-fix"); - assert.equal(created.worktree.refName, "issue-7-fix-3"); - assert.equal(created.worktree.path, path.join(local, "issue-7-fix-3")); - yield* git(cwd, ["branch", "parser/old"]); - assert.equal((yield* create("parser")).worktree.refName, "parser-2"); - - // Concurrent requests for one name all succeed under distinct suffixes. - const same = yield* Effect.all( - Array.from({ length: 4 }, () => create("same-title")), - { concurrency: "unbounded" }, - ); - assert.deepEqual(refNames(same), [ - "same-title", - "same-title-2", - "same-title-3", - "same-title-4", - ]); - - // Temporary branches sharing a folder name keep their branches; only folders move. - const temporary = yield* Effect.all( - [create("t3/aaaaaaaa", "new-thread"), create("t3/bbbbbbbb", "new-thread")], - { concurrency: "unbounded" }, - ); - assert.deepEqual(refNames(temporary), ["t3/aaaaaaaa", "t3/bbbbbbbb"]); - assert.deepEqual( - temporary.map((result) => path.basename(result.worktree.path)).toSorted(), - ["new-thread", "new-thread-2"], - ); - }), - ); - it.effect("creates worktrees under the configured worktrees directory", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -4786,84 +3399,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) => @@ -4970,67 +3505,70 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("allows worktree removal to run longer than the default command timeout", () => + it.effect("force-removes a worktree without deleting what its junctions point to", () => 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); + yield* writeTextFile(cwd, ".gitignore", "node_modules\n"); + yield* git(cwd, ["add", ".gitignore"]); + yield* git(cwd, ["commit", "-m", "ignore node_modules"]); const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "slow-removal"); - + const outside = yield* makeTmpDir("git-worktree-link-target-"); + yield* writeTextFile(outside, "keep.txt", "keep\n"); + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked"); + const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.createWorktree({ cwd, path: worktreePath, refName: initialBranch, - newRefName: "feature/slow-removal", + newRefName: "feature/linked", }); + // Junctions on Windows, the way pnpm links packages; symlinks elsewhere. + NodeFS.mkdirSync(pathService.join(worktreePath, "node_modules")); + NodeFS.symlinkSync( + outside, + pathService.join(worktreePath, "node_modules", "dep"), + "junction", + ); + NodeFS.symlinkSync(outside, pathService.join(worktreePath, "shared"), "junction"); - 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); + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); - assert.equal(yield* fileSystem.exists(worktreePath), false); + assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), []); + assert.equal( + yield* fileSystem.readFileString(pathService.join(outside, "keep.txt")), + "keep\n", + ); }), ); - it.effect("removes the same worktree path twice without failing", () => + it.effect("keeps the junctions of a worktree that git refuses to remove", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, ".gitignore", "node_modules\n"); + yield* git(cwd, ["add", ".gitignore"]); + yield* git(cwd, ["commit", "-m", "ignore node_modules"]); const pathService = yield* Path.Path; - const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared"); + const outside = yield* makeTmpDir("git-worktree-link-target-"); + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked"); const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.createWorktree({ cwd, path: worktreePath, refName: initialBranch, - newRefName: "feature/shared", + newRefName: "feature/linked", }); + const link = pathService.join(worktreePath, "node_modules"); + NodeFS.symlinkSync(outside, link, "junction"); + yield* writeTextFile(worktreePath, "notes.txt", "draft\n"); - yield* driver.removeWorktree({ cwd, path: worktreePath }); - yield* driver.removeWorktree({ cwd, path: worktreePath }); + const result = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath })); + + assert.isTrue(Result.isFailure(result)); + assert.isTrue(NodeFS.lstatSync(link).isSymbolicLink()); + assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), ["linked"]); }), ); @@ -5088,6 +3626,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(); @@ -5340,65 +3879,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(); @@ -5726,43 +4206,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", () => @@ -5999,21 +4442,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" }), @@ -6060,6 +4488,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/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e1eae1bfc08e9..a0731f7aec299 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -45,6 +45,8 @@ import { resolveWorktreePath } from "../worktreesDirectory.ts"; import { excludeProjectWorktrees } from "../projectLocalWorktrees.ts"; import { removeEmptyDirectory } from "./removeEmptyDirectory.ts"; import { explicitBareRepositoryEnv, gitConfigEnv } from "./VcsProcess.ts"; +import { removeLinkedWorktree } from "./worktreeRemoval.ts"; +import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; import { parseRemoteNames, parseRemoteNamesInGitOrder, @@ -81,7 +83,6 @@ const REVIEW_DIFF_ARGS = [ "--no-color", "--no-ext-diff", "--no-textconv", - "--minimal", ...PATCH_RENDER_PREFIX_ARGS, ]; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); @@ -463,8 +464,32 @@ const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray> = { + push_rejected_by_remote: + "The remote rejected the push. Push this branch from a terminal to see the server's message.", + push_rejected_non_fast_forward: + "The remote has commits this branch doesn't have. Pull or rebase, then push again.", +}; + +function gitFailureDetail(reason: GitCommandFailureReason | null): string { + return ( + (reason === null ? undefined : GIT_FAILURE_REASON_DETAILS[reason]) ?? + "Git command exited with a non-zero status." + ); +} + // Existing non-commit objects share the missing-ref fatal diagnostic; Git // distinguishes them with a separate error line. const GIT_NOT_A_COMMIT_PATTERN = /^error: .+(?:, not a commit|expected commit type)/im; @@ -1038,7 +1063,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* new GitCommandError({ ...gitCommandContext(commandInput), ...(reason === null ? {} : { reason }), - detail: "Git command exited with a non-zero status.", + detail: gitFailureDetail(reason), exitCode, stdoutLength: stdout.text.length, stderrLength: stderr.text.length, @@ -1130,7 +1155,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* new GitCommandError({ ...gitCommandContext({ operation, cwd, args }), ...(reason === null ? {} : { reason }), - detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + detail: options.fallbackErrorDetail ?? gitFailureDetail(reason), ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, stderrLength: result.stderr.length, @@ -3124,6 +3149,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const diffArgs = [ ...REVIEW_DIFF_ARGS, + "--minimal", ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), ]; const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( @@ -4318,25 +4344,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { - // Git also accepts a worktree's short name, which can match a different - // folder than the path resolved against `input.cwd`. Resolve it once so Git - // and the leftover cleanup below act on the same directory. + // Git also matches a worktree by its short name, which can name a different + // folder than this path. Resolving it once keeps Git, the Windows deletion + // in removeLinkedWorktree, and the leftover cleanup below on one directory. const target = path.resolve(input.cwd, input.path); - // Git refuses to remove a worktree with untracked files unless forced, but - // its check honors `status.showUntrackedFiles=no` and would delete them. - const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push("--", target); - const result = yield* executeGitWithStableDiagnostics( - "GitVcsDriver.removeWorktree", - input.cwd, - args, - { - timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, - allowNonZeroExit: true, - }, + const result = yield* removeLinkedWorktree({ + operation: "GitVcsDriver.removeWorktree", + cwd: input.cwd, + worktreePath: target, + force: input.force === true, + execute, + run: (args) => + executeGitWithStableDiagnostics("GitVcsDriver.removeWorktree", input.cwd, args, { + timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + allowNonZeroExit: true, + }).pipe(Effect.map((result) => ({ ...result, args }))), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); if (result.exitCode === 0) { // Git for Windows never descends into NTFS junctions, such as pnpm's @@ -4368,7 +4393,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // than an error. const alreadyGone = isMissingWorktreeStderr(result.stderr) && - !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); + !(yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))); if (alreadyGone) { yield* pruneWorktrees({ cwd: input.cwd }); return; @@ -4377,7 +4402,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, ); return yield* new GitCommandError({ - ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), + ...gitCommandContext({ + operation: "GitVcsDriver.removeWorktree", + cwd: input.cwd, + args: result.args, + }), detail: "git worktree remove failed", ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 1e1ed5254dec1..e5c8169774830 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 6617b15691d2f..c781bd5c6da8f 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 1c91cbadea8cb..65e4068db3618 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 ea03ed8a41ab3..0000000000000 --- 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 22ad05d20acc0..334c0d4747bce 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 cfa253aafa31b..41859b9dd9072 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/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts new file mode 100644 index 0000000000000..c8feb71c3d845 --- /dev/null +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -0,0 +1,164 @@ +import { GitCommandError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import type * as GitVcsDriver from "./GitVcsDriver.ts"; + +const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; + +/** + * Removes the linked worktree at `worktreePath` by running `git worktree + * remove` through `run`. + * + * Git for Windows before 2.54 follows NTFS junctions while it deletes a + * worktree, and deletes what they point to, such as a shared node_modules. + * Later versions skip junctions but leave them, and the folders holding them, + * behind. On Windows, Git therefore never deletes the files: this makes the + * checks `git worktree remove` makes before deleting (builtin/worktree.c), + * deletes the folder with Node's `rm`, which unlinks junctions without + * following them, and then has Git drop the now-missing worktree's metadata. + * Git elsewhere only unlinks links, so other hosts run Git as before. + */ +export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* < + A, + E, + R, +>(input: { + readonly operation: string; + readonly cwd: string; + readonly worktreePath: string; + readonly force: boolean; + readonly execute: GitVcsDriver.GitVcsDriver["Service"]["execute"]; + readonly run: (args: ReadonlyArray) => Effect.Effect; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreePath = input.worktreePath; + const gitFile = path.join(worktreePath, ".git"); + // Git refuses to remove a worktree with untracked files unless forced, but + // its check honors `status.showUntrackedFiles=no` and would delete them. + const statusConfig = ["-c", "status.showUntrackedFiles=normal"]; + const linked = + (yield* HostProcess.isWindows) && + (yield* fileSystem.stat(gitFile).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + )); + if (!linked) { + return yield* input.run([ + ...statusConfig, + "worktree", + "remove", + ...(input.force ? ["--force"] : []), + "--", + worktreePath, + ]); + } + + // Git's own refusals, worded the way it prints them. + const refuse = (message: string, cause?: unknown) => + new GitCommandError({ + operation: input.operation, + command: "git worktree remove", + cwd: input.cwd, + detail: `fatal: ${message}`, + ...(cause === undefined ? {} : { cause }), + }); + const gitOutput = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + input + .execute({ + operation: input.operation, + cwd, + args, + maxOutputBytes: INDEX_LIST_MAX_BYTES, + ...(env === undefined ? {} : { env }), + }) + .pipe(Effect.map((result) => result.stdout)); + const samePath = (left: string, right: string) => + Effect.all([fileSystem.realPath(left), fileSystem.realPath(right)]).pipe( + Effect.map(([a, b]) => a.toLowerCase() === b.toLowerCase()), + Effect.orElseSucceed(() => false), + ); + + // A registered worktree of this repository: its `.git` file names an admin + // directory under the common `worktrees/`, whose `gitdir` points back. + const notAWorktree = refuse(`'${worktreePath}' is not a working tree`); + const adminDir = path.resolve( + worktreePath, + (yield* gitOutput(worktreePath, ["rev-parse", "--absolute-git-dir"]).pipe( + Effect.mapError(() => notAWorktree), + )).trim(), + ); + const commonDir = path.resolve( + input.cwd, + (yield* gitOutput(input.cwd, ["rev-parse", "--git-common-dir"])).trim(), + ); + const pointsBack = yield* fileSystem.readFileString(path.join(adminDir, "gitdir")).pipe( + Effect.flatMap((gitdir) => samePath(path.resolve(adminDir, gitdir.trim()), gitFile)), + Effect.orElseSucceed(() => false), + ); + if (!pointsBack || !(yield* samePath(path.dirname(adminDir), path.join(commonDir, "worktrees")))) + return yield* notAWorktree; + // Git counts the file's existence as a lock. T3 passes at most one + // `--force`, which never removes a locked worktree. Anything but a + // confirmed absence refuses. + const lockFile = path.join(adminDir, "locked"); + const locked = yield* fileSystem.stat(lockFile).pipe( + Effect.as(true), + Effect.catch((error) => + error.reason._tag === "NotFound" + ? Effect.succeed(false) + : Effect.fail(refuse(`cannot inspect the lock of '${worktreePath}'`, error)), + ), + ); + if (locked) { + const reason = (yield* fileSystem.readFileString(lockFile).pipe(Effect.option)).pipe( + Option.map((text) => text.trim()), + Option.getOrElse(() => ""), + ); + return yield* refuse( + reason === "" + ? "cannot remove a locked working tree;\nuse 'remove -f -f' to override or unlock first" + : `cannot remove a locked working tree, lock reason: ${reason}\nuse 'remove -f -f' to override or unlock first`, + ); + } + if (!input.force) { + // Bound like Git's own check, so `core.worktree` can't point it elsewhere. + const boundEnv = { GIT_DIR: gitFile, GIT_WORK_TREE: worktreePath }; + const submodules = refuse("working trees containing submodules cannot be moved or removed"); + // An unreadable path counts as present, so a doubt refuses. + const present = (target: string) => + fileSystem.exists(target).pipe(Effect.orElseSucceed(() => true)); + if (yield* present(path.join(adminDir, "modules"))) return yield* submodules; + const index = yield* gitOutput(worktreePath, ["ls-files", "--stage", "-z"], boundEnv); + for (const entry of index.split("\0")) { + if (!entry.startsWith("160000 ")) continue; + const submodule = entry.slice(entry.indexOf("\t") + 1); + if (yield* present(path.join(worktreePath, submodule, ".git"))) return yield* submodules; + } + const status = yield* gitOutput( + worktreePath, + [...statusConfig, "status", "--porcelain", "--ignore-submodules=none"], + boundEnv, + ); + if (status !== "") + return yield* refuse( + `'${worktreePath}' contains modified or untracked files, use --force to delete it`, + ); + } + + // A file held open can stop the delete partway. Putting `.git` back, when + // that works, leaves a worktree that a retry can remove. + const gitFileContents = yield* fileSystem + .readFileString(gitFile) + .pipe(Effect.mapError(() => notAWorktree)); + yield* fileSystem.remove(worktreePath, { recursive: true }).pipe( + Effect.tapError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)), + Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)), + ); + // The folder is gone, so Git only drops the worktree's metadata. + return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]); +}); diff --git a/apps/server/src/workspace/WorkspaceEntries.fork.test.ts b/apps/server/src/workspace/WorkspaceEntries.fork.test.ts new file mode 100644 index 0000000000000..171ca80e1d7cb --- /dev/null +++ b/apps/server/src/workspace/WorkspaceEntries.fork.test.ts @@ -0,0 +1,116 @@ +// @effect-diagnostics nodeBuiltinImport:off + +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { it, afterEach, describe, expect } 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 PlatformError from "effect/PlatformError"; +import { vi } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readdir: vi.fn(actual.readdir) }; +}); + +const layerTest = Layer.empty.pipe( + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-entries-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { + const fileSystem = yield* FileSystem.FileSystem; + const dir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: opts?.prefix ?? "t3code-workspace-entries-", + }); + if (opts?.git) { + yield* git(dir, ["init"]); + } + return dir; +}); + +function writeTextFile( + cwd: string, + relativePath: string, + contents = "", +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fileSystem.writeFileString(absolutePath, contents); + }); +} + +const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "WorkspaceEntries.test.git", + command: "git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const searchWorkspaceEntries = (input: { + cwd: string; + query: string; + limit: number; + kind?: "file" | "directory"; +}) => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + return yield* workspaceEntries.search(input); + }); + +it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("search", () => { + it.effect("excludes project-local worktrees from listing, indexing and content search", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".git/info/exclude", ".worktrees/\n"); + yield* writeTextFile(cwd, ".worktrees/.ignore", "*\n"); + yield* writeTextFile(cwd, ".worktrees/feature/duplicate.ts", "worktree-secret"); + yield* writeTextFile(cwd, "src/keep.ts", "worktree-secret"); + const entries = yield* WorkspaceEntries.WorkspaceEntries; + const listed = yield* entries.list({ cwd, directoryPath: "" }); + expect(listed.entries.some((entry) => entry.path === ".worktrees")).toBe(false); + const searched = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + expect(searched.entries.some((entry) => entry.path.startsWith(".worktrees"))).toBe(false); + const contents = yield* entries.searchContents({ + cwd, + query: "worktree-secret", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }); + expect(contents.matches.map((match) => match.path)).toEqual(["src/keep.ts"]); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 35e039af9d6a4..150ac2c69131a 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,68 +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 project-local worktrees from listing, indexing and content search", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ git: true }); - yield* writeTextFile(cwd, ".git/info/exclude", ".worktrees/\n"); - yield* writeTextFile(cwd, ".worktrees/.ignore", "*\n"); - yield* writeTextFile(cwd, ".worktrees/feature/duplicate.ts", "worktree-secret"); - yield* writeTextFile(cwd, "src/keep.ts", "worktree-secret"); - const entries = yield* WorkspaceEntries.WorkspaceEntries; - const listed = yield* entries.list({ cwd, directoryPath: "" }); - expect(listed.entries.some((entry) => entry.path === ".worktrees")).toBe(false); - const searched = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); - expect(searched.entries.some((entry) => entry.path.startsWith(".worktrees"))).toBe(false); - const contents = yield* entries.searchContents({ - cwd, - query: "worktree-secret", - limit: 100, - caseSensitive: false, - wholeWord: false, - useRegex: false, - }); - expect(contents.matches.map((match) => match.path)).toEqual(["src/keep.ts"]); - }), - ); - it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -588,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", () => { @@ -693,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-" }); @@ -801,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-" }); @@ -872,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-" }); @@ -916,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; @@ -962,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 8bdf91580e301..9eb2ba7580490 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,14 +1,14 @@ -// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO. +// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO, and reads spy on native realpath. import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "@effect/platform-node/NodePath"; 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"; @@ -19,6 +19,12 @@ import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { vi } from "vite-plus/test"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, realpath: vi.fn(actual.realpath) }; +}); // Temp folders count as known workspaces unless named as unknown. const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; @@ -71,19 +77,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", () => @@ -139,47 +132,37 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect.skipIf(!symlinksSupported)("follows file and directory symlinks", () => + // A file chip for `file://attacker.test/s/a.ts` in an agent's message sent + // the Windows user's NTLM credentials to attacker.test as it rendered. + it.effect("never stats a network path", () => 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" }, - ]); + const statted: Array = []; + const service = yield* WorkspaceFileSystem.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fs, + stat: (requestedPath) => { + statted.push(requestedPath); + return fs.stat(requestedPath); + }, + }), + ), + Effect.provide(layerKnownWorkspacesTest), + ); + const paths = [ + "\\\\attacker.test\\s\\a.ts", + "//attacker.test/s/a.ts", + "/\\attacker.test\\s", + ]; + expect((yield* service.getMetadata({ paths })).entries).toEqual([null, null, null]); + expect(statted).toEqual([]); }), ); }); 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 +236,68 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects reads outside the workspace root", () => + it.effect("refuses a network path outside the workspace", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const cwd = yield* makeTempDir; const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "../escape.md" }) + .readFile({ cwd: "//nas.test/share/project", relativePath: "//attacker.test/s/a.md" }) .pipe(Effect.flip); - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); + + // This resolves inside the project, but Windows reads `\\?\` paths as + // written, so realpath on the raw string signed in to attacker.test. + it.effect("reads the network path it checked", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.make.pipe( + Effect.provide(Layer.merge(layerKnownWorkspacesTest, NodePath.layerWin32)), ); + // Rejects without touching the filesystem, so a Windows runner never looks the share up. + const realpath = vi + .mocked(NodeFSP.realpath) + .mockClear() + .mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + + yield* workspaceFileSystem + .readFile({ + cwd: "\\\\?\\UNC\\nas.test\\share\\project", + relativePath: "\\\\?\\UNC\\attacker.test\\s\\..\\..\\nas.test\\share\\project\\a.md", + }) + .pipe(Effect.exit); + + expect(realpath.mock.calls.map(([target]) => target)).toEqual([ + "\\\\?\\UNC\\nas.test\\share\\project\\a.md", + ]); }), ); + // Resolving `..` before realpath would read beside the link instead of its target. + // Windows collapses `..` before the filesystem sees a path, so it has no such reads. + it.effect.skipIf(!symlinksSupported || HostProcess.Platform.defaultValue() === "win32")( + "reads `..` after a link from the link's target", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir; + yield* writeTextFile(root, "target/parent.md", "beside the target\n"); + yield* writeTextFile(root, "parent.md", "beside the link\n"); + yield* fs.makeDirectory(path.join(root, "target", "child")); + yield* fs.symlink(path.join(root, "target", "child"), path.join(root, "link")); + + const result = yield* workspaceFileSystem.readFile({ + cwd: root, + relativePath: `${root}/link/../parent.md`, + }); + + expect(result.contents).toBe("beside the target\n"); + }), + ); + it.effect.skipIf(!symlinksSupported)( "rejects symlinks that resolve outside the workspace root", () => @@ -313,31 +343,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 +367,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/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index ec18f675aae03..4e7c81404256b 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -13,6 +13,7 @@ import type { } from "@t3tools/contracts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isNetworkPath } from "@t3tools/shared/path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -142,7 +143,9 @@ export const make = Effect.gen(function* () { const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) { const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory); - if (!path.isAbsolute(expandedPath)) return null; + // Chips ask as soon as an agent's message renders, and touching a network + // path makes Windows sign in to its host with the user's NTLM credentials. + if (!path.isAbsolute(expandedPath) || isNetworkPath(expandedPath)) return null; const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option); if (stat._tag === "None") return null; if (stat.value.type === "Directory") return { kind: "directory" } as const; @@ -222,7 +225,8 @@ export const make = Effect.gen(function* () { /** * Workspace-relative paths must stay inside the root, symlinks included. An * absolute or `~/` path reads a host file in place, such as a report an agent - * wrote to a temp directory; it gets no root check. + * wrote to a temp directory; it gets no root check. A network path does, since + * Windows signs in to its host with the user's NTLM credentials. */ const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( input: ProjectReadFileInput, @@ -231,15 +235,28 @@ export const make = Effect.gen(function* () { const hostPath = requestedPath.startsWith("~/") ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory) : requestedPath; + if ( + isNetworkPath(hostPath) && + !KnownWorkspaces.isWithinRoot(path, path.resolve(hostPath), path.resolve(input.cwd)) + ) { + return yield* new WorkspacePaths.WorkspacePathOutsideRootError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + } if (path.isAbsolute(hostPath)) { + // Native realpath hands Windows a `\\?\` path as written, `..` included, + // so a network path must be the resolved one the check above approved. + // Others keep POSIX `link/..` semantics, which resolving first would change. + const absolutePath = isNetworkPath(hostPath) ? path.resolve(hostPath) : hostPath; const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(hostPath), + try: () => NodeFSP.realpath(absolutePath), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: hostPath, - operationPath: hostPath, + resolvedPath: absolutePath, + operationPath: absolutePath, operation: "realpath-target", cause, }), diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index ecaf6116c67ac..574904b343563 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 2c61bbe76501e..6b748e19396ec 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 f9526112af3fd..0000000000000 --- 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 4a3e80e92ecc6..2c41e4ed0b3d0 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 9fe13cfd01d74..0000000000000 --- 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 925024cf25f4b..1c7749560bed3 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -2,7 +2,6 @@ import * as ProjectArtifacts from "./project/ProjectArtifacts.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { - ORCHESTRATION_PROTOCOL_VERSION, AuthSessionId, EnvironmentId, ProjectId, @@ -14,22 +13,14 @@ import { type ServerConfig, type ServerConfigStreamEvent, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; -import { ChildProcessSpawner } from "effect/process"; import * as SqlClient from "effect/sql/SqlClient"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -126,53 +117,13 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { - hasCompatibleOrchestrationProtocol, layerWsRpc, - resolveAvailableEditorsForConfig, - shouldUseBoundedThreadSnapshot, subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, WS_RPC_SERVER_OPTIONS, withLateEditorConfig, } from "./ws.ts"; -it("accepts only the current orchestration protocol before websocket RPC setup", () => { - assert.isTrue( - hasCompatibleOrchestrationProtocol( - new URL(`https://host.test/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`), - ), - ); - assert.isFalse(hasCompatibleOrchestrationProtocol(new URL("https://host.test/ws"))); - assert.isFalse( - hasCompatibleOrchestrationProtocol( - new URL(`https://host.test/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION - 1}`), - ), - ); -}); - -it("keeps full thread snapshot fallback unless the client opts into bounded history", () => { - assert.isFalse(shouldUseBoundedThreadSnapshot({})); - assert.isFalse(shouldUseBoundedThreadSnapshot({ acceptBoundedSnapshot: false })); - assert.isTrue(shouldUseBoundedThreadSnapshot({ acceptBoundedSnapshot: true })); -}); - -it.effect("does not block server config when editor discovery never resolves", () => - Effect.gen(function* () { - const discoveryInterrupted = yield* Deferred.make(); - const responseFiber = yield* resolveAvailableEditorsForConfig( - Effect.never.pipe( - Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), - ), - ).pipe(Effect.forkChild); - - yield* TestClock.adjust(Duration.seconds(5)); - - const availableEditors = yield* Fiber.join(responseFiber); - yield* Deferred.await(discoveryInterrupted); - assert.deepEqual(availableEditors, []); - }), -); - const snapshotConfig = (fields: Partial) => ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; @@ -212,21 +163,6 @@ it.effect("resends late editors without rolling back updates already sent", () = }), ); -it.effect("sends no late snapshot when the scan matches the snapshot", () => - Effect.gen(function* () { - const events = yield* withLateEditorConfig( - snapshotConfig({ availableEditors: ["vscode"] }), - Stream.empty, - { - resolveAvailableEditors: () => Effect.succeed(["vscode"]), - resolveFileManagerRevealKind: () => Effect.succeed(undefined), - }, - ).pipe(Stream.runCollect); - - assert.equal(events.length, 0); - }), -); - it.effect("resends a file manager reveal kind that missed the snapshot", () => Effect.gen(function* () { const events = yield* withLateEditorConfig( @@ -247,99 +183,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 a5a20abcb6cca..1507ab3e50da0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -247,6 +247,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { isNetworkPath } from "@t3tools/shared/path"; import * as RelayClient from "@t3tools/shared/relayClient"; import { isTelemetryIdHash } from "@t3tools/shared/telemetryId"; import { @@ -269,7 +270,7 @@ const resolveDiscoveryForConfig = ( Effect.map(Option.getOrElse(onTimeout)), ); -export const resolveAvailableEditorsForConfig = ( +const resolveAvailableEditorsForConfig = ( discovery: Effect.Effect, E, R>, ) => resolveDiscoveryForConfig(discovery, () => []); @@ -604,14 +605,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; @@ -2746,7 +2747,9 @@ export const layerWsRpc = ( input.resource._tag === "native-app-icon" || input.resource._tag === "tool-output-image" || input.resource._tag === "github-media" || - (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) + (input.resource._tag === "media-file" && + path.isAbsolute(input.resource.path) && + !isNetworkPath(input.resource.path)) ) { return yield* issueAssetUrl({ resource: input.resource }); } diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 5c6176b28c06d..92ad8e9083c3f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import rootPackageJson from "../../package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; @@ -34,7 +35,6 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { // executable needs a host Node that supports `--build-sea` (25.7+), so this is // a separate mode rather than a second entry in the default build. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; -const SEA_NODE_VERSION = "26.8.2"; const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, "darwin-x64": { platform: "darwin", arch: "x64" }, @@ -44,6 +44,12 @@ const SEA_TARGETS = { "win-x64": { platform: "win", arch: "x64" }, } as const; const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); +const cliNodeVersion = rootPackageJson.engines.node; +if (packExecutable && !packExecutableTarget && process.versions.node !== cliNodeVersion) { + throw new Error( + `Native CLI executables must be built with Node ${cliNodeVersion}; running ${process.versions.node}.`, + ); +} if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) { throw new Error( `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`, @@ -53,13 +59,17 @@ const packExecutableTargets = packExecutableTarget ? [ { ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], - nodeVersion: SEA_NODE_VERSION, + nodeVersion: cliNodeVersion, }, ] : 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 ad420d046380a..5a8051c3737d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,14 +1,14 @@ { "name": "@t3tools/web", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "dev": "vp dev", + "dev:built": "vp build --watch", "build": "vp build", "preview": "vp preview", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests --project unit --project browser" }, "dependencies": { @@ -65,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 +94,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", "jsdom": "^30.0.1", "oxc-transform-react": "~0.152.0", diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts index 004eb9b53eebb..1b3bbee034642 100644 --- a/apps/web/scripts/warm-dep-cache.ts +++ b/apps/web/scripts/warm-dep-cache.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. +// @effect-diagnostics nodeBuiltinImport:off - plain Node script that dev-runner spawns on a cold cache. /** * The cache cannot be shared between worktrees: Vite's config hash includes * the absolute project root, so each worktree must warm its own. diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts deleted file mode 100644 index 3e6c1fad04485..0000000000000 --- 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 31a2f1d779c56..0000000000000 --- 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 3341ec27c2c1b..c816836ae8aed 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 13ff3ea8e5797..0000000000000 --- 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 6add5c1856e7d..86823d66b5672 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 c5c0d89597aa3..0000000000000 --- 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.fork.test.ts b/apps/web/src/branding.fork.test.ts new file mode 100644 index 0000000000000..4b9362b6c442e --- /dev/null +++ b/apps/web/src/branding.fork.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const originalWindow = globalThis.window; + +afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + + if (originalWindow === undefined) { + Reflect.deleteProperty(globalThis, "window"); + return; + } + + globalThis.window = originalWindow; +}); + +describe("branding", () => { + it("lets a dev server present with an explicit stage label", async () => { + vi.stubEnv("DEV", true); + vi.stubEnv("VITE_APP_STAGE_LABEL", "Alpha"); + + const branding = await import("./branding"); + + expect(branding.APP_STAGE_LABEL).toBe("Alpha"); + expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Alpha)"); + }); +}); diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts deleted file mode 100644 index 70f402eff5336..0000000000000 --- a/apps/web/src/branding.test.ts +++ /dev/null @@ -1,127 +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(); - vi.unstubAllEnvs(); - - 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("lets a dev server present with an explicit stage label", async () => { - vi.stubEnv("DEV", true); - vi.stubEnv("VITE_APP_STAGE_LABEL", "Alpha"); - - const branding = await import("./branding"); - - expect(branding.APP_STAGE_LABEL).toBe("Alpha"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Alpha)"); - }); - - 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 087b5d109b40c..0000000000000 --- 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 390c77de4424f..fbd843c8d43fd 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 26aa15c08fae4..0128a203b2087 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 08e69c359a158..1c621e0070fbd 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.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 78b4d579c71f0..8818b377f5e3b 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,5 +1,3 @@ -"use client"; - import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, diff --git a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx b/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx deleted file mode 100644 index 12b03def42d23..0000000000000 --- a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { - DEFAULT_CLIENT_SETTINGS, - EnvironmentId, - FILL_PREVIEW_VIEWPORT, - ThreadId, - type ClientSettings, - type DesktopPreviewBridge, - type PreviewViewportSetting, -} from "@t3tools/contracts"; -import type { ReactNode } from "react"; - -import "~/index.css"; -import { describe, expect, it, vi } from "vite-plus/test"; -import type { previewBridge } from "~/components/preview/previewBridge"; - -import { act, fakeTimers, render, stubGlobal } from "../../test/render"; - -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"; - -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 }; -} - -function resetStores() { - __resetClientSettingsPersistenceForTests(); - useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); -} - -function setup() { - using stack = new DisposableStack(); - resetStores(); - stack.defer(resetStores); - vi.mocked(desktopTabLifetime.acquireDesktopTab).mockClear(); - 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, - }); - stack.use(vi.spyOn(console, "error").mockImplementation(() => undefined)); - return stack.move(); -} - -// The webview releases its desktop tab on a timer after unmount; run it before the test ends -// so it cannot land in the next one. -async function renderHost(ui: ReactNode) { - await using stack = new AsyncDisposableStack(); - const screen = stack.use(await render(ui)); - stack.defer(async () => { - using _timers = fakeTimers(); - await screen.unmount(); - await vi.advanceTimersByTimeAsync(0); - }); - const environment = stack.move(); - return Object.assign(screen, { [Symbol.asyncDispose]: () => environment.disposeAsync() }); -} - -describe("HostedBrowserWebview settings hydration", () => { - it("starts a retained background tab only after a settings read succeeds on retry", async () => { - using _setup = setup(); - 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); - using 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 = () => screen.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); - - await using screen = await renderHost( - , - ); - - 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 }; - - const webview = (viewport: PreviewViewportSetting) => ( - - ); - - async function renderViewport(viewport: PreviewViewportSetting) { - mocks.getClientSettings.mockResolvedValue({ - ...DEFAULT_CLIENT_SETTINGS, - browserDefaultZoomFactor: 1.5, - }); - await act(() => ensureClientSettingsHydrated()); - await using stack = new AsyncDisposableStack(); - const screen = stack.use(await renderHost(webview(viewport))); - const guest = screen.container.querySelector("webview"); - expect(guest).not.toBeNull(); - const environment = stack.move(); - return Object.assign(screen, { - guest: guest!, - setViewport: (next: PreviewViewportSetting) => screen.rerender(webview(next)), - [Symbol.asyncDispose]: () => environment.disposeAsync(), - }); - } - - it("keeps fixed viewports sized in guest pixels across host zoom and fitted surfaces", async () => { - using _setup = setup(); - let windowZoom = 1.25; - using _bridge = stubGlobal("desktopBridge", { getWindowZoomFactor: () => windowZoom }); - useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); - useBrowserSurfaceStore.getState().present(runtimeTabId, owner, panel, true, 0, 30); - await using screen = await renderViewport({ _tag: "freeform", width: 800, height: 600 }); - const { guest } = screen; - 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 screen.setViewport({ _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 screen.setViewport(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 () => { - using _setup = setup(); - using _bridge = stubGlobal("desktopBridge", {}); - await using screen = await renderViewport({ _tag: "freeform", width: 800, height: 600 }); - const { guest } = screen; - expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 }); - - // `_bridge` puts the original back after the unmount, so the unmount still sees no bridge. - Reflect.set(globalThis, "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 7d6c86d77409e..4dc393ffa4d85 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 27ae15e5e7989..c0de4f0d32ea4 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 5384d093b883f..df371a83b317c 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 7747898d08185..55bf7c3be8ee9 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 de9c173dc2da7..0000000000000 --- 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 02408846d0603..67b66ba4bade3 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 304adf4f19e0a..0000000000000 --- 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 ffbcc19a54532..0000000000000 --- 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 456377a4d641c..2630d8fe0e3e3 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 c2b6c9ea64b47..39afd5ec816b3 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 a7633fd0963d5..a441eecf08ddb 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 ca2832bea6ce1..1932a634fb6c1 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 63e0fde1b3a73..d6bb4cae59324 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 831167095fa10..0000000000000 --- 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 5bdf9b7c4f6db..420fdb9cc39a9 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 951b52e904687..0000000000000 --- 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 7518ede3e5e7a..9cb0114e15246 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 75704cd2da2bd..0000000000000 --- 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 312dde29a66c5..aa46b4c845275 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 d10e69d246632..0000000000000 --- 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 8702b673a951c..07e19c78d124f 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 629294a5f582a..0000000000000 --- 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 924e3730bd98c..b3f3091ba1f14 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 a13e6ff06f6bb..0000000000000 --- 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 606244d436430..9c11278ad4c8c 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 1f2bc2bd7c0fd..cfdedef001626 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 4051b4ebfbfb0..c8d6fd521d245 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 cdabd8e80177b..e06b35b3ccdb8 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 aaba58c8a7e88..770b26e0a686b 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 5154fe47e035a..fd3821b81b538 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 6bb4631bf5725..0223dfb9d5bd5 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 63bee2d9ccfab..97db1089c8e8e 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 5510b698216f0..25a5a21400a3c 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 dbcaacfa25a4d..65cf9402e5fc4 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 63001bdb184da..27f8a21879014 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 bab63e8260c1f..182367a410de2 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 8ec1820d1115f..2f505df24fb5f 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 be9819e5c405b..3050948985145 100644 --- a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx @@ -216,85 +216,6 @@ describe("useCloudLinkController", () => { expectNoMutations(); }); - it("uses the latest linked mode to change publishing without replacing the tunnel", async () => { - using _setup = setup(); - testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; - testState.renderedSnapshot = testState.snapshot; - await using screen = await mountController(); - testState.getToken.mockImplementationOnce(async () => { - testState.snapshot = linkedState; - return "clerk-token"; - }); - - expect(await screen.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 () => { - using _setup = setup(); - testState.relayProviderKind = "manual"; - await using screen = await mountController(); - - expect(screen.controller?.managedTunnelActive).toBe(false); - expect(screen.controller?.managedTunnelOutOfSync).toBe(true); - expect(await screen.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 () => { - using _setup = setup(); - testState.snapshot = { ...linkedState, linkRevoked: true }; - testState.renderedSnapshot = testState.snapshot; - await using screen = await mountController(); - expect(screen.controller).toMatchObject({ - linked: false, - linkRevoked: true, - managedTunnelActive: false, - managedTunnelOutOfSync: false, - }); - - expect(await screen.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 () => { - using _setup = setup(); - testState.relayProviderKind = "cloudflare_tunnel"; - await using screen = await mountController(); - - expect(screen.controller?.managedTunnelActive).toBe(true); - expect(screen.controller?.managedTunnelOutOfSync).toBe(false); - expect(await screen.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 () => { - using _setup = setup(); - await using screen = await mountController(); - testState.link.mockImplementationOnce(async () => { - testState.scopes.delete(AuthRelayWriteScope); - return AsyncResult.success({}); - }); - - expect(await screen.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 () => { using _setup = setup(); await using screen = await mountController(); @@ -305,19 +226,4 @@ describe("useCloudLinkController", () => { expect(testState.link).not.toHaveBeenCalled(); expect(testState.preferences).not.toHaveBeenCalled(); }); - - it("unlinks if the link-state read fails while requesting the cloud token", async () => { - using _setup = setup(); - await using screen = await mountController(); - testState.getToken.mockImplementationOnce(async () => { - testState.snapshot = null; - throw new Error("Cloud sign-in unavailable"); - }); - - expect(await screen.reconcile({ managedTunnel: false, publish: false })).toBe(true); - expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); - expect(testState.link).not.toHaveBeenCalled(); - expect(testState.preferences).not.toHaveBeenCalled(); - expect(testState.refreshLink).toHaveBeenCalledOnce(); - }); }); diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 7d1748f9d0c41..fc6dce938ec10 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -11,6 +11,11 @@ import { type ScopedProjectRef, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; +import { + type CloneProtocol, + cloneUrlForProtocol, + cloneUrlProtocol, +} from "@t3tools/shared/cloneUrl"; import { ArrowRightIcon } from "lucide-react"; import { useEffect, useId, useState } from "react"; import { create } from "zustand"; @@ -21,10 +26,7 @@ import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { COPYABLE_FILE_MAX_BYTES, - type CloneProtocol, addProjectToMachine, - cloneUrlForProtocol, - cloneUrlProtocol, isDefaultCopiedFile, } from "../lib/addProjectToMachine"; import { formatBytes } from "../lib/formatBytes"; @@ -82,7 +84,10 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const [targetId, setTargetId] = useState(request.targetEnvironmentId); const target = targets.find((t) => t.environmentId === targetId) ?? targets[0] ?? null; const source = environments.find((env) => env.environmentId === request.projectRef.environmentId); - const sourceUrl = project?.repositoryIdentity?.locator.remoteUrl ?? ""; + const sourceUrl = + project?.repositoryIdentity?.origin?.remoteUrl ?? + project?.repositoryIdentity?.locator.remoteUrl ?? + ""; const [protocol, setProtocol] = useState(() => cloneUrlProtocol(sourceUrl)); const sshUrl = cloneUrlForProtocol(sourceUrl, "ssh"); const httpsUrl = cloneUrlForProtocol(sourceUrl, "https"); diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx index dabb2abaeab49..2c44325832057 100644 --- a/apps/web/src/components/AnimatedHeight.tsx +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -1,5 +1,3 @@ -"use client"; - import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { observeResize } from "../lib/observeResize"; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index b518898a5d6d6..f6bda2e709628 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -3,6 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, + useRef, useState, useSyncExternalStore, type CSSProperties, @@ -10,7 +11,13 @@ import { } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; +import { + releaseThreadUndoShortcut, + threadUndoClaimsShortcut, + undoLatestThreadAction, +} from "../hooks/showThreadUndoNotice"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { isRichTextBoldShortcut, @@ -209,7 +216,21 @@ function SidebarControl() { ); } -function NavigationHistoryShortcuts() { +function routePanelsOpen(routeThreadRef: ReturnType) { + if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; + return { + terminalOpen: selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen, + previewOpen: + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview", + }; +} + +export function NavigationHistoryShortcuts() { + const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const routeThreadRef = useThreadRouteRef(); @@ -225,19 +246,11 @@ function NavigationHistoryShortcuts() { const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, previewFocus: isPreviewFocused(), - previewOpen: routeThreadRef - ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === - "preview" - : false, + ...routePanelsOpen(routeThreadRef), editableFocus: isEditableFocused(event.target), modelPickerOpen: isModelPickerOpen(), + usagePageOpen, }, }); if (runInterfaceZoomCommand(command)) { @@ -255,7 +268,68 @@ function NavigationHistoryShortcuts() { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, routeThreadRef]); + }, [keybindings, routeThreadRef, usagePageOpen]); + + return null; +} + +const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); + +// Undoes the thread actions in the sidebar's notice. Mounted wherever the +// thread sidebar can show; Settings replaces it. Listens in the capture phase +// so mod+z reaches it before the composer's own undo handler consumes the key. +function ThreadUndoShortcut() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const routeThreadRef = useThreadRouteRef(); + // A collapsed sidebar hides the notice, so text fields keep their own undo. + const noticeVisible = useSidebarVisibility(); + // Outlives the effect: undoing an archive navigates, which re-runs it mid-hold. + const heldUndoKey = useRef(null); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + // A held key undoes once. Cancel its repeats so they cannot fall through + // to the editor's own undo, even after the last notice is gone. + if (event.repeat) { + if (event.code === heldUndoKey.current) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + // A modifier tapped mid-hold leaves the held key repeating. + if (!MODIFIER_KEYS.has(event.key)) heldUndoKey.current = null; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + previewFocus: isPreviewFocused(), + ...routePanelsOpen(routeThreadRef), + editableFocus: + isEditableFocused(event.target) && !(noticeVisible && threadUndoClaimsShortcut()), + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "thread.undo") { + if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); + return; + } + if (isCommandPaletteOpen() || isModelPickerOpen()) return; + if (undoLatestThreadAction()) { + heldUndoKey.current = event.code; + event.preventDefault(); + event.stopPropagation(); + } + }; + // Pastes from a menu, dictation and drops edit text without a keypress. + const onInput = () => releaseThreadUndoShortcut(); + + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("input", onInput, true); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("input", onInput, true); + }; + }, [keybindings, noticeVisible, routeThreadRef]); return null; } @@ -372,6 +446,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + {isOnSettings ? null : } diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 615a35a5a9151..6bb868bf7e698 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,116 +1,18 @@ -import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; +import { type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildBranchPickerRefItems, 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( @@ -122,16 +24,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({ @@ -143,146 +35,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({ @@ -291,15 +44,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", () => { @@ -317,17 +61,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({ @@ -338,184 +71,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({ @@ -535,54 +93,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", () => { @@ -618,54 +134,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[] = [ { @@ -726,23 +194,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({ @@ -773,28 +224,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({ @@ -805,43 +234,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"); }); @@ -851,22 +250,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.fork.browser.test.tsx similarity index 67% rename from apps/web/src/components/BranchToolbar.panel.browser.test.tsx rename to apps/web/src/components/BranchToolbar.panel.fork.browser.test.tsx index 5e8bc9c50b97f..c0b6a994c66cb 100644 --- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx +++ b/apps/web/src/components/BranchToolbar.panel.fork.browser.test.tsx @@ -88,44 +88,3 @@ it("keeps machine choices usable when the combined row's workspace is locked", a 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 using screen = 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(screen.container.textContent).toBe("local"); - expect(screen.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) => { - await using screen = await render({ envMode }, { worktreePath }); - const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); - await act(async () => { - screen.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/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 681def5b6a776..8ee7ac5fa3709 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -701,23 +701,18 @@ export const BranchToolbar = memo(function BranchToolbar({ const handleReturnToRoot = async () => { if (returningToRoot || !canReturnToRoot) return; setReturningToRoot(true); - try { - const result = await returnToRoot({ environmentId, input: { threadId } }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not move to main checkout", - description: error instanceof Error ? error.message : "Workspace move failed.", - }), - ); - } - return; - } - } finally { - setReturningToRoot(false); + const result = await returnToRoot({ environmentId, input: { threadId } }).finally(() => + setReturningToRoot(false), + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not move to main checkout", + description: error instanceof Error ? error.message : "Workspace move failed.", + }), + ); } }; const branchSelectorRef = useRef(null); 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 bb58f89606247..0000000000000 --- a/apps/web/src/components/ChatMarkdown.assets.browser.test.tsx +++ /dev/null @@ -1,308 +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 type { ReactNode } from "react"; -import { expect, it, vi } from "vite-plus/test"; - -import { act, render } from "../../test/render"; - -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 imageUrl = (version: string) => - `data:image/svg+xml,#${version}`; -const assetResult = (version: string): AssetCreateUrlResult => ({ - relativeUrl: imageUrl(version), - expiresAt: 1, -}); - -function setup() { - mint.mockReset().mockResolvedValue(assetResult("updated")); - remoteOpen.isResolved = true; - remoteOpen.state.mode = "local-exec"; - remoteOpen.label = "media-host"; - remoteOpen.httpBaseUrl = "https://host.test"; - const registry = AtomRegistry.make(); - return { registry, [Symbol.dispose]: () => registry.dispose() }; -} - -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 }; -} - -async function renderInRegistry(registry: AtomRegistry.AtomRegistry, ui: ReactNode) { - const wrap = (node: ReactNode) => ( - {node} - ); - const screen = await render(wrap(ui)); - return Object.assign(screen, { update: (node: ReactNode) => screen.rerender(wrap(node)) }); -} - -it("opens host media through server authorization before the client grant loads", async () => { - using env = setup(); - mint.mockResolvedValue({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }); - const expanded = deferred(); - const onImageExpand = vi.fn(expanded.resolve); - await using screen = await renderInRegistry( - env.registry, - , - ); - const link = screen.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) => { - using env = setup(); - const lookup = deferred(); - const started = deferred(); - const reported = deferred[0]>(); - mint.mockImplementation(() => { - started.resolve(); - return lookup.promise; - }); - using _toast = vi.spyOn(toastManager, "add").mockImplementation((toast) => { - reported.resolve(toast); - return "media-toast"; - }); - remoteOpen.isResolved = false; - const content = ( - - ); - await using screen = await renderInRegistry(env.registry, content); - const link = screen.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 screen.update(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 () => { - using env = setup(); - await using screen = await renderInRegistry(env.registry, markdown()); - expect(renderedImage(screen.container)).toBe(imageUrl("updated")); - mint.mockResolvedValue(assetResult("changed")); - - await screen.update(markdown()); - await screen.update(null); - await screen.update(markdown()); - - expect(renderedImage(screen.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) => { - using env = setup(); - if (cached === "failure") mint.mockRejectedValueOnce(new Error("Image missing")); - else mint.mockResolvedValueOnce(assetResult("stale")); - await using screen = await renderInRegistry(env.registry, markdown(null)); - expect(renderedImage(screen.container)).toBe(cached === "URL" ? imageUrl("stale") : undefined); - await screen.update(null); - - mint.mockResolvedValue(assetResult("updated")); - await screen.update(markdown(null)); - - expect(renderedImage(screen.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) => { - using env = setup(); - let resolvePending!: (value: AssetCreateUrlResult) => void; - const pending = new Promise((resolve) => { - resolvePending = resolve; - }); - await using screen = await renderInRegistry(env.registry, null); - if (request === "refresh") { - await screen.update(markdown("m0")); - expect(renderedImage(screen.container)).toBe(imageUrl("updated")); - } - - mint.mockReturnValueOnce(pending).mockResolvedValue(assetResult("latest")); - await screen.update(markdown("m1")); - const callsBeforeEdits = mint.mock.calls.length; - await screen.update(markdown("m2")); - await screen.update(markdown("m3")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits); - - await act(async () => { - resolvePending(assetResult("before-edits")); - await pending; - }); - - expect(renderedImage(screen.container)).toBe(imageUrl("latest")); - expect(mint).toHaveBeenCalledTimes(callsBeforeEdits + 1); - await screen.update(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 5ba36369bd722..e30b81c5e5f93 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,77 +1,13 @@ -const inlineMocks = vi.hoisted(() => ({ - run: vi.fn(), - write: vi.fn(), - listeners: new Set<() => void>(), - output: null as - | typeof import("@t3tools/client-runtime/state/inlineShell").emptyInlineShellOutput - | null, -})); -vi.mock(import("@effect/atom-react"), async (original) => { - const actual = await original(); - return { - ...actual, - useAtomValue: ((atom: unknown) => - atom === "inline-permission" ? true : actual.useAtomValue(atom as never)) as never, - }; -}); -vi.mock("../state/terminal", () => ({ - terminalEnvironment: { - runInline: { permissionAtom: () => "inline-permission" }, - write: { permissionAtom: () => "inline-permission" }, - watchInlineRun: () => "inline-record", - observeInline: () => "inline-output", - }, -})); -vi.mock(import("../state/query"), async (original) => { - const { useSyncExternalStore } = await import("react"); - return { - ...(await original()), - useEnvironmentQuery: ((atom: unknown) => { - const data = useSyncExternalStore( - (notify) => { - inlineMocks.listeners.add(notify); - return () => { - inlineMocks.listeners.delete(notify); - }; - }, - () => (atom === "inline-output" ? inlineMocks.output : null), - ); - return { data, error: null }; - }) as never, - }; -}); -vi.mock(import("../state/use-atom-command"), async (original) => { - const actual = await original(); - return { - ...actual, - useAtomCommand: ((command: unknown) => { - if (command === terminalEnvironment.runInline) return inlineMocks.run; - if (command === terminalEnvironment.write) return inlineMocks.write; - return actual.useAtomCommand(command as never); - }) as never, - }; -}); -import { terminalEnvironment } from "../state/terminal"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; -import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import { AsyncResult } from "effect/reactivity"; -import { useEffect, type ReactElement } from "react"; +import { type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; -import { act, render, stubGlobal } from "../../test/render"; +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(() => ({ @@ -126,26 +62,6 @@ vi.mock(import("./chat/MermaidDiagram"), async (original) => { }); const settingsOverrides = vi.hoisted(() => ({ mathRenderingEnabled: false })); -vi.mock(import("../state/environments"), () => ({ - useEnvironments: (() => ({ - isReady: false, - networkStatus: null, - environments: [], - presentationById: new Map(), - })) as never, - useEnvironmentProviders: (() => new Map()) as never, - usePrimaryEnvironmentId: (() => null) as never, - useEnvironment: (() => null) as never, - usePrimaryEnvironment: (() => null) as never, - useEnvironmentHttpBaseUrl: (() => null) as never, - useRelayEnvironmentDiscovery: (() => ({})) as never, - useEnvironmentIds: (() => []) as never, - useEnvironmentIdentities: (() => []) as never, - usePullRequestsSupported: (() => false) as never, - useIssuesSupported: (() => false) as never, - useEnvironmentMachines: (() => new Map()) as never, - useConnectedEnvironmentIds: (() => []) as never, -})); vi.mock(import("../hooks/useSettings"), async (original) => { const actual = await original(); type Settings = ReturnType; @@ -157,10 +73,6 @@ vi.mock(import("../hooks/useSettings"), async (original) => { }); import ChatMarkdown from "./ChatMarkdown"; -import { InlineShellRunProvider } from "./chat/InlineShellRun"; -import { emptyInlineShellOutput } from "@t3tools/client-runtime/state/inlineShell"; -import { serializeTableElementToCsv, chatMarkdownClipboardPayload } from "../markdown-clipboard"; -import { captureAssistantTextSelection } from "../lib/assistantTextSelection"; function setup() { settingsOverrides.mathRenderingEnabled = false; @@ -173,344 +85,12 @@ function renderText(text: string, props: Partial return render(); } -function linkTexts(container: HTMLElement) { - return [...container.querySelectorAll("a")].map((link) => link.textContent); -} - function buttonsLabelled(container: HTMLElement, label: string) { return [...container.querySelectorAll("button")].filter( (button) => button.getAttribute("aria-label") === label, ); } -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", () => { - it.each(["dark", "light"] as const)( - "previews opposite syntax colors and restores saved %s colors on cancel", - async (savedAppearance) => { - setup(); - let selection: ReturnType; - function Preview() { - const theme = useTheme(); - useEffect(() => { - selection = theme; - }, [theme]); - return ; - } - await getSyntaxHighlighterPromise("typescript"); - await using screen = await render(); - // Disposed before the unmount, while useTheme still listens for the storage event. - using _storage = { - [Symbol.dispose]() { - localStorage.clear(); - window.dispatchEvent(new StorageEvent("storage", { key: null })); - }, - }; - const { container } = screen; - 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 }) => { - setup(); - await using screen = await render( -
-
- -
-
, - ); - const { container } = screen; - 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) => { - setup(); - await using screen = await renderText( - `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`, - ); - const { container } = screen; - 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) => { - setup(); - await using screen = await renderText(`See label\n\n${closing}\n\nfinish`); - const { container } = screen; - expect(container.querySelector("p")?.textContent).toBe("See label"); - }, - ); - - it("preserves a paired anchor after comment-looking raw text", async () => { - setup(); - await using screen = await renderText("See label -->"); - const { container } = screen; - expect(container.querySelector("p")?.textContent).toBe("See label -->"); - }); - - it.each(["", '
more
', ''])( - "ignores apparent closing anchors inside %s", - async (html) => { - setup(); - await using screen = await renderText(`Before .\n\n${html}\n\nAfter.`); - const { container } = screen; - 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 () => { - setup(); - await using screen = await renderText( - 'Bare label, , ``, and [docs](https://example.com).\n\n
MoreDetails
', - ); - const { container } = screen; - 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 () => { - setup(); - 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) => ( - - ); - await using screen = await render(message(open)); - const { container, rerender } = screen; - 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) => { - setup(); - const message = (text: string) => ; - await using screen = await render(message(open)); - const { container, rerender } = screen; - 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 () => { - setup(); - const open = "```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```"; - const message = (text: string) => ; - await using screen = await render(message(open)); - const { container, rerender } = screen; - 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 () => { - setup(); - await getSyntaxHighlighterPromise("mermaid"); - renderDiagram.mockImplementation(() => { - throw new Error("Invalid Mermaid diagram"); - }); - using _consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - await using screen = await renderText("```mermaid\ninvalid diagram\n```", { - isStreaming: true, - }); - const { container } = screen; - 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 () => { - setup(); - const text = - "See [Terminal output](t3-context://v1/terminal/term-1) and ![Error image](t3-context://v1/image/img-1)."; - await using screen = await render( - ( - - )} - />, - ); - const { container, rerender } = screen; - 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 () => { - setup(); - const seen: Array = []; - await using _screen = 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(); @@ -533,105 +113,42 @@ describe("ChatMarkdown favicon privacy", () => { }); describe("ChatMarkdown streaming", () => { - it("runs closed shell blocks inline and through the terminal command action", async () => { + it("runs only a complete single-line shell block after a click", async () => { setup(); const onRunShellCommand = vi.fn(); - const onOpenInlineTerminal = vi.fn(); - const environmentId = EnvironmentId.make("inline-environment"); - const threadId = ThreadId.make("inline-thread"); - inlineMocks.run.mockReset().mockImplementation(async ({ input }) => ({ - _tag: "Success", - value: { - ...input, - terminalId: `inline-${input.runId}`, - sequence: inlineMocks.run.mock.calls.length, - status: "running", - startedAt: "2026-10-13T00:00:00.000Z", - completedAt: null, - durationMs: 0, - output: "", - exitCode: null, - error: null, - }, - })); - using _outputReset = { - [Symbol.dispose]() { - inlineMocks.output = null; - }, - }; const message = (text: string, isStreaming = false) => ( - - - + ); await using screen = await render(message("```bash\necho hello\n```", true)); const { container, rerender } = screen; - const inlineButtons = () => buttonsLabelled(container, "Run inline"); - const terminalButtons = () => buttonsLabelled(container, "Run in terminal"); - expect(inlineButtons()).toHaveLength(0); - expect(terminalButtons()).toHaveLength(0); + const runButtons = () => buttonsLabelled(container, "Run in terminal"); + expect(runButtons()).toHaveLength(0); await rerender(message("```bash\necho hello\n```")); - await act(async () => terminalButtons()[0]!.click()); + await act(async () => runButtons()[0]!.click()); expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello"); - onRunShellCommand.mockClear(); - for (const [fence, command] of [ - ["```bash\necho hello\n```", "echo hello"], - ["~~~bash\necho tilde\n~~~", "echo tilde"], - ["> ```bash\n> echo quote\n> ```", "echo quote"], - ["````bash\necho four\n````", "echo four"], - ["```bash\necho one\necho two\n```", "echo one\necho two"], - ["```bash\necho trailing\n\n```", "echo trailing"], + for (const text of [ + "~~~bash\necho tilde\n~~~", + "> ```bash\n> echo quote\n> ```", + "````bash\necho four\n````", + "- Command\n\n ```bash\n echo list\n ```", + "```bash\r\necho crlf\r\n```\r", ]) { - inlineMocks.output = { ...emptyInlineShellOutput, status: "running" }; - await rerender(message(fence!)); - expect(inlineButtons()).toHaveLength(1); - expect(terminalButtons()).toHaveLength(command!.includes("\n") ? 0 : 1); - await act(async () => inlineButtons()[0]!.click()); - expect(inlineMocks.run.mock.calls.at(-1)?.[0]).toMatchObject({ - environmentId, - input: { - threadId, - blockId: expect.stringContaining("visible-message:"), - command, - language: "bash", - }, - }); - inlineMocks.output = { - ...emptyInlineShellOutput, - status: "exited", - output: "done", - exitCode: 7, - endedAt: Date.parse("2026-10-13T00:00:02Z"), - }; - await act(async () => inlineMocks.listeners.forEach((notify) => notify())); - expect( - [...container.querySelectorAll("button")].some((button) => button.textContent === "Stop"), - ).toBe(false); - expect([...container.querySelectorAll("pre")].some((pre) => pre.textContent === "done")).toBe( - true, - ); + await rerender(message(text)); + expect(runButtons()).toHaveLength(1); } - const open = [...container.querySelectorAll("button")].find( - (button) => button.textContent === "Open in terminal", - ); - await act(async () => open!.click()); - expect(onOpenInlineTerminal).toHaveBeenCalledExactlyOnceWith( - expect.stringMatching(/^inline-/), - ); - expect(inlineMocks.run).toHaveBeenCalledTimes(6); for (const text of [ + "```bash\necho one\necho two\n```", "```typescript\necho hello\n```", "```bash\n\n```", + "```bash\necho hello\n\n```", "```bash\necho hello\\\n```", "```bash\necho safe \u202e#\n```", "```bash\necho incomplete", @@ -640,121 +157,10 @@ describe("ChatMarkdown streaming", () => { '
echo html
', ]) { await rerender(message(text)); - expect(inlineButtons()).toHaveLength(0); - expect(terminalButtons()).toHaveLength(0); + expect(runButtons()).toHaveLength(0); } }); - it("does not retokenize completed lines when streaming finishes", async () => { - setup(); - const highlighter = await getSyntaxHighlighterPromise("typescript"); - using highlight = vi.spyOn(highlighter, "codeToHast"); - const text = "```typescript\nconst completed = 1;\nconst current = 2;"; - await using screen = await render(); - const { rerender } = screen; - 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 () => { - setup(); - const highlighter = await getSyntaxHighlighterPromise("text"); - const codeToHast = highlighter.codeToHast.bind(highlighter); - let fail = true; - using _codeToHast = vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { - if (fail) throw new Error("Temporary highlighter failure"); - return codeToHast(...args); - }); - using _consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - using _consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - - await using screen = await render( - , - ); - const { container, rerender } = screen; - 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 () => { - setup(); - const highlighter = await getSyntaxHighlighterPromise("text"); - using highlight = vi.spyOn(highlighter, "codeToHast"); - using writeText = vi - .spyOn(navigator.clipboard, "writeText") - .mockImplementation(async () => undefined); - const text = [ - "```text", - "First code block", - "```", - "", - "
More", - "", - "Details content", - "", - "
", - "", - "Streaming reply", - ].join("\n"); - - await using screen = await render(); - const { container, rerender } = screen; - 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; @@ -783,167 +189,12 @@ describe("ChatMarkdown streaming", () => { }); }); -describe("ChatMarkdown skill chips", () => { - it("updates digit-leading skill labels when discovered skills change", async () => { - setup(); - const text = "Use $2spec with a $20k budget."; - await using screen = await render(); - const { container, rerender } = screen; - 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 }) => { - setup(); - workspaceSearch.mockResolvedValue( - AsyncResult.success({ - entries: [ - { path: indexedPath, kind: "file" }, - { path, kind: "file" }, - ], - truncated: false, - }), - ); - using openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - await using screen = await renderText(`[Notes](${path}:7)`, { cwd, threadRef }); - const { container } = screen; - - 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 () => { - setup(); - 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, - }), - ); - using openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - await using screen = await renderText("[Notes](physics/notes/a.md)", { - cwd: "C:/school/Physics", - threadRef, - }); - const { container } = screen; - - 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 () => { - setup(); - workspaceSearch.mockResolvedValue( - AsyncResult.success({ - entries: [{ path: "notes/a.md", kind: "file" }], - truncated: true, - }), - ); - filesystemMetadata.mockResolvedValue( - AsyncResult.success({ entries: [{ kind: "file", byteLength: 10 }] }), - ); - using openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - await using screen = await renderText("[Notes](physics/notes/a.md:7)", { - cwd: "/school/physics", - threadRef, - }); - const { container } = screen; - - 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 }) => { - setup(); - workspaceSearch.mockResolvedValue( - AsyncResult.success({ - entries: [{ path: "notes/a.md", kind: "file" }], - truncated: false, - }), - ); - using openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - await using screen = await renderText(`[Notes](${href})`, { - cwd: "/school/physics", - imageBaseDir, - threadRef, - }); - const { container } = screen; - - 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({ @@ -979,209 +230,7 @@ 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) => { - setup(); - await using screen = await renderText( - 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.', - { lineBreaks: !parseRawHtml, parseRawHtml }, - ); - const { container } = screen; - 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 () => { - setup(); - await using screen = await renderText( - 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"', - { isStreaming: true }, - ); - const { container } = screen; - 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) => { - setup(); - await using screen = await renderText(text); - const { container } = screen; - expect(container.textContent).toContain(text.replace("\\", "")); - expect(fileOptions(container)).toEqual([]); - }); - - it("preserves Codex file citation examples inside code", async () => { - setup(); - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - await using screen = await renderText( - `Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``, - ); - const { container } = screen; - 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 () => { - setup(); - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - await using screen = await renderText(`[See ${directive}](https://example.com)`); - const { container } = screen; - expect(container.textContent).toContain("codex-file-citation"); - expect(fileOptions(container)).toEqual([]); - }); - - it("renders file citations created by over-indented list recovery", async () => { - setup(); - await using screen = await renderText( - '- Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}', - ); - const { container } = screen; - 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 () => { - setup(); - await using screen = await renderText( - 'Changed :codex-file-citation{path="/tmp/project/src/index.ts"} and :codex-file-citation{path="/tmp/project/test/index.ts"}.', - ); - const { container } = screen; - 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 () => { - setup(); - await using malformed = await renderText( - 'Leading text before list.\n\n- Bad :codex-file-citation{purpose="output"}', - ); - expect(malformed.container.querySelector("li")?.textContent).toBe( - 'Bad :codex-file-citation{purpose="output"}', - ); - await using nestedLink = await renderText( - 'Leading text before list.\n\n- [Bad :codex-file-citation{path="/tmp/project/report.xlsx"}](https://example.com)', - ); - expect(nestedLink.container.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) => { - setup(); - await using screen = await renderText( - 'Done.\n\n- :codex-followup[Prepare print version]{prompt="Prepare the document for printing."}', - { lineBreaks: !parseRawHtml, parseRawHtml }, - ); - const { container } = screen; - 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 () => { - setup(); - await using screen = await renderText("- Next :codex-followup[Prepare print version]"); - const { container } = screen; - 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) => { - setup(); - const onUseArtifactTemplate = vi.fn(); - await using screen = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE, { - parseRawHtml, - onUseArtifactTemplate, - }); - const { container } = screen; - 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 () => { - setup(); - await using screen = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE); - const { container } = screen; - 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) => { - setup(); - await using screen = await renderText(text); - const { container } = screen; - expect(container.textContent).toContain("::artifact-template"); - expect(container.textContent).not.toContain("Document template"); - }); - - it("preserves artifact-template examples inside code", async () => { - setup(); - await using screen = await renderText( - `\`${ARTIFACT_TEMPLATE_DIRECTIVE}\`\n\n\`\`\`text\n${ARTIFACT_TEMPLATE_DIRECTIVE}\n\`\`\``, - ); - const { container } = screen; - 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 () => { - setup(); - await using screen = await renderText("# Top\n\n## Section\n\n###### Fine print", { - headingLevelOffset: 3, - }); - const { container } = screen; - expect( - [...container.querySelectorAll("h1, h2, h6")].map((heading) => [ - heading.tagName, - heading.getAttribute("aria-level"), - ]), - ).toEqual([ - ["H1", "4"], - ["H2", "5"], - ["H6", "6"], - ]); - await using flat = await renderText("# Top"); - expect(flat.container.querySelector("h1")?.hasAttribute("aria-level")).toBe(false); - }); - it("never gives two headings the same id, even when a suffix matches another heading", async () => { setup(); await using screen = await renderText( @@ -1214,47 +263,6 @@ 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) => { - setup(); - for (const parseRawHtml of [true, false]) { - await using screen = await renderWindows(text, parseRawHtml); - const { container } = screen; - expect(fileHrefs(container)).toEqual(["C:/Users/shawn/project/src/main.ts"]); - } - }); - - it("keeps backslashes CommonMark would read as escapes", async () => { - setup(); - for (const parseRawHtml of [true, false]) { - await using screen = await renderWindows( - String.raw`[settings](C:\Users\shawn\.claude\settings.json)`, - parseRawHtml, - ); - const { container } = screen; - expect(fileHrefs(container)).toEqual(["C:/Users/shawn/.claude/settings.json"]); - } - }); - - it("distinguishes same-named backslash paths but not the same file twice", async () => { - setup(); - for (const parseRawHtml of [true, false]) { - await using 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.container.textContent).toContain("index.ts · project/src"); - expect(distinct.container.textContent).toContain("index.ts · project/test"); - const path = String.raw`C:\Users\shawn\project\src\main.ts`; - await using same = await renderWindows(`[Source](${path}) and \`${path}\``, parseRawHtml); - expect(fileHrefs(same.container)).toHaveLength(2); - expect(same.container.textContent).not.toContain("main.ts ·"); - } - }); - it("still rejects unsafe schemes", async () => { setup(); for (const parseRawHtml of [true, false]) { @@ -1269,450 +277,3 @@ describe("ChatMarkdown Windows file links", () => { } }); }); - -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) => { - setup(); - using scrollIntoView = vi - .spyOn(HTMLElement.prototype, "scrollIntoView") - .mockImplementation(() => {}); - const href = window.location.href; - await using screen = await renderText( - "- [Operating model](#1-operating-model)\n- [Missing](#nowhere)\n\n## 1. Operating model\n\n## 1. Operating model", - { parseRawHtml }, - ); - const { container } = screen; - 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()))); - // Highlights live in a document-wide registry the rendered tree does not own. - const clearHighlights = () => ({ [Symbol.dispose]: () => CSS.highlights.clear() }); - 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 () => { - setup(); - using _highlights = clearHighlights(); - const container = document.createElement("div"); - const markdown = ( - Unrelated

nothing here

", - "
Outer
Inner

needle

", - ].join("\n\n")} - /> - ); - const probe = (query: string, activeOccurrence: number) => ( - - {markdown} - - ); - await using screen = await render(probe("", 0), { container }); - const show = (query: string, activeOccurrence: number) => - screen.rerender(probe(query, activeOccurrence)); - const openStates = () => - [...container.querySelectorAll("[data-markdown-details-open]")].map( - (node) => node.dataset.markdownDetailsOpen, - ); - - 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 () => { - setup(); - using _highlights = clearHighlights(); - const container = document.createElement("div"); - const diagram = "```mermaid\ngraph TD; Alpha-->Beta\n```"; - const probe = (query: string) => ( - - - - ); - const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length; - - await using screen = await render(probe("Needle"), { container }); - await frame(); - expect(diagrams()).toBe(2); - await screen.rerender(probe("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 }) => { - setup(); - const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }]; - using _highlights = clearHighlights(); - const container = document.createElement("div"); - await using _screen = await render( - - undefined : undefined} - /> - , - { container }, - ); - 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); - }, - ); -}); - -describe("ChatMarkdown inline code copying", () => { - it("copies inline code on click and Enter", async () => { - setup(); - using writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); - await using screen = await renderText("Run `vp test run` now."); - const code = screen.container.querySelector("code")!; - - await act(async () => code.click()); - await act(async () => { - code.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - }); - expect(writeText.mock.calls).toEqual([["vp test run"], ["vp test run"]]); - }); -}); - -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 }) => { - setup(); - using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); - using _clipboardItem = stubGlobal("ClipboardItem", undefined); - using writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); - using toast = vi.spyOn(toastManager, "add"); - const api = readLocalApi(); - if (!api) throw new Error("Local API unavailable"); - using choose = vi.spyOn(api.contextMenu, "show"); - await using screen = await renderText(`[File](<${href}>)`, { - cwd, - environmentId: EnvironmentId.make("env-file-copy"), - }); - const { container } = screen; - 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; - await using stack = new AsyncDisposableStack(); - const screen = stack.use( - await render(), - ); - screen.container.setAttribute("data-assistant-citation-source", "assistant"); - await act(async () => { - await import("./chat/MathTypeset"); - }); - stack.move(); - return screen; - }; - - it.each(["$x$", "$$x$$", "```math\nx\n```", "$\\notACommand{x}$"])( - "keeps keyboard focus on the parent control in compact previews of %s", - async (formula) => { - setup(); - settingsOverrides.mathRenderingEnabled = true; - await using screen = await render( - <> - - - , - ); - const { container } = screen; - 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 () => { - setup(); - settingsOverrides.mathRenderingEnabled = true; - await using screen = await render( - <> - - - , - ); - const { container } = screen; - 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 () => { - setup(); - await using rendered = await renderMath( - "| Name | Formula |\n| --- | --- |\n| Norm | $|x|$ and \\(\\|y\\|\\) |\n| Price | $5 and $10 |", - ); - - const table = rendered.container.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.container, selection)?.selector.text).toBe( - "\\(|x|\\)", - ); - }); - - it("renders explicit math fences and keeps unfinished streamed fences readable", async () => { - setup(); - await using rendered = await renderMath("```math\nx_i^2\n```\n\n`$literal$`\n\n$unfinished"); - - expect(rendered.container.querySelectorAll(".katex")).toHaveLength(1); - expect(rendered.container.textContent).toContain("$literal$"); - expect(rendered.container.textContent).toContain("$unfinished"); - - await using streamed = await renderMath("```math\nx_i^2", { isStreaming: true }); - - expect(streamed.container.querySelector(".katex")).toBeNull(); - expect(streamed.container.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) => { - setup(); - for (const text of [source, `${source}\nFollowing prose.`]) { - await using math = await renderMath(text, { isStreaming: true }); - expect(math.container.querySelector(".katex")).toBeNull(); - expect(math.container.querySelector("[data-markdown-math]")).toBeNull(); - expect(math.container.textContent).toContain("x^2"); - if (text.includes("Following prose.")) - expect(math.container.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) => { - setup(); - await using math = await renderMath(source, { isStreaming: true }); - expect(math.container.querySelectorAll(".katex")).toHaveLength(1); - expect(math.container.querySelector('[data-markdown-math="display"]')).not.toBeNull(); - }, - ); - - it("shows unsupported TeX as source and keeps active TeX commands inert", async () => { - setup(); - await using rendered = await renderMath( - "\\(\\notACommand{x}\\) and \\(\\href{https://example.com}{x}\\)", - ); - - expect(rendered.container.textContent).toContain("\\(\\notACommand{x}\\)"); - expect(rendered.container.querySelector('a[href="https://example.com"]')).toBeNull(); - }); - - it("typesets only when enabled, keeping prices and task offsets intact", async () => { - setup(); - const text = "Area $\\pi r^2$ costs $5 and $10.\n\n\\[\nE = mc^2\n\\]\n\n- [ ] check \\(x\\)"; - await using off = await renderText(text); - expect(off.container.querySelector("[data-markdown-math]")).toBeNull(); - await using html = await renderText('x'); - expect(html.container.querySelector("[data-markdown-math]")).toBeNull(); - for (const parseRawHtml of [true, false]) { - let editedText = text; - await using math = await renderMath(text, { - parseRawHtml, - onTaskListChange: ({ markerOffset, checked }) => { - editedText = setMarkdownTaskChecked(text, markerOffset, checked); - }, - }); - expect( - [...math.container.querySelectorAll("[data-markdown-math]")].map((node) => - node.getAttribute("data-markdown-copy"), - ), - ).toEqual(["\\(\\pi r^2\\)", "\\[\nE = mc^2\n\\]\n\n", "\\(x\\)"]); - await act(() => - math.container.querySelector('input[type="checkbox"]')!.click(), - ); - expect(editedText).toBe(text.replace("- [ ]", "- [x]")); - } - await using math = await renderMath("- intro\n\n $$\n x^2\n $$"); - expect( - [...math.container.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.fork.browser.test.tsx b/apps/web/src/components/ChatMarkdown.fork.browser.test.tsx new file mode 100644 index 0000000000000..36e78a766368a --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.fork.browser.test.tsx @@ -0,0 +1,314 @@ +const inlineMocks = vi.hoisted(() => ({ + run: vi.fn(), + write: vi.fn(), + listeners: new Set<() => void>(), + output: null as + | typeof import("@t3tools/client-runtime/state/inlineShell").emptyInlineShellOutput + | null, +})); +vi.mock(import("@effect/atom-react"), async (original) => { + const actual = await original(); + return { + ...actual, + useAtomValue: ((atom: unknown) => + atom === "inline-permission" ? true : actual.useAtomValue(atom as never)) as never, + }; +}); +vi.mock("../state/terminal", () => ({ + terminalEnvironment: { + runInline: { permissionAtom: () => "inline-permission" }, + write: { permissionAtom: () => "inline-permission" }, + watchInlineRun: () => "inline-record", + observeInline: () => "inline-output", + }, +})); +vi.mock(import("../state/query"), async (original) => { + const { useSyncExternalStore } = await import("react"); + return { + ...(await original()), + useEnvironmentQuery: ((atom: unknown) => { + const data = useSyncExternalStore( + (notify) => { + inlineMocks.listeners.add(notify); + return () => { + inlineMocks.listeners.delete(notify); + }; + }, + () => (atom === "inline-output" ? inlineMocks.output : null), + ); + return { data, error: null }; + }) as never, + }; +}); +vi.mock(import("../state/use-atom-command"), async (original) => { + const actual = await original(); + return { + ...actual, + useAtomCommand: ((command: unknown) => { + if (command === terminalEnvironment.runInline) return inlineMocks.run; + if (command === terminalEnvironment.write) return inlineMocks.write; + return actual.useAtomCommand(command as never); + }) as never, + }; +}); +import { terminalEnvironment } from "../state/terminal"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { AsyncResult } from "effect/reactivity"; + +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; +import "../index.css"; + +import { filesystemEnvironment } from "../state/filesystem"; + +const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ + workspaceSearch: vi.fn(), + filesystemMetadata: vi.fn(), +})); +vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ + ...(await original()), + useAtomQueryRunner: ((family: unknown) => + family === filesystemEnvironment.getMetadata ? filesystemMetadata : workspaceSearch) as never, +})); + +// 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 }, + }; +}); +// A standard client session, so environment-owned file links can open. +vi.mock(import("../state/session"), async (original) => { + const { AuthStandardClientScopes } = await import("@t3tools/contracts"); + const granted = new Set(AuthStandardClientScopes); + const hasScope = (environmentId: unknown, scope: string) => + environmentId !== null && granted.has(scope); + return { + ...(await original()), + useEnvironmentScope: hasScope as never, + readEnvironmentScope: hasScope as never, + }; +}); +vi.mock(import("../remoteOpen"), async (original) => ({ + ...(await original()), + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, +})); +const { renderDiagram } = vi.hoisted(() => ({ + renderDiagram: vi.fn<(source: string) => Promise | undefined>(), +})); +// Control rendering latency without running Mermaid layout. +vi.mock(import("./chat/MermaidDiagram"), async (original) => { + const { use } = await import("react"); + return { + ...(await original()), + MermaidDiagram: ({ source }: { source: string }) => { + const pending = renderDiagram(source); + return ; + }, + }; +}); + +const settingsOverrides = vi.hoisted(() => ({ mathRenderingEnabled: false })); +vi.mock(import("../state/environments"), () => ({ + useEnvironments: (() => ({ + isReady: false, + networkStatus: null, + environments: [], + presentationById: new Map(), + })) as never, + useEnvironmentProviders: (() => new Map()) as never, + usePrimaryEnvironmentId: (() => null) as never, + useEnvironment: (() => null) as never, + usePrimaryEnvironment: (() => null) as never, + useEnvironmentHttpBaseUrl: (() => null) as never, + useRelayEnvironmentDiscovery: (() => ({})) as never, + useEnvironmentIds: (() => []) as never, + useEnvironmentIdentities: (() => []) as never, + usePullRequestsSupported: (() => false) as never, + useIssuesSupported: (() => false) as never, + useEnvironmentMachines: (() => new Map()) as never, + useConnectedEnvironmentIds: (() => []) as never, +})); +vi.mock(import("../hooks/useSettings"), async (original) => { + const actual = await original(); + type Settings = ReturnType; + function useClientSettings(select?: (settings: Settings) => T): T { + const settings = { ...actual.getClientSettings(), ...settingsOverrides }; + return select ? select(settings) : (settings as T); + } + return { ...actual, useClientSettings }; +}); + +import ChatMarkdown from "./ChatMarkdown"; +import { InlineShellRunProvider } from "./chat/InlineShellRun"; +import { emptyInlineShellOutput } from "@t3tools/client-runtime/state/inlineShell"; + +function setup() { + settingsOverrides.mathRenderingEnabled = false; + renderDiagram.mockReset(); + workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); + filesystemMetadata.mockReset().mockResolvedValue(AsyncResult.success({ entries: [null] })); +} + +function renderText(text: string, props: Partial[0]> = {}) { + return render(); +} + +function buttonsLabelled(container: HTMLElement, label: string) { + return [...container.querySelectorAll("button")].filter( + (button) => button.getAttribute("aria-label") === label, + ); +} + +function fileOptions(container: HTMLElement) { + return [...container.querySelectorAll("button")] + .map((button) => button.getAttribute("aria-label")) + .filter((label) => label?.startsWith("File options for ")); +} + +describe("ChatMarkdown streaming", () => { + it("runs closed shell blocks inline and through the terminal command action", async () => { + setup(); + const onRunShellCommand = vi.fn(); + const onOpenInlineTerminal = vi.fn(); + const environmentId = EnvironmentId.make("inline-environment"); + const threadId = ThreadId.make("inline-thread"); + inlineMocks.run.mockReset().mockImplementation(async ({ input }) => ({ + _tag: "Success", + value: { + ...input, + terminalId: `inline-${input.runId}`, + sequence: inlineMocks.run.mock.calls.length, + status: "running", + startedAt: "2026-10-13T00:00:00.000Z", + completedAt: null, + durationMs: 0, + output: "", + exitCode: null, + error: null, + }, + })); + using _outputReset = { + [Symbol.dispose]() { + inlineMocks.output = null; + }, + }; + const message = (text: string, isStreaming = false) => ( + + + + ); + await using screen = await render(message("```bash\necho hello\n```", true)); + const { container, rerender } = screen; + const inlineButtons = () => buttonsLabelled(container, "Run inline"); + const terminalButtons = () => buttonsLabelled(container, "Run in terminal"); + expect(inlineButtons()).toHaveLength(0); + expect(terminalButtons()).toHaveLength(0); + + await rerender(message("```bash\necho hello\n```")); + await act(async () => terminalButtons()[0]!.click()); + expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello"); + onRunShellCommand.mockClear(); + + for (const [fence, command] of [ + ["```bash\necho hello\n```", "echo hello"], + ["~~~bash\necho tilde\n~~~", "echo tilde"], + ["> ```bash\n> echo quote\n> ```", "echo quote"], + ["````bash\necho four\n````", "echo four"], + ["```bash\necho one\necho two\n```", "echo one\necho two"], + ["```bash\necho trailing\n\n```", "echo trailing"], + ]) { + inlineMocks.output = { ...emptyInlineShellOutput, status: "running" }; + await rerender(message(fence!)); + expect(inlineButtons()).toHaveLength(1); + expect(terminalButtons()).toHaveLength(command!.includes("\n") ? 0 : 1); + await act(async () => inlineButtons()[0]!.click()); + expect(inlineMocks.run.mock.calls.at(-1)?.[0]).toMatchObject({ + environmentId, + input: { + threadId, + blockId: expect.stringContaining("visible-message:"), + command, + language: "bash", + }, + }); + inlineMocks.output = { + ...emptyInlineShellOutput, + status: "exited", + output: "done", + exitCode: 7, + endedAt: Date.parse("2026-10-13T00:00:02Z"), + }; + await act(async () => inlineMocks.listeners.forEach((notify) => notify())); + expect( + [...container.querySelectorAll("button")].some((button) => button.textContent === "Stop"), + ).toBe(false); + expect([...container.querySelectorAll("pre")].some((pre) => pre.textContent === "done")).toBe( + true, + ); + } + const open = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Open in terminal", + ); + await act(async () => open!.click()); + expect(onOpenInlineTerminal).toHaveBeenCalledExactlyOnceWith(expect.stringMatching(/^inline-/)); + expect(inlineMocks.run).toHaveBeenCalledTimes(6); + + for (const text of [ + "```typescript\necho hello\n```", + "```bash\n\n```", + "```bash\necho hello\\\n```", + "```bash\necho safe \u202e#\n```", + "```bash\necho incomplete", + "~~~bash\necho incomplete", + "````bash\necho incomplete\n```", + '
echo html
', + ]) { + await rerender(message(text)); + expect(inlineButtons()).toHaveLength(0); + expect(terminalButtons()).toHaveLength(0); + } + }); +}); + +describe("ChatMarkdown file citations", () => { + 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) => { + setup(); + await using screen = await renderText(text); + const { container } = screen; + expect(container.textContent).toContain(text.replace("\\", "")); + expect(fileOptions(container)).toEqual([]); + }); +}); + +describe("ChatMarkdown inline code copying", () => { + it("copies inline code on click and Enter", async () => { + setup(); + using writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + await using screen = await renderText("Run `vp test run` now."); + const code = screen.container.querySelector("code")!; + + await act(async () => code.click()); + await act(async () => { + code.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(writeText.mock.calls).toEqual([["vp test run"], ["vp test run"]]); + }); +}); 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 0e8a55d9fb6ba..0000000000000 --- 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 ef8d25e62dddc..4c63927b928d1 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -201,27 +201,6 @@ it("removes host actions after revocation while keeping file preview and copying expect(state.openEditor).not.toHaveBeenCalled(); }); -it("starts copying before the nested-file lookup finishes", async () => { - using _setup = setup(); - 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 using screen = await renderMarkdown("`readme.md:10:2`"); - await screen.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 = () => { @@ -265,21 +244,6 @@ 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) => { - using _setup = setup(); - state.allowed = false; - state.linkedPullRequest = linked ? linkedPullRequest : null; - state.choose.mockResolvedValue("open-external"); - await using screen = await renderMarkdown(`[Review](${linkedPullRequest.url})`); - await screen.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) => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2f0ec58ceea43..f29ca3b6676f1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,12 +1,11 @@ -import { InlineShellBlock } from "./chat/InlineShellRun"; -import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; -import { MarkdownFindContext, findRevealRef } from "./chat/markdownFindContext"; -import { - buildFileLinkParentSuffixByPath, - fileLinkLabel, - resolvePathLinkTarget, - splitFilePathPosition, -} from "@t3tools/shared/fileLinks"; +import { MarkdownInlineCode } from "./chat/ForkMarkdownActions"; +import { markdownImageUnavailableLabel } from "./markdownImageState"; +import { knownGitHubHosts } from "../lib/openIssueLink"; +import { parseIssueUrl } from "../lib/openIssueLink"; +import { useOpenIssueLink } from "../lib/openIssueLink"; +import { IssueLinkPreview } from "./issues/IssueLinkPreview"; +import { IssueLinkPreviewFallback } from "./issues/IssueLinkPreview"; +import { buildFileLinkParentSuffixByPath, fileLinkLabel } from "@t3tools/shared/fileLinks"; import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination, @@ -28,8 +27,6 @@ import { encodeComposerContextClipboardHtml, } from "@t3tools/shared/composerContextClipboard"; import { - ChevronRightIcon, - CodeIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -40,19 +37,14 @@ import { MessageSquareIcon, MessageSquareWarningIcon, OctagonAlertIcon, - PlayIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, - WorkflowIcon, - WrapTextIcon, type LucideIcon, } from "lucide-react"; -import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import { AssetWorkspaceAssetNotFoundError, AuthPreviewOperateScope, - type AssetResource, type EditorId, type EnvironmentId, type ServerConfig, @@ -60,12 +52,10 @@ import { type ServerProviderSkill, type ThreadPullRequestKey, } from "@t3tools/contracts"; -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { codexArtifactTemplatePresentationLabel, @@ -76,25 +66,18 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; +import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import React, { - Children, Suspense, - type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - isValidElement, use, memo, - useEffect, useLayoutEffect, useRef, useState, @@ -106,7 +89,6 @@ import type { Options as ReactMarkdownOptions, } from "react-markdown"; import ReactMarkdown from "react-markdown"; -import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; @@ -120,7 +102,6 @@ import { artifactTemplateFromHastProperties, renderCodexInlineDirectivesAsMarkdown, } from "@t3tools/shared/codexMarkdownDirectives"; -import { markdownImageUnavailableLabel } from "./markdownImageState"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { MarkdownMath } from "./chat/MarkdownMath"; import { @@ -128,13 +109,9 @@ import { type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; -import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; -import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; -import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { markdownImageGallery } from "./chat/markdownImageGallery"; +import { type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; -import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; -import { FileTagChipContent } from "./chat/FileTagChip"; -import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, @@ -143,14 +120,8 @@ import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; -import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; -import { ContextChip } from "./ContextChip"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; -import { ScrollArea } from "./ui/scroll-area"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; import { @@ -159,28 +130,15 @@ import { usePreferredEditor, } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; -import { fnv1a32 } from "../lib/diffRendering"; -import { LRUCache } from "../lib/lruCache"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { GitHubIcon } from "./Icons"; -import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; -import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; +import { resolveDiffThemeName } from "../lib/diffRendering"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; -import { MermaidDiagram } from "./chat/MermaidDiagram"; import { useRenderedAppearance } from "../hooks/useTheme"; -import { getClientSettings, useClientSettings } from "../hooks/useSettings"; -import { - chatMarkdownClipboardPayload, - serializeTableElementToCsv, - serializeTableElementToMarkdown, -} from "../markdown-clipboard"; +import { useClientSettings } from "../hooks/useSettings"; +import { chatMarkdownClipboardPayload } from "../markdown-clipboard"; import { resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, - shouldOpenMarkdownFileLinkInBrowserByDefault, - shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { @@ -191,13 +149,8 @@ import { import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; -import { - resolveRemoteOpenState, - useRemoteOpenResolution, - type RemoteOpenMode, -} from "../remoteOpen"; +import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -207,7 +160,6 @@ import { readEnvironmentScope, readPreparedConnection, useEnvironmentScope, - usePreparedConnection, } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; @@ -221,17 +173,15 @@ import { pickWorkspaceBasenameMatch, stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, -} from "../workspaceBasenameLookup"; +} from "@t3tools/client-runtime/workspace-basename-lookup"; import { parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; -import { observeResize } from "~/lib/observeResize"; import { useOpenLink } from "../browser/useOpenLink"; -import { knownGitHubHosts, parseIssueUrl, useOpenIssueLink } from "../lib/openIssueLink"; -import { useCopyToClipboard, writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; import { isBrowserPreviewFile, @@ -241,8 +191,35 @@ import { BrowserSettingsReadError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; -import { IssueLinkPreview, IssueLinkPreviewFallback } from "./issues/IssueLinkPreview"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; +import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; +import { MarkdownTable } from "./chat/MarkdownTable"; +import { MarkdownDetails } from "./chat/MarkdownDetails"; +import { + authoredImageSizeStyle, + ChatMarkdownAssetImage, + ChatMarkdownImage, + ChatMarkdownImageFallback, + ChatMarkdownVideo, + MarkdownLinkContext, + markdownImageCopy, +} from "./chat/ChatMarkdownMedia"; +import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; +import { MarkdownFileLink } from "./chat/MarkdownFileLink"; +import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; +import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; +import { rehypeTextDirection } from "./chat/markdownTextDirection"; +import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic"; +import { + extractCodeBlock, + extractFenceLanguage, + extractFenceTitle, + extractPreCodeMeta, + isClosedCodeFence, + nodeToPlainText, +} from "./chat/MarkdownCodeBlock.logic"; +import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); @@ -290,60 +267,6 @@ export interface ChatMarkdownContextReference { label: string; } -export function canUseMarkdownFileShellActions( - environmentId: EnvironmentId | null, - remoteOpenMode: RemoteOpenMode, - isRemoteOpenResolved: boolean, -): boolean { - return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; -} - -function hasMarkdownFilePrimaryAction(input: { - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; - canOpenMedia?: boolean; -}): boolean { - return ( - input.canOpenInEditor || - input.canOpenInBrowser || - input.canOpenInPanel || - input.canOpenMedia === true - ); -} - -export function markdownFileCopyMenuItems({ - targetPath, - workspaceRelativePath, -}: Pick) { - const items = [ - { id: "copy-full", label: "Copy full path", value: targetPath, title: "Full path" }, - ]; - if (workspaceRelativePath !== null) { - const position = targetPath.slice(splitFilePathPosition(targetPath).path.length); - items.unshift({ - id: "copy-relative", - label: "Copy relative path", - value: `${workspaceRelativePath}${position}`, - title: "Relative path", - }); - } - return items; -} - -export function shouldUseMarkdownFileBrowserPrimaryAction(input: { - iconPath: string; - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; -}): boolean { - return ( - input.canOpenInBrowser && - (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || - (!input.canOpenInEditor && !input.canOpenInPanel)) - ); -} - const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; @@ -357,2017 +280,136 @@ const ARTIFACT_TEMPLATE_ICON_BY_KIND = { "google-sheets": FileSpreadsheetIcon, image: ImageIcon, email: MailIcon, - slack: MessageSquareIcon, -} satisfies Record; - -function CodexArtifactTemplateCard(props: { - readonly template: CodexArtifactTemplate; - readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; -}) { - const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; - const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); - - return ( -
-
- - - - - - -
-
- {props.template.displayName} -
-
{presentationLabel}
-
-
- {props.onUse ? ( - - ) : null} -
- ); -} - -const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; -const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; -const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; -const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; - -interface MarkdownActionFailureContext { - readonly operation: string; - readonly target?: string; - readonly copyTarget?: string; -} - -function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { - console.error("[chat-markdown] action failed", context, cause); -} - -function toastCopyFailure(error: Error): void { - toastManager.add( - stackedThreadToast({ type: "error", title: "Couldn't copy", description: error.message }), - ); -} - -const highlightedCodeCache = new LRUCache( - MAX_HIGHLIGHT_CACHE_ENTRIES, - MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, -); - -function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { - const firstLineEnd = markdown.indexOf("\n", listItemStart); - const firstLine = markdown.slice( - listItemStart, - firstLineEnd === -1 ? markdown.length : firstLineEnd, - ); - const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); - if (!match?.[1]) return null; - return listItemStart + firstLine.indexOf(match[1]); -} - -function orderedListGutterStyle( - itemCount: number, - start: unknown, -): { "--list-gutter": string } | undefined { - const parsedStart = Number.parseInt(String(start ?? 1), 10); - const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; - const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); - if (markerWidth <= 1) return undefined; - return { "--list-gutter": `${markerWidth + 2}ch` }; -} - -const GITHUB_ALERT_PRESENTATIONS: Record< - string, - { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } -> = { - note: { - label: "Note", - Icon: InfoIcon, - borderClassName: "border-blue-500/70", - titleClassName: "text-blue-600 dark:text-blue-400", - }, - tip: { - label: "Tip", - Icon: LightbulbIcon, - borderClassName: "border-emerald-500/70", - titleClassName: "text-emerald-600 dark:text-emerald-400", - }, - important: { - label: "Important", - Icon: MessageSquareWarningIcon, - borderClassName: "border-purple-500/70", - titleClassName: "text-purple-600 dark:text-purple-400", - }, - warning: { - label: "Warning", - Icon: TriangleAlertIcon, - borderClassName: "border-amber-500/70", - titleClassName: "text-amber-600 dark:text-amber-500", - }, - caution: { - label: "Caution", - Icon: OctagonAlertIcon, - borderClassName: "border-red-500/70", - titleClassName: "text-red-600 dark:text-red-400", - }, -}; - -function extractFenceLanguage(className: string | undefined): string { - const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); - const raw = match?.[1] ?? "text"; - // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) - return raw === "gitignore" ? "ini" : raw; -} - -const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; -const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; - -function extractFenceTitle(meta: string | undefined): string | null { - if (!meta) return null; - const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); - const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; - if (attrTitle) return attrTitle; - return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null; -} - -function extractPreCodeMeta(node: unknown): string | undefined { - const children = ( - node as - | { - children?: Array<{ - type?: string; - tagName?: string; - data?: { meta?: unknown }; - properties?: { dataCodeMeta?: unknown }; - }>; - } - | undefined - )?.children; - const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); - const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; - return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; -} - -function isClosedCodeFence( - node: ReactMarkdownExtraProps["node"], - text: string, - code: string, -): boolean { - const start = node?.position?.start.offset; - const end = node?.position?.end.offset; - if (start === undefined || end === undefined) return false; - const source = text.slice(start, end); - const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0]; - // One class for the blockquote prefix: nested quantifiers here backtrack - // exponentially on code lines that start with many `> ` markers. - const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1]; - return ( - opening !== undefined && - closing !== undefined && - opening[0] === closing[0] && - closing.length >= opening.length && - // Only a real closer is omitted from parsed code. A CRLF stream cut after - // CR must not count as an extra source line. - source.replace(/\r$/, "").split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length - ); -} - -function nodeToPlainText(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - if (Array.isArray(node)) { - return node.map((child) => nodeToPlainText(child)).join(""); - } - if (isValidElement<{ children?: ReactNode }>(node)) { - // Raw HTML such as `
one
two
` breaks lines with `
`. - if (node.type === "br") return "\n"; - return nodeToPlainText(node.props.children); - } - return ""; -} - -function extractCodeBlock( - children: ReactNode, -): { className: string | undefined; code: string } | null { - const childNodes = Children.toArray(children); - if (childNodes.length !== 1) { - return null; - } - - const onlyChild = childNodes[0]; - if ( - !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( - onlyChild, - ) - ) { - return null; - } - // With a custom `code` component the child's type is that component, not - // the "code" tag — the hast node react-markdown attaches still names it. - if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { - return null; - } - - return { - className: onlyChild.props.className, - code: nodeToPlainText(onlyChild.props.children), - }; -} - -function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { - return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; -} - -function estimateHighlightedSize(html: string, code: string): number { - return Math.max(html.length * 2, code.length * 3); -} - -function readInitialWordWrapSetting(): boolean { - return getClientSettings().wordWrap; -} - -/** - * Whether any cell is clipped in the unwrapped layout. Only then does the wrap - * toggle change anything, so short tables hide it. Measures with the container - * briefly unwrapped and restores it before the browser paints. - */ -function tableHasClippedCells(container: HTMLElement, table: HTMLTableElement): boolean { - const viewport = table.closest('[data-slot="scroll-area-viewport"]'); - const scrollLeft = viewport?.scrollLeft ?? 0; - const previous = container.dataset.expanded; - container.dataset.expanded = "false"; - try { - for (const row of table.rows) { - for (const cell of row.cells) { - if (cell.scrollWidth > cell.clientWidth) return true; - } - } - return false; - } finally { - if (previous === undefined) delete container.dataset.expanded; - else container.dataset.expanded = previous; - // The unwrapped layout can be narrower and clamp a sideways scroll. - if (viewport && scrollLeft > 0) viewport.scrollLeft = scrollLeft; - } -} - -function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { - const containerRef = useRef(null); - const tableRef = useRef(null); - const [expanded, setExpanded] = useState(readInitialWordWrapSetting); - const [hasClippedCells, setHasClippedCells] = useState(false); - const { copyToClipboard, isCopied: copied } = useCopyToClipboard({ - target: "table", - timeout: 1200, - onError: toastCopyFailure, - }); - const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; - const copyLabel = copied ? "Copied" : "Copy table"; - - const measureClippedCells = () => { - const container = containerRef.current; - const table = tableRef.current; - if (!container || !table) return; - setHasClippedCells(tableHasClippedCells(container, table)); - }; - - // A streamed cell can grow until it clips while the table keeps its size, - // so remeasure after every render, not only on resize. - useLayoutEffect(measureClippedCells); - - // Column width changes can clip a cell without rendering this table. - useLayoutEffect(() => { - const table = tableRef.current; - if (!table) return; - return observeResize(table, measureClippedCells); - }, [measureClippedCells]); - - const handleCopy = (format: "markdown" | "csv") => { - const table = containerRef.current?.querySelector("table"); - if (!table) return; - void copyToClipboard( - format === "markdown" - ? serializeTableElementToMarkdown(table) - : serializeTableElementToCsv(table), - ); - }; - - return ( -
- - - {children} -
-
-
- {hasClippedCells ? ( - - setExpanded((value) => !value)} - aria-label={expandLabel} - /> - } - > - - - {expandLabel} - - ) : ( - - )} - - - - } - /> - } - > - - - {copyLabel} - - - handleCopy("markdown")}>Copy as Markdown - handleCopy("csv")}>Copy as CSV - - -
-
- ); -} - -function MarkdownDetails({ - children, - open = false, -}: Pick, "children" | "open">) { - const [isOpen, setIsOpen] = useState(open); - const searching = use(MarkdownFindContext); - const expanded = isOpen; - const revealForFind = () => setIsOpen(true); - // Base UI only listens for `beforematch` on a panel mounted at its first - // render; a closed panel mounts later, when find starts, so listen here. - const revealRef = findRevealRef(revealForFind); - const childNodes = Children.toArray(children); - const summaryIndex = childNodes.findIndex( - (child) => isValidElement(child) && child.type === "summary", - ); - const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null; - const summary = - isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children - ? summaryNode.props.children - : "Details"; - const content = childNodes.filter((_, index) => index !== summaryIndex); - - return ( -
- - - - {summary} - - -
- {content} -
-
-
-
- ); -} - -const THREAD_FIND_IGNORE_PROPS = { - "data-thread-find-ignore": "", -} as React.HTMLAttributes; - -export function MarkdownCodeBlockFrame({ - as: Wrapper = "div", - language, - fenceTitle, - theme, - wrapped = true, - title, - actions, - headerProps, - children, -}: { - as?: React.ElementType; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - wrapped?: boolean; - title?: React.ReactNode; - actions?: React.ReactNode; - headerProps?: React.HTMLAttributes; - children: React.ReactNode; -}) { - return ( - -
- {title ?? ( - - - - )} - {actions} -
- {children} -
- ); -} - -function MarkdownCodeBlockTitleContent({ - fenceTitle, - language, - theme, -}: { - fenceTitle: string | null; - language: string; - theme: "light" | "dark"; -}) { - if (fenceTitle) { - return ( - <> - - {fenceTitle} - - ); - } - - const fileName = syntheticFileNameForLanguageId(language); - if (!hasSpecificPierreIconForFileName(fileName)) { - return {language}; - } - return ( - - - } - > - - - {language} - - ); -} - -function MarkdownCodeBlock({ - code, - language, - fenceTitle, - theme, - onRunShellCommand, - onOpenInlineTerminal, - shellMessageId, - isStreaming, - viewToggle, - canWrap = true, - diagram = false, - children, -}: { - code: string; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - onRunShellCommand?: ((command: string) => void) | undefined; - onOpenInlineTerminal?: ((terminalId: string) => void) | undefined; - shellMessageId?: string | undefined; - isStreaming: boolean; - viewToggle?: ReactNode; - canWrap?: boolean; - diagram?: boolean; - children: ReactNode; -}) { - const { copyToClipboard, isCopied: copied } = useCopyToClipboard({ - target: "code", - timeout: 1200, - onError: toastCopyFailure, - }); - const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); - const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; - const copyLabel = copied ? "Copied" : "Copy code"; - const command = code.trim(); - const canRun = - !isStreaming && - /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) && - code.endsWith("\n") && - command.length > 0 && - !command.endsWith("\\") && - // Control and invisible format characters (bidi overrides, zero-width) can - // make the rendered command differ from what the terminal would receive. - !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1).replace(/[\n\t]/g, "")); - const canRunInTerminal = canRun && !/[\p{Cc}\p{Cf}]/u.test(command); - - const copyButton = ( - - copyToClipboard(code)} - aria-label={copyLabel} - /> - } - > - - - {copyLabel} - - ); - - if (diagram) { - return ( -
-
- {viewToggle} - {copyButton} -
- {children} -
- ); - } - - const renderFrame = (runAction: ReactNode, output: ReactNode) => ( - - {canWrap ? ( - - setWrapped((value) => !value)} - aria-label={wrapLabel} - /> - } - > - - - {wrapLabel} - - ) : null} - {runAction} - {viewToggle} - {copyButton} - - } - > - {children} - {output} - - ); - const runCommandButton = canRunInTerminal && onRunShellCommand ? ( - - onRunShellCommand(command)} - aria-label="Run in terminal" - /> - } - > - - - Run in terminal - - ) : null; - - return canRun && shellMessageId && onOpenInlineTerminal ? ( - - {(inlineAction, output) => renderFrame(<>{inlineAction}{runCommandButton}, output)} - - ) : ( - renderFrame(runCommandButton, null) - ); -} - -/** - * Inline code copies itself on click, Enter or Space; a click that ends a text - * selection leaves it alone. It stays a plain focusable `` (no button role) - * so its text remains selectable and is read as code. - */ -function MarkdownInlineCode(props: React.ComponentProps<"code">) { - const { copyToClipboard, isCopied } = useCopyToClipboard({ - target: "code", - timeout: 1200, - onError: toastCopyFailure, - }); - return ( - { - if (window.getSelection()?.toString()) return; - copyToClipboard(event.currentTarget.textContent ?? ""); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - copyToClipboard(event.currentTarget.textContent ?? ""); - }} - /> - ); -} - -function MarkdownMermaidCodeBlock({ - code, - fenceTitle, - theme, - isStreaming, - onExpand, - children, -}: { - code: string; - fenceTitle: string | null; - theme: "light" | "dark"; - isStreaming: boolean; - onExpand: (imageUrl: string) => void; - children: ReactNode; -}) { - const [showCode, setShowCode] = useState(false); - const searching = use(MarkdownFindContext); - const revealSource = () => setShowCode(true); - const sourceRevealRef = findRevealRef(revealSource); - const showDiagram = !showCode && !isStreaming && code.trim().length > 0; - const toggleLabel = showCode ? "Show diagram" : "Show code"; - return ( - - setShowCode((value) => !value)} - aria-label={toggleLabel} - /> - } - > - {showCode ? : } - - {toggleLabel} - - ) - } - > - {showDiagram ? ( - - - {code} - - } - > - - - {searching ? ( - - ) : null} - - ) : ( - children - )} - - ); -} - -interface SuspenseShikiCodeBlockProps { - className: string | undefined; - code: string; - themeName: DiffThemeName; - isStreaming: boolean; -} - -function SuspenseShikiCodeBlock({ - className, - code, - themeName, - isStreaming, -}: SuspenseShikiCodeBlockProps) { - const [hasStreamed, setHasStreamed] = useState(isStreaming); - if (isStreaming && !hasStreamed) setHasStreamed(true); - const language = extractFenceLanguage(className); - const cacheKey = createHighlightCacheKey(code, language, themeName); - // Once lines are mounted individually, keep that renderer when streaming - // finishes so switching to cached HTML cannot clear an existing selection. - const cachedHighlightedHtml = - !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null; - - if (cachedHighlightedHtml != null) { - return ( -
- ); - } - - return ( - - ); -} - -interface UncachedShikiCodeBlockProps { - code: string; - language: string; - themeName: DiffThemeName; - cacheKey: string; - isStreaming: boolean; - preserveLines: boolean; -} - -function UncachedShikiCodeBlock({ - code, - language, - themeName, - cacheKey, - isStreaming, - preserveLines, -}: UncachedShikiCodeBlockProps) { - const highlighter = use(getSyntaxHighlighterPromise(language)); - const incrementalHighlight = preserveLines - ? createIncrementalHighlightedDocument(highlighter, language, themeName) - : null; - const highlighted = (() => { - try { - if (incrementalHighlight) return incrementalHighlight(code); - return preserveLines - ? highlighter.codeToHast(code, { lang: language, theme: themeName }) - : highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch (error) { - console.warn( - `Code highlighting failed for language "${language}", falling back to plain text.`, - error instanceof Error ? error.message : error, - ); - return preserveLines - ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) - : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } - })(); - - useEffect(() => { - if (!isStreaming) { - const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); - highlightedCodeCache.set( - cacheKey, - highlightedHtml, - estimateHighlightedSize(highlightedHtml, code), - ); - } - }, [cacheKey, code, highlighted, isStreaming]); - - return typeof highlighted === "string" ? ( -
- ) : ( -
- -
- ); -} - -interface MarkdownFileLinkProps { - environmentId: EnvironmentId | null; - href: string; - targetPath: string; - iconPath: string; - workspaceRelativePath: string | null; - panelPath: string | null; - line?: number | undefined; - label: string; - copyMarkdown: string; - theme: "light" | "dark"; - threadRef?: ScopedThreadRef | undefined; - onOpen?: (() => Promise>) | undefined; - onOpenInPanel: (panelPath: string, line: number | undefined) => void; - resolveCopyPaths: () => Promise>; - openInEditorMenuLabel: string; - onOpenInBrowser?: (() => Promise>) | undefined; - onOpenMedia?: (() => void) | undefined; - onReveal?: (() => Promise>) | undefined; - revealLabel?: string | undefined; -} - -const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; - -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; - return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) - ? rewrittenHref.replaceAll("\\", "/") - : rewrittenHref; -} - -const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; - -const failedFaviconHosts = new Set(); - -function isGitHubHost(host: string): boolean { - const hostname = host.toLowerCase(); - return hostname === "github.com" || hostname.endsWith(".github.com"); -} - -const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { - const [failedHost, setFailedHost] = useState(null); - const brandIcon = isGitHubHost(host); - const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); - return ( - - {brandIcon ? ( - - ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( - - ) : ( - { - failedFaviconHosts.add(host); - setFailedHost(host); - }} - /> - )} - - ); -}); - -const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; -const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( - "max-h-[30rem]", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, -); -const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; -const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; -const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( - "h-auto w-auto object-contain", - CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, -); - -function markdownImageCopy(alt: string, src: string, title: string | undefined): string { - const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); - const titleSuffix = - title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; - return `![${escapedAlt}](${src}${titleSuffix})`; -} - -/** - * `maxHeightRem` folds a height cap into the width bound: `max-height` alone - * would not feed back through `aspect-ratio` once `width` is definite, so a - * tall image would keep a box wider than the picture it draws. - */ -function authoredImageSizeStyle( - width: string | number | undefined, - height: string | number | undefined, - maxHeightRem = 30, -): CSSProperties | undefined { - const parsedWidth = Number(width); - const parsedHeight = Number(height); - const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; - const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; - if (hasWidth && hasHeight) { - return { - width: parsedWidth, - height: "auto", - aspectRatio: `${parsedWidth} / ${parsedHeight}`, - maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, - }; - } - if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; - if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; - return undefined; -} - -const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( - CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); -const MarkdownLinkContext = React.createContext(false); - -function expandableMarkdownImageProps( - onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, - alt: string, -) { - if (!onImageExpand) return {}; - const previewName = alt.trim() || "image"; - const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { - if (event.currentTarget.closest("a")) return; - event.preventDefault(); - event.stopPropagation(); - const item = markdownImageItems.get(event.currentTarget); - if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); - }; - return { - role: "button" as const, - tabIndex: 0, - "aria-label": `Preview ${previewName}`, - onClick: expand, - onKeyDown: (event: ReactKeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") expand(event); - }, - }; -} - -function ChatMarkdownMediaUnavailableLabel(props: { - readonly alt: string; - readonly kind?: "image" | "video" | undefined; - readonly detail?: string | undefined; -}) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; - return ( - - - {[label, props.alt, props.detail].filter(Boolean).join(" · ")} - - ); -} - -function ChatMarkdownImageFallback(props: { - readonly alt: string; - readonly copyMarkdown?: string | undefined; - readonly kind?: "image" | "video" | undefined; - readonly actionsSource?: MediaActionSource | undefined; - readonly unavailableLabel?: string | undefined; -}) { - const content = ( - - {props.unavailableLabel ?? ( - - )} - - ); - return props.actionsSource ? ( - {content} - ) : ( - content - ); -} - -const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( - "aspect-video w-full overflow-hidden bg-muted/60", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); - -/** - * A standalone image holds a 16:9 slot (or its authored size) until it has - * decoded, showing the image as it arrives (a bare `` is zero height - * until then). A failure replaces it with a small chip naming the path and - * the reason. Once decoded the image renders bare again so its box, hit area, and - * alignment are exactly the image's own. Inline images (badges, icons in a - * sentence) skip the slot: a placeholder taller than the image would move the - * page more than the image does. - * - * Callers key this on the file's identity, not its URL: a re-signed URL for - * the same file keeps the decoded image on screen while the new bytes arrive, - * and a different file starts from the slot again. - */ -function ChatMarkdownImage(props: { - readonly src: string | null; - readonly sourcePath?: string | undefined; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly standalone: boolean; - readonly className?: string | undefined; - readonly style?: CSSProperties | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly actionsSource: MediaActionSource; - readonly originalUrl?: string | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const [loadedSrc, setLoadedSrc] = useState(null); - const [failedSrc, setFailedSrc] = useState(null); - const src = props.src ?? loadedSrc; - const unavailableLabel = markdownImageUnavailableLabel({ - path: props.sourcePath ?? props.alt, - loadFailed: src !== null && failedSrc === src, - reason: "Could not load or decode the image.", - }); - const settled = src !== null && (!props.standalone || loadedSrc !== null); - // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = (image: HTMLImageElement | null) => { - if (!image) return; - if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); - markdownImageItems.set(image, { - src, - name: props.alt.trim() || "image", - actionsSource: props.actionsSource, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - }); - }; - const imageEvents = (loadingSrc: string) => ({ - onLoad: () => { - setLoadedSrc(loadingSrc); - setFailedSrc(null); - }, - onError: () => { - setFailedSrc(loadingSrc); - setLoadedSrc(null); - }, - }); - - if (unavailableLabel) { - return ( - - ); - } - if (settled) { - return ( - - {props.alt} - - ); - } - if (!props.standalone) { - return ( - - Loading image: {props.sourcePath ?? props.alt} - - ); - } - return ( - - - {src !== null ? ( - {props.alt} - ) : ( - - Loading image: {props.sourcePath ?? props.alt} - - )} - - - ); -} - -function ChatMarkdownVideo(props: { - readonly src: string | null; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly originalUrl?: string | undefined; - readonly sourceFailed?: boolean | undefined; - readonly style?: CSSProperties | undefined; - readonly mediaIdentity?: string | undefined; - readonly actionsSource?: MediaActionSource | undefined; - readonly onRetry?: (() => Promise) | undefined; -}) { - return ( - - ); -} - -export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { - readonly environmentId: EnvironmentId; - readonly resource: Extract< - AssetResource, - { - readonly _tag: - | "attachment" - | "workspace-file" - | "media-file" - | "github-media" - | "tool-output-image"; - } - >; - readonly kind?: "image" | "video"; - readonly alt: string; - readonly copyMarkdown?: string; - readonly srcFragment?: string; - readonly standalone?: boolean | undefined; - readonly maxHeightRem?: number | undefined; - readonly style?: CSSProperties | undefined; - readonly className?: string | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly originalUrl?: string | undefined; - readonly framed?: boolean | undefined; - readonly fallbackSrc?: string | undefined; - readonly workspaceRoot?: string | undefined; - readonly workspaceMutationId?: string | null | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const assetUrl = useAssetUrlState(props.environmentId, props.resource); - const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; - const resource = props.resource; - const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); - const handledRefreshToken = useRef(undefined); - const assetLoading = assetUrl._tag === "Loading" || assetUrl.waiting === true; - useEffect(() => { - if (!connectionReady) { - handledRefreshToken.current = null; - return; - } - if ( - props.workspaceMutationId === undefined || - props.kind === "video" || - (resource._tag !== "media-file" && resource._tag !== "workspace-file") || - handledRefreshToken.current === refreshToken - ) { - return; - } - if (assetLoading) { - // Only the initial mount shares a pending request; edits and reconnects need a new one. - if (handledRefreshToken.current === undefined) { - handledRefreshToken.current = refreshToken; - } - return; - } - handledRefreshToken.current = refreshToken; - void refreshAssetUrl().catch(() => undefined); - }, [ - assetLoading, - connectionReady, - props.kind, - props.workspaceMutationId, - refreshAssetUrl, - refreshToken, - resource._tag, - ]); - const path = - resource._tag === "media-file" - ? resource.path - : resource._tag === "workspace-file" && props.workspaceRoot - ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` - : undefined; - const reference = path - ? mediaFileReference(path, props.workspaceRoot) - : props.originalUrl - ? mediaUrlReference(props.originalUrl) - : undefined; - const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; - const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; - const src = - assetUrl._tag === "Success" - ? assetUrl.url + (props.srcFragment ?? "") - : fallbackSrc === undefined - ? null - : fallbackSrc + (props.srcFragment ?? ""); - const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; - const maxHeightRem = props.maxHeightRem ?? 30; - const style = - props.style ?? - (knownSize - ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) - : maxHeightRem !== 30 - ? { maxHeight: `${maxHeightRem}rem` } - : undefined); - const actionsSource: MediaActionSource = { - kind: props.kind ?? "image", - name: props.alt || (props.kind ?? "image"), - src, - ...(fallbackSrc === undefined - ? { asset: { environmentId: props.environmentId, resource } } - : {}), - ...(reference ? { reference } : {}), - ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") - ? { - onOpenFile: () => - useRightPanelStore - .getState() - .openFile( - { environmentId: props.environmentId, threadId: resource.threadId }, - relativePath, - ), - } - : {}), - }; - - const unavailableLabel = markdownImageUnavailableLabel({ - path: path ?? props.originalUrl ?? props.alt, - sourceFailed: assetUrl._tag === "Failure" && fallbackSrc === undefined, - reason: - assetUrl._tag !== "Failure" - ? undefined - : assetUrl.reason === "missing-file-access" - ? MISSING_FILE_ACCESS_MESSAGE - : assetUrl.reason, - kind: props.kind, - }); - if (unavailableLabel) { - return ( - - ); - } - - if (props.kind === "video") { - return ( - - ); - } - - return ( - - ); -}); - -function leadingExternalLinkTextLength(text: string): number { - const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; - if (protocol) return protocol.length; - return Math.min(text.length, 1); -} - -function breakableExternalLinkText(text: string): ReactNode[] { - return Array.from(text, (character, index) => ( - - {character} - - - )); -} - -function plainHastText(node: unknown): string | null { - if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { - return null; - } - const parts = node.children.map((child) => { - if ( - child && - typeof child === "object" && - "type" in child && - child.type === "text" && - "value" in child && - typeof child.value === "string" - ) { - return child.value; - } - return null; - }); - return parts.every((part) => part !== null) ? parts.join("") : null; -} - -function hastPlainTextDeep(node: unknown): string { - if (!node || typeof node !== "object") return ""; - if ("type" in node && node.type === "text" && "value" in node && typeof node.value === "string") { - return node.value; - } - if (!("children" in node) || !Array.isArray(node.children)) return ""; - return node.children.map(hastPlainTextDeep).join(""); -} - -function hastHasText(node: unknown): boolean { - if (!node || typeof node !== "object") return false; - if ( - "type" in node && - node.type === "text" && - "value" in node && - typeof node.value === "string" && - node.value.trim().length > 0 - ) { - return true; - } - return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); -} - -const SANITIZED_FRAGMENT_PREFIX = "user-content-"; - -function decodeMarkdownFragmentId(href: string): string { - const encodedId = href.slice(1); - try { - return decodeURIComponent(encodedId); - } catch { - return encodedId; - } -} - -function normalizeSanitizedFragmentId(id: string): string { - let normalizedId = id; - while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { - normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); - } - return normalizedId; -} + slack: MessageSquareIcon, +} satisfies Record; -function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { - const decodedId = decodeMarkdownFragmentId(href); - const normalizedId = normalizeSanitizedFragmentId(decodedId); - const matchesFragment = (element: HTMLElement) => - element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; - const markdownRoot = anchor.closest(".chat-markdown"); - if (markdownRoot) { - const localTargets = Array.from(markdownRoot.querySelectorAll("[id]")); - const localTarget = localTargets.find(matchesFragment); - if (localTarget) return localTarget; - } +function CodexArtifactTemplateCard(props: { + readonly template: CodexArtifactTemplate; + readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; +}) { + const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; + const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); return ( - document.getElementById(decodedId) ?? - Array.from(document.querySelectorAll("[id]")).find(matchesFragment) ?? - null +
+
+ + + + + + +
+
+ {props.template.displayName} +
+
{presentationLabel}
+
+
+ {props.onUse ? ( + + ) : null} +
); } -function handleMarkdownFragmentClick(event: ReactMouseEvent, href: string) { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; - // Never let the browser follow the fragment or write it to the URL: desktop keeps - // its route in the hash, so replacing the hash navigates away from the thread. - event.preventDefault(); - findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); +function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { + const firstLineEnd = markdown.indexOf("\n", listItemStart); + const firstLine = markdown.slice( + listItemStart, + firstLineEnd === -1 ? markdown.length : firstLineEnd, + ); + const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); + if (!match?.[1]) return null; + return listItemStart + firstLine.indexOf(match[1]); } -type HeadingHastNode = { - type?: string; - tagName?: string; - properties?: Record; - children?: HeadingHastNode[]; -}; - -function githubHeadingSlug(text: string): string { - return text - .trim() - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") - .replace(/ /g, "-"); +function orderedListGutterStyle( + itemCount: number, + start: unknown, +): { "--list-gutter": string } | undefined { + const parsedStart = Number.parseInt(String(start ?? 1), 10); + const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + if (markerWidth <= 1) return undefined; + return { "--list-gutter": `${markerWidth + 2}ch` }; } -/** - * Like the sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app - * element ids; fragment lookup strips it. - */ -function rehypeHeadingIds() { - return (tree: HeadingHastNode) => { - const taken = new Set(); - const collect = (node: HeadingHastNode) => { - const id = node.properties?.id; - if (typeof id === "string") taken.add(id); - node.children?.forEach(collect); - }; - collect(tree); - const nextSuffix = new Map(); - const visit = (node: HeadingHastNode) => { - if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { - const slug = githubHeadingSlug(hastPlainTextDeep(node)); - if (node.properties?.id === undefined && slug) { - let count = nextSuffix.get(slug) ?? 0; - let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; - while (taken.has(id)) { - count += 1; - id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; - } - nextSuffix.set(slug, count); - taken.add(id); - node.properties = { ...node.properties, id }; - } - return; - } - node.children?.forEach(visit); - }; - visit(tree); - }; +const GITHUB_ALERT_PRESENTATIONS: Record< + string, + { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } +> = { + note: { + label: "Note", + Icon: InfoIcon, + borderClassName: "border-blue-500/70", + titleClassName: "text-blue-600 dark:text-blue-400", + }, + tip: { + label: "Tip", + Icon: LightbulbIcon, + borderClassName: "border-emerald-500/70", + titleClassName: "text-emerald-600 dark:text-emerald-400", + }, + important: { + label: "Important", + Icon: MessageSquareWarningIcon, + borderClassName: "border-purple-500/70", + titleClassName: "text-purple-600 dark:text-purple-400", + }, + warning: { + label: "Warning", + Icon: TriangleAlertIcon, + borderClassName: "border-amber-500/70", + titleClassName: "text-amber-600 dark:text-amber-500", + }, + caution: { + label: "Caution", + Icon: OctagonAlertIcon, + borderClassName: "border-red-500/70", + titleClassName: "text-red-600 dark:text-red-400", + }, +}; + +function normalizeMarkdownLinkHrefKey(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) + ? rewrittenHref.replaceAll("\\", "/") + : rewrittenHref; } // Heading ids are added after sanitizing, which would prefix them a second time. const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, + rehypeTextDirection, ] satisfies NonNullable; -const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< - ReactMarkdownOptions["rehypePlugins"] ->; - -function MarkdownExternalLinkContent({ - host, - plainText, - children, -}: { - host: string; - plainText: string | null; - children: ReactNode; -}) { - if (plainText) { - const leadingLength = leadingExternalLinkTextLength(plainText); - return ( - <> - - - {plainText.slice(0, leadingLength)} - - {breakableExternalLinkText(plainText.slice(leadingLength))} - - ); - } - - const childNodes = Children.toArray(children); - const firstChild = childNodes[0]; - - if (typeof firstChild === "string" && firstChild.length > 0) { - const leadingLength = leadingExternalLinkTextLength(firstChild); - return ( - <> - - - {firstChild.slice(0, leadingLength)} - - {breakableExternalLinkText(firstChild.slice(leadingLength))} - {childNodes.slice(1)} - - ); - } - - return ( - <> - - - {firstChild} - - {childNodes.slice(1)} - - ); -} - -const MarkdownFileLink = memo(function MarkdownFileLink({ - environmentId, - href, - targetPath, - iconPath, - workspaceRelativePath, - panelPath, - line, - label, - copyMarkdown, - theme, - threadRef, - onOpen, - onOpenInPanel, - openInEditorMenuLabel, - onOpenInBrowser, - onOpenMedia, - onReveal, - revealLabel, - resolveCopyPaths, -}: MarkdownFileLinkProps) { - const handleOpenInEditor = () => { - if (!onOpen) { - return; - } - void (async () => { - try { - const result = await onOpen(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleOpenInFilePreview = () => { - if (threadRef && panelPath) { - onOpenInPanel(panelPath, line); - return; - } - if (onOpenMedia) { - onOpenMedia(); - return; - } - handleOpenInEditor(); - }; - - const handleOpenInBrowser = () => { - if (!onOpenInBrowser) { - return; - } - void (async () => { - try { - const result = await onOpenInBrowser(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleRevealInFileManager = () => { - if (!onReveal) { - return; - } - void (async () => { - try { - const result = await onReveal(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleCopy = (resolveValue: () => Promise, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } - - const value = resolveValue().then((text) => formatFilePathForClipboard(text, targetPath)); - // Start the write before workspace lookup finishes so Safari retains the user gesture. - const write = - typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined" - ? navigator.clipboard.write([ - new ClipboardItem({ - "text/plain": value.then((text) => new Blob([text], { type: "text/plain" })), - }), - ]) - : value.then((text) => navigator.clipboard.writeText(text)); - void Promise.all([write, value]).then( - ([, text]) => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: text, - }); - }, - (error) => { - reportMarkdownActionFailure( - { operation: "copy-file-path", target: targetPath, copyTarget: title }, - error, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - - const showFileContextMenu = async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - - try { - const copyItems = markdownFileCopyMenuItems({ targetPath, workspaceRelativePath }); - const clicked = await api.contextMenu.show( - [ - ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), - ...copyItems.map(({ id, label }) => ({ id, label })), - ] as const, - position, - ); - - if (clicked === "preview-media") { - onOpenMedia?.(); - return; - } - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } - if (clicked === "copy-relative" || clicked === "copy-full") { - handleCopy( - () => - resolveCopyPaths().then((paths) => - clicked === "copy-relative" ? paths.relativePath : paths.fullPath, - ), - clicked === "copy-relative" ? "Relative path" : "Full path", - ); - } - } catch (cause) { - reportMarkdownActionFailure( - { operation: "show-file-context-menu", target: targetPath }, - cause, - ); - } - }; - - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const position = - event.clientX === 0 && event.clientY === 0 - ? (() => { - const bounds = event.currentTarget.getBoundingClientRect(); - return { x: bounds.left, y: bounds.bottom }; - })() - : { x: event.clientX, y: event.clientY }; - void showFileContextMenu(position); - }; - - const canOpenInEditor = onOpen !== undefined; - const canOpenInBrowser = onOpenInBrowser !== undefined; - const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); - const hasPrimaryAction = hasMarkdownFilePrimaryAction({ - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - canOpenMedia: onOpenMedia !== undefined, - }); - const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath, - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - }); - - return ( - - } - className={MARKDOWN_FILE_LINK_CLASS_NAME} - data-markdown-copy={copyMarkdown} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (useBrowserPrimaryAction) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - - ) : ( - } - aria-label={`File options for ${label}`} - aria-haspopup="menu" - className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} - data-markdown-copy={copyMarkdown} - onClick={handleContextMenu} - onContextMenu={handleContextMenu} - > - - - ) - } - /> - -
- -
-
-
- ); -}, areMarkdownFileLinkPropsEqual); - -function areMarkdownFileLinkPropsEqual( - previous: Readonly, - next: Readonly, -): boolean { - return ( - previous.href === next.href && - previous.targetPath === next.targetPath && - previous.iconPath === next.iconPath && - previous.environmentId === next.environmentId && - previous.workspaceRelativePath === next.workspaceRelativePath && - previous.panelPath === next.panelPath && - previous.line === next.line && - previous.label === next.label && - previous.copyMarkdown === next.copyMarkdown && - previous.theme === next.theme && - previous.threadRef === next.threadRef && - previous.onOpen === next.onOpen && - previous.onOpenInPanel === next.onOpenInPanel && - previous.openInEditorMenuLabel === next.openInEditorMenuLabel && - previous.onOpenInBrowser === next.onOpenInBrowser && - previous.onOpenMedia === next.onOpenMedia && - previous.onReveal === next.onReveal && - previous.resolveCopyPaths === next.resolveCopyPaths && - previous.revealLabel === next.revealLabel - ); -} +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ + rehypeHeadingIds, + rehypeTextDirection, +] satisfies NonNullable; const NO_MARKDOWN_EDITORS: ReadonlyArray = []; @@ -2963,7 +1005,11 @@ const CHAT_MARKDOWN_COMPONENTS = { return
{children}
; } return ( -
+

= {}): Thread { return makeThreadFixture({ @@ -202,112 +148,9 @@ describe("resolveDraftPromotionNavigationTarget", () => { }), ).toBeNull(); }); - - it("navigates once the run starts or startup stops", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: false, - }), - ).toBe(serverThreadRef); - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ - latestRun: { ...preparingRun, status: "failed" as const }, - }), - backgroundSubmissionPending: false, - }), - ).toBe(serverThreadRef); - }); - - it("defers while a background submission is pending", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: true, - }), - ).toBeNull(); - }); -}); - -describe("resolveEffectiveInteractionMode", () => { - it("forces build mode when legacy plan mode is disabled", () => { - expect( - resolveEffectiveInteractionMode({ - planModeEnabled: false, - composerInteractionMode: "plan", - threadInteractionMode: "plan", - }), - ).toBe("default"); - }); - - it("uses the saved mode while legacy plan mode is enabled", () => { - expect( - resolveEffectiveInteractionMode({ - planModeEnabled: true, - composerInteractionMode: null, - threadInteractionMode: "plan", - }), - ).toBe("plan"); - }); -}); - -describe("resolveThreadMetadataUpdateForNextTurn", () => { - const modelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }; - - it("updates a stale local thread branch to the active checkout", () => { - expect( - resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: modelSelection, - currentBranch: "feature/thread", - nextBranch: "feature/checkout", - }), - ).toEqual({ branch: "feature/checkout", worktreePath: null }); - }); - - it("does not write metadata when the model and branch are unchanged", () => { - expect( - resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: modelSelection, - nextModelSelection: modelSelection, - currentBranch: "feature/current", - nextBranch: "feature/current", - }), - ).toBeNull(); - }); }); describe("deriveComposerSendState", () => { - it("treats expired terminal pills as non-sendable content", () => { - const state = deriveComposerSendState({ - prompt: "[Terminal 1](t3-context://v1/terminal/ctx-expired)", - imageCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId, - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: now, - }, - ], - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.sendableTerminalContexts).toEqual([]); - expect(state.expiredTerminalContextCount).toBe(1); - expect(state.hasSendableContent).toBe(false); - }); - it("keeps text sendable while excluding expired terminal pills", () => { const state = deriveComposerSendState({ prompt: `yoo [Terminal 1](t3-context://v1/terminal/ctx-expired) waddup`, @@ -330,321 +173,9 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); - - it("treats element contexts as sendable content (no text, no images, no terminals)", () => { - const state = deriveComposerSendState({ - prompt: "", - imageCount: 0, - terminalContexts: [], - elementContextCount: 1, - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.expiredTerminalContextCount).toBe(0); - expect(state.hasSendableContent).toBe(true); - }); - - it("does NOT treat zero element contexts as sendable", () => { - expect( - deriveComposerSendState({ - prompt: "", - imageCount: 0, - terminalContexts: [], - elementContextCount: 0, - }).hasSendableContent, - ).toBe(false); - }); -}); - -describe("buildExpiredTerminalContextToastCopy", () => { - it("formats empty and omission guidance", () => { - expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ - title: "Expired terminal context won't be sent", - description: "Remove it or re-add it to include terminal output.", - }); - expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ - title: "Expired terminal contexts omitted from message", - description: "Re-add it if you want that terminal output included.", - }); - }); -}); - -describe("getStartedThreadModelChangeBlockReason", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - }, - { - instanceId: ProviderInstanceId.make("grok"), - requiresNewThreadForModelChange: true, - }, - ]; - - it("allows model changes before a provider session has started", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: false, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-other", - }, - }), - ).toBeNull(); - }); - - it("allows unchanged model selections for restricted providers", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: true, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - }), - ).toBeNull(); - }); - - it("blocks started-session model changes for providers that require a new thread", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: true, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-other", - }, - }), - ).toEqual({ - title: "Start a new chat to change models", - description: - "This provider does not allow switching models after a conversation has started.", - }); - }); -}); - -describe("resolveSendEnvMode", () => { - it("keeps worktree mode only for git repositories", () => { - expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); - expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); - }); -}); - -describe("branchMismatchKey", () => { - it("builds a key from thread id and both branches", () => { - expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( - "thread-1:feat/a:feat/b", - ); - }); - - it("returns null without a thread or mismatch", () => { - expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull(); - expect(branchMismatchKey("thread-1", null)).toBeNull(); - }); -}); - -describe("shouldShowBranchMismatchBanner", () => { - const base = { - hasMismatch: true, - isDismissed: false, - composerHasContent: false, - wasShownForCurrentMismatch: false, - }; - - it("stays hidden during passive browsing (even though the composer autofocuses)", () => { - expect(shouldShowBranchMismatchBanner(base)).toBe(false); - }); - - it("shows once the composer has draft content", () => { - expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true); - }); - - it("stays mounted after the draft clears once shown for the current mismatch", () => { - expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe( - true, - ); - }); - - it("never shows when dismissed or without a mismatch", () => { - expect( - shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }), - ).toBe(false); - expect( - shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }), - ).toBe(false); - }); -}); - -describe("shouldShowPlanFollowUpPrompt", () => { - const base = { - pendingUserInputCount: 0, - interactionMode: "plan" as const, - latestTurnSettled: true, - hasActionableProposedPlan: true, - hasComposerAttachments: false, - }; - - it("shows plan actions for a settled actionable plan without attachments", () => { - expect(shouldShowPlanFollowUpPrompt(base)).toBe(true); - }); - - it("hides plan actions while the composer has staged attachments", () => { - expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false); - }); - - it("preserves the existing plan follow-up gates", () => { - expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false); - }); -}); - -describe("session branch mismatch dismissal", () => { - it("tracks dismissed keys and treats other keys as active", () => { - expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); - dismissBranchMismatchForSession("t1:a:b"); - expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true); - expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false); - expect(isBranchMismatchDismissedForSession(null)).toBe(false); - }); -}); - -describe("resolveTerminalWorktreePath", () => { - it("uses the terminal's checkout identity until neither launch nor session is known", () => { - const threadWorktreePath = "/repo/.worktrees/feature"; - - expect( - resolveTerminalWorktreePath({ - launchContext: { worktreePath: null }, - summary: { worktreePath: "/repo/.worktrees/other" }, - threadWorktreePath, - }), - ).toBeNull(); - - expect( - resolveTerminalWorktreePath({ - launchContext: null, - summary: { worktreePath: null }, - threadWorktreePath, - }), - ).toBeNull(); - - expect( - resolveTerminalWorktreePath({ launchContext: null, summary: null, threadWorktreePath }), - ).toBe(threadWorktreePath); - }); -}); - -describe("reconcileMountedTerminalThreadIds", () => { - it("keeps open threads and makes the active thread most recent", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: ["thread-a", "thread-b", "thread-c"], - openThreadIds: ["thread-a", "thread-b", "thread-c"], - activeThreadId: "thread-a", - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, - }), - ).toEqual(["thread-b", "thread-c", "thread-a"]); - }); - - it("drops closed threads and enforces the hidden mounted cap", () => { - const ids = Array.from( - { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, - (_, index) => `thread-${index}`, - ); - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: ids, - openThreadIds: ids.slice(1), - activeThreadId: null, - activeThreadTerminalOpen: false, - }), - ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); - }); -}); - -describe("shouldWriteThreadErrorToCurrentServerThread", () => { - it("requires the environment, route thread, and target thread to match", () => { - const routeThreadRef = { environmentId, threadId }; - - expect( - shouldWriteThreadErrorToCurrentServerThread({ - serverThread: { environmentId, id: threadId }, - routeThreadRef, - targetThreadId: threadId, - }), - ).toBe(true); - expect( - shouldWriteThreadErrorToCurrentServerThread({ - serverThread: null, - routeThreadRef, - targetThreadId: threadId, - }), - ).toBe(false); - }); -}); - -describe("startNewThreadForProject", () => { - it("starts a thread through the supplied shared handler for the active project", () => { - const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; - const projectRef = { environmentId, projectId }; - - expect( - startNewThreadForProject(projectRef, (nextProjectRef) => { - calls.push(nextProjectRef); - return Promise.resolve(); - }), - ).toBe(true); - expect(calls).toEqual([projectRef]); - }); - - it("does nothing when the active project is unavailable", () => { - let called = false; - - expect( - startNewThreadForProject(null, () => { - called = true; - return Promise.resolve(); - }), - ).toBe(false); - expect(called).toBe(false); - }); }); describe("hasServerAcknowledgedLocalDispatch", () => { - it("does not acknowledge unchanged server state", () => { - const localDispatch = createLocalDispatchSnapshot( - makeThread({ latestRun: completedTurn, runtime: readySession }), - ); - - expect( - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: "ready", - latestRun: completedTurn, - runtime: readySession, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }), - ).toBe(false); - }); - it("acknowledges a settled newer background turn", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), @@ -767,23 +298,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { }), ).toBe(true); }); - - it("acknowledges pending user interaction and errors immediately", () => { - const localDispatch = createLocalDispatchSnapshot(makeThread()); - const common = { - localDispatch, - phase: "ready" as const, - latestRun: null, - runtime: null, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }; - - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); - }); }); describe("deriveCommittedServerUserMessageIds", () => { @@ -886,185 +400,6 @@ describe("deriveCommittedServerUserMessageIds", () => { }); }); -describe("agent browser close confirmation", () => { - const surfaces = [ - { id: "browser:one", kind: "preview", resourceId: "tab-1" }, - { id: "browser:two", kind: "preview", resourceId: "tab-2" }, - { id: "diff", kind: "diff" }, - ] satisfies RightPanelSurface[]; - - it("only warns for browsers under active agent control", () => { - expect( - agentControlledBrowserCloseConfirmation(surfaces, { - "tab-1": { controller: "none" }, - "tab-2": { controller: "human" }, - }), - ).toBeNull(); - - expect( - agentControlledBrowserCloseConfirmation([surfaces[0]!], { - "tab-1": { controller: "agent" }, - }), - ).toBe( - [ - "Close browser while the agent is using it?", - "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", - ].join("\n"), - ); - }); - - it("counts every agent-controlled browser in a bulk close", () => { - expect( - agentControlledBrowserCloseConfirmation(surfaces, { - "tab-1": { controller: "agent" }, - "tab-2": { controller: "agent" }, - }), - ).toContain("Close 2 browsers"); - }); -}); - -describe("floating browser preview", () => { - it("only hides the duplicate while the same browser is rendered in the panel", () => { - expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { - id: "browser:one", - kind: "preview", - resourceId: "tab-1", - }, - ), - ).toBe(false); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { - id: "browser:two", - kind: "preview", - resourceId: "tab-2", - }, - ), - ).toBe(true); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { id: "diff", kind: "diff" }, - ), - ).toBe(true); - }); -}); - -describe("proactive panels", () => { - it("opens an existing pull request on entry and follows newly observed links", () => { - expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(true); - expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true); - expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false); - expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); - }); - - it("opens a completed diff on entry or when the observed running turn settles", () => { - const turnId = RunId.make("turn-1"); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: undefined, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(true); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(true); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: RunId.make("turn-2"), - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(false); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: false, - }), - ).toBe(false); - }); -}); - -describe("artifact template composer insertion", () => { - it("does not insert an already-present prompt", () => { - const prompt = "Create a document using this $artifact-template-hello-world about…"; - - expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); - }); -}); - -describe("draft hero submission transition", () => { - it("does not dock the composer before a background submission", () => { - expect( - shouldDockDraftHeroForSubmission({ - isDraftHeroState: true, - activeThreadKey: "environment-local:thread-1", - submissionIntent: "background", - }), - ).toBe(false); - }); - - it("leaves the hero layout while a worktree setup card is on the timeline", () => { - expect( - resolveDraftHeroState({ - isLocalDraftThread: true, - hasTimelineEntries: false, - isWorking: false, - draftHeroDockRequested: false, - backgroundSubmissionPending: false, - hasWorktreeSetupCard: true, - }), - ).toBe(false); - expect( - resolveDraftHeroState({ - isLocalDraftThread: true, - hasTimelineEntries: false, - isWorking: false, - draftHeroDockRequested: false, - backgroundSubmissionPending: true, - hasWorktreeSetupCard: true, - }), - ).toBe(false); - }); - - it("keeps the composer in the hero layout until navigation after server promotion", () => { - expect( - resolveDraftHeroState({ - isLocalDraftThread: false, - hasTimelineEntries: true, - isWorking: true, - draftHeroDockRequested: false, - backgroundSubmissionPending: true, - }), - ).toBe(true); - }); - - it("does not auto-navigate a background submission after server promotion", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef: { environmentId, threadId }, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: true, - }), - ).toBeNull(); - }); -}); - describe("resolveThreadSwitchTimeline", () => { afterEach(() => { resetHeldThreadTimeline(); @@ -1072,53 +407,6 @@ describe("resolveThreadSwitchTimeline", () => { const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; - it("leaves an uncached destination empty while its history loads", () => { - rememberReadyThreadTimeline(held); - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-b", - nextEntries: [], - lastReady: held, - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-b" }); - }); - - it("shows the new thread once its detail is ready", () => { - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-b", - nextEntries: ["b1"], - lastReady: held, - }), - ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" }); - }); - - it("does not invent a timeline on the first open of a thread", () => { - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-a", - nextEntries: [], - lastReady: null, - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - - it("keeps the held thread workspace cwd with the snapshot", () => { - rememberReadyThreadTimeline({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - expect(peekHeldThreadTimeline()).toEqual({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - }); - it("paints a remembered destination instead of the last-viewed thread", () => { rememberReadyThreadTimeline(held); rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] }); @@ -1132,28 +420,6 @@ describe("resolveThreadSwitchTimeline", () => { ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); }); - it("prefers live entries over a remembered snapshot", () => { - rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] }); - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-b", - nextEntries: ["fresh-b"], - }), - ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" }); - }); - - it("does not keep a remembered snapshot on a resolved empty thread", () => { - rememberReadyThreadTimeline(held); - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-a", - nextEntries: [], - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - it("does not hold another environment's timeline across a jump", () => { expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); expect( @@ -1166,11 +432,6 @@ describe("resolveThreadSwitchTimeline", () => { ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); }); - it("treats a foreign held timeline as paint-only", () => { - expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true); - expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false); - }); - it("does not remember a timeline that still has handoff blob previews", () => { expect( timelineHasEphemeralPreviewUrls([ @@ -1227,150 +488,6 @@ describe("resolveThreadSwitchTimeline", () => { }); }); -describe("shouldReleaseTimelineAnchorForToolActivity", () => { - const activeTurnId = RunId.make("active-turn"); - const anchorMessageId = MessageId.make("anchored-message"); - const activeToolEntry = { - id: "tool-entry", - kind: "work" as const, - createdAt: now, - entry: { - id: "active-tool", - createdAt: now, - runId: activeTurnId, - label: "Run command", - tone: "tool" as const, - command: "git status", - }, - }; - - it("releases the send anchor for tool activity in the active turn", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }), - ).toBe(true); - }); - - it("keeps the anchor while the user reads history", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: false, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }), - ).toBe(false); - }); - - it("ignores tool activity from earlier turns", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [ - { - ...activeToolEntry, - entry: { - ...activeToolEntry.entry, - runId: RunId.make("previous-turn"), - }, - }, - ], - }), - ).toBe(false); - }); - - it("ignores thinking and error rows without tool activity", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [ - { - ...activeToolEntry, - entry: { - id: "thinking-entry", - createdAt: now, - runId: activeTurnId, - label: "Thinking", - tone: "thinking", - }, - }, - { - ...activeToolEntry, - id: "error-entry", - entry: { - id: "error-entry", - createdAt: now, - runId: activeTurnId, - label: "Provider error", - tone: "error", - }, - }, - ], - }), - ).toBe(false); - }); - - it("does nothing without an anchor or running turn", () => { - const input = { - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }; - - expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( - false, - ); - expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( - false, - ); - }); -}); - -describe("environment reconnect warning grace", () => { - afterEach(() => vi.useRealTimers()); - - it("shows a persistent reconnect after the grace period", () => { - vi.useFakeTimers(); - const showWarning = vi.fn(); - - scheduleEnvironmentReconnectWarning(showWarning); - vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); - expect(showWarning).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(showWarning).toHaveBeenCalledOnce(); - }); - - it("cancels the warning when the connection recovers during the grace period", () => { - vi.useFakeTimers(); - const showWarning = vi.fn(); - - const cancel = scheduleEnvironmentReconnectWarning(showWarning); - cancel(); - vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); - - expect(showWarning).not.toHaveBeenCalled(); - }); - - it("does not reuse elapsed grace from another environment", () => { - const anotherEnvironmentId = EnvironmentId.make("environment-remote"); - - expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); - expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( - false, - ); - }); -}); - describe("resolveComposerProviderSelection", () => { const catalogModels: ServerProvider["models"] = [ { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null }, @@ -1477,68 +594,6 @@ describe("resolveComposerProviderSelection", () => { }, ); - it("leaves a new draft free to select a different driver", () => { - const original = entry("claudeAgent", "claude_work"); - const selected = entry("codex", "codex_work"); - expect( - deriveLockedProvider({ - thread: makeThread({ - modelSelection: { instanceId: original.instanceId, model: "default" }, - }), - selectedProvider: selected.instanceId, - threadProvider: original.instanceId, - providers: [original.snapshot, selected.snapshot], - }), - ).toBeNull(); - }); - - it("uses the custom instance's capability instead of the default instance", () => { - const defaultEntry = entry("antigravity", "antigravity", { - showInteractionModeToggle: true, - }); - const customEntry = entry("antigravity", "google_work", { - showInteractionModeToggle: false, - }); - const selection = resolveComposerProviderSelection({ - entries: [defaultEntry, customEntry], - candidateInstanceIds: [customEntry.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry?.instanceId).toBe(customEntry.instanceId); - expect( - resolveComposerInteractionMode({ - provider: selection.selectedProviderEntry?.snapshot, - planModeEnabled: true, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("uses the fallback provider's plan capability after the draft's instance is disabled", () => { - const disabledEntry = entry("antigravity", "antigravity", { - enabled: false, - showInteractionModeToggle: false, - }); - const fallbackEntry = entry("codex"); - const selection = resolveComposerProviderSelection({ - entries: [disabledEntry, fallbackEntry], - candidateInstanceIds: [disabledEntry.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry?.instanceId).toBe(fallbackEntry.instanceId); - expect( - resolveComposerInteractionMode({ - provider: selection.selectedProviderEntry?.snapshot, - planModeEnabled: true, - interactionMode: "plan", - }), - ).toEqual({ enabled: true, interactionMode: "plan" }); - }); - it("keeps a signed-out selection instead of silently switching providers", () => { const signedOutEntry = entry("antigravity", "google_work", { status: "error", @@ -1558,48 +613,6 @@ describe("resolveComposerProviderSelection", () => { ).toBe("Sign in to Antigravity in provider settings before sending."); }); - it("blocks sends until the selected Antigravity profile is installed", () => { - const provider = entry("antigravity", "google_work", { - installed: false, - models: catalogModels, - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( - "Install Antigravity in provider settings before sending.", - ); - }); - - it("lets Antigravity check saved credentials when resuming after a restart", () => { - const provider = entry("antigravity", "google_work", { - status: "warning", - auth: { status: "unknown" }, - models: [], - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); - expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull(); - expect( - getAntigravitySendBlockReason({ ...provider, models: catalogModels }, "gemini-pro"), - ).toBeNull(); - expect(getAntigravitySendBlockReason(provider, "")).toBe( - "Choose an Antigravity model before sending.", - ); - }); - - it("blocks saved model sends until Antigravity loads its account catalog", () => { - expect(getAntigravitySendBlockReason(entry("antigravity").snapshot, "gemini-pro")).toBe( - "Refresh Antigravity models in provider settings before sending.", - ); - }); - - it("blocks an empty Antigravity selection after the catalog has loaded", () => { - const provider = entry("antigravity", "google_work", { models: catalogModels }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "")).toBe( - "Choose an Antigravity model before sending.", - ); - }); - it("blocks a saved model that a ready catalog no longer lists", () => { const provider = entry("antigravity", "google_work", { status: "ready", @@ -1619,18 +632,8 @@ describe("resolveComposerProviderSelection", () => { }).snapshot; expect( - getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog"), - ).toBeNull(); - }); - - it("keeps existing send behavior for other providers", () => { - const provider = entry("codex", "codex", { - installed: false, - auth: { status: "unknown" }, - models: [], - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull(); + getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog"), + ).toBeNull(); }); it("does not continue an existing Antigravity thread in another profile after deletion", () => { @@ -1646,18 +649,6 @@ describe("resolveComposerProviderSelection", () => { expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); }); - it("does not treat the empty draft placeholder as a provider setup target", () => { - const selection = resolveComposerProviderSelection({ - entries: [entry("antigravity", "antigravity", { enabled: false })], - candidateInstanceIds: [NO_PROVIDER_MODEL_SELECTION.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry).toBeUndefined(); - expect(selection.unavailableProviderInstanceId).toBeUndefined(); - }); - it("keeps the session's continuation group when another instance was selected", () => { const sessionEntry = entry("antigravity", "google_work", { enabled: false, @@ -1677,404 +668,13 @@ describe("resolveComposerProviderSelection", () => { }); }); -describe("resolveComposerInteractionMode", () => { - it("resets a restored plan draft when the selected instance does not support plan mode", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: { showInteractionModeToggle: false }, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("keeps legacy plan behavior for providers that omit the capability", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: {}, - interactionMode: "plan", - }), - ).toEqual({ enabled: true, interactionMode: "plan" }); - }); - - it("resets a restored plan draft when the beta setting is off", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: false, - provider: { showInteractionModeToggle: true }, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("disables plan mode until the selected provider is available", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: null, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); -}); - -describe("resolveBackgroundDraftWorkspaceOptions", () => { - it("keeps New worktree selected without reusing the launched worktree", () => { - expect( - resolveBackgroundDraftWorkspaceOptions({ - envMode: "worktree", - branch: "main", - startFromOrigin: true, - }), - ).toEqual({ - envMode: "worktree", - branch: "main", - worktreePath: null, - startFromOrigin: true, - }); - }); -}); - -describe("proactive completed diff guard", () => { - it.each([ - { files: 0, additions: 0, deletions: 0, action: "ignore" }, - { files: 1, additions: 1, deletions: 0, action: "ignore" }, - { files: 2, additions: 12, deletions: 12, action: "ignore" }, - { files: 1, additions: 25, deletions: 24, action: "ignore" }, - { files: 1, additions: 25, deletions: 25, action: "open" }, - { files: 1, additions: 0, deletions: 50, action: "open" }, - { files: 3, additions: 1, deletions: 0, action: "open" }, - ])( - "uses change size for automatic diffs: $files files, +$additions/-$deletions", - ({ files, additions, deletions, action }) => { - const changedCheckpoint = { - status: "ready", - files: Array.from({ length: files }, (_, index) => ({ - path: `src/app-${index}.ts`, - kind: "modified" as const, - additions, - deletions, - })), - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: true, - }), - ).toBe(action); - }, - ); - - it("waits for definitive checkpoint and repository state", () => { - const missingCheckpoint = { - status: "missing", - files: [], - } satisfies Pick; - const changedCheckpoint = { - status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: undefined, - isGitRepo: true, - }), - ).toBe("defer"); - expect( - resolveProactiveTurnDiffAction({ - checkpoint: missingCheckpoint, - isGitRepo: true, - }), - ).toBe("defer"); - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: undefined, - }), - ).toBe("defer"); - }); - - it("delegates a qualifying completed diff to the store while a pull request is active", () => { - const changedCheckpoint = { - status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 50, deletions: 0 }], - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: true, - }), - ).toBe("open"); - }); - - it("delegates a qualifying completion to the store while a diff is already open", () => { - const largeCheckpoint = { - status: "ready", - files: Array.from({ length: 3 }, (_, index) => ({ - path: `src/app-${index}.ts`, - kind: "modified" as const, - additions: 20, - deletions: 0, - })), - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: largeCheckpoint, - isGitRepo: true, - }), - ).toBe("open"); - }); -}); - -describe("Diff scope across right-panel tab switches", () => { - const threadRef: ScopedThreadRef = { environmentId, threadId }; - const otherThreadRef: ScopedThreadRef = { environmentId, threadId: ThreadId.make("thread-2") }; - let explicitDiffOpen: ScopedThreadRef | null = null; - - const diffIsActive = (ref: ScopedThreadRef) => - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, ref) === "diff"; - const diffScope = (ref: ScopedThreadRef) => - selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, ref).kind; - const settle = (ref: ScopedThreadRef) => { - const explicit = explicitDiffOpen; - explicitDiffOpen = null; - if (diffIsActive(ref) && explicit !== ref) { - useDiffPanelStore.getState().selectGitScope(ref, "branch"); - } - }; - const clickTab = (ref: ScopedThreadRef, surfaceId: string) => { - const surface = selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - ref, - ).surfaces.find((entry) => entry.id === surfaceId); - explicitDiffOpen = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface!.kind, - diffOpen: diffIsActive(ref), - activeThreadRef: ref, - }); - useRightPanelStore.getState().activateSurface(ref, surfaceId); - settle(ref); - }; - - beforeEach(() => { - explicitDiffOpen = null; - useRightPanelStore.setState({ - byThreadKey: {}, - userActionRevisionByThreadKey: {}, - closeRevisionByThreadKey: {}, - }); - useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); - }); - - it("keeps Uncommitted when the user clicks back to the Diff tab", () => { - const panels = useRightPanelStore.getState(); - panels.open(threadRef, "diff"); - settle(threadRef); - useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged"); - - panels.open(threadRef, "files"); - settle(threadRef); - clickTab(threadRef, "diff"); - - expect(diffScope(threadRef)).toBe("unstaged"); - }); - - it("still opens on Changes for generic openings", () => { - const panels = useRightPanelStore.getState(); - const diffStore = useDiffPanelStore.getState(); - - panels.open(threadRef, "diff"); - settle(threadRef); - expect(diffScope(threadRef)).toBe("branch"); - - diffStore.selectGitScope(threadRef, "unstaged"); - panels.open(threadRef, "files"); - settle(threadRef); - panels.closeSurface(threadRef, "files"); - settle(threadRef); - expect(diffScope(threadRef)).toBe("branch"); - - diffStore.selectGitScope(threadRef, "unstaged"); - panels.open(otherThreadRef, "diff"); - diffStore.selectGitScope(otherThreadRef, "unstaged"); - settle(otherThreadRef); - expect(diffScope(otherThreadRef)).toBe("branch"); - }); - - it("marks only a click that brings the Diff tab on screen", () => { - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "diff", - diffOpen: false, - activeThreadRef: threadRef, - }), - ).toBe(threadRef); - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "diff", - diffOpen: true, - activeThreadRef: threadRef, - }), - ).toBeNull(); - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "files", - diffOpen: false, - activeThreadRef: threadRef, - }), - ).toBeNull(); - }); -}); - -describe("shouldRefocusComposerOnWindowFocus", () => { - function element( - tagName: string, - options?: { editable?: boolean; role?: string; within?: string }, - ) { - return { - tagName, - isContentEditable: options?.editable ?? false, - getAttribute: (name: string) => (name === "role" ? (options?.role ?? null) : null), - closest: (selector: string) => - options?.within !== undefined && selector.includes(options.within) ? ({} as Element) : null, - }; - } - - it("refocuses when nothing or the body holds focus", () => { - expect(shouldRefocusComposerOnWindowFocus(null)).toBe(true); - expect(shouldRefocusComposerOnWindowFocus(element("BODY"))).toBe(true); - }); - - it("refocuses away from a plain button, such as a pull request tab", () => { - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON"))).toBe(true); - }); - - it("leaves other text fields alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("INPUT"))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("TEXTAREA"))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { editable: true }))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { role: "textbox" }))).toBe(false); - }); - - it.each(["IFRAME", "WEBVIEW"])("leaves a focused %s preview alone", (tagName) => { - expect(shouldRefocusComposerOnWindowFocus(element(tagName))).toBe(false); - }); - - it("leaves a focused device screen alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { within: "application" }))).toBe( - false, - ); - }); - - it("leaves a focused terminal alone in the drawer and the right panel", () => { - expect( - shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "data-terminal-owner" })), - ).toBe(false); - }); - - it("leaves focus inside a dialog or popup alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "dialog" }))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); - }); -}); - describe("checkout Git memory", () => { - it("answers from the last status seen for the same checkout", () => { - rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); - expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); - rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); - expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); - }); - - it("does not answer for a checkout it has not seen", () => { - expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); - expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); - }); - - it("keeps environments apart", () => { - rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); - expect( - recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), - ).toBeUndefined(); - }); - it("does not confuse an environment id containing the separator with a path", () => { rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); }); }); -describe("threadShellHasStarted", () => { - it("counts a thread that has a user message but no latest turn", () => { - expect( - threadShellHasStarted({ latestRun: null, latestUserMessageAt: now, runtime: null }), - ).toBe(true); - }); - - it("counts a thread with a live runtime and nothing else", () => { - expect( - threadShellHasStarted({ - latestRun: null, - latestUserMessageAt: null, - runtime: { - providerInstanceId: ProviderInstanceId.make("codex"), - status: "starting", - providerName: "codex", - activeRunId: null, - lastError: null, - updatedAt: now, - }, - }), - ).toBe(true); - }); - - it("does not count a thread that never sent anything", () => { - expect( - threadShellHasStarted({ latestRun: null, latestUserMessageAt: null, runtime: null }), - ).toBe(false); - expect(threadShellHasStarted(null)).toBe(false); - }); -}); - -it("follows a changed server PR link without replacing an unrelated open panel", () => { - const previous = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const current = { - ...previous, - number: 43, - url: "https://github.com/pingdotgg/t3code/pull/43", - }; - const surface = { - id: "pull-request:previous", - kind: "pull-request", - projectId: previous.projectId, - repository: "PingDotGG/T3Code", - number: previous.number, - } satisfies RightPanelSurface; - - expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true); - expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false); - expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false); - expect(shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 })).toBe( - false, - ); - expect( - shouldRetargetThreadPullRequestPanel(previous, current, { - ...surface, - projectId: "another-project", - }), - ).toBe(false); -}); - describe("worktree setup visibility", () => { const stage = ( id: "fetch" | "checkout" | "submodules" | "setup-script" | "agent", @@ -2108,30 +708,6 @@ describe("worktree setup visibility", () => { stages: [stage("checkout", "done"), stage("setup-script", "done"), stage("agent", "done")], }; - it("keeps setup presentation continuous until the provider handoff", () => { - const progress = ( - localPreparing: boolean, - runStatus: NonNullable["status"] | undefined, - latest: WorktreeSetupSnapshot | null, - held: WorktreeSetupSnapshot | null = null, - ) => resolveWorktreeSetupProgress({ threadId, localPreparing, runStatus, latest, held }); - - expect(progress(true, undefined, null).isPreparingWorktree).toBe(true); - expect(progress(false, "preparing", null).isPreparingWorktree).toBe(true); - expect(progress(false, "preparing", base).snapshot).toBe(base); - expect(progress(false, "starting", base).isPreparingWorktree).toBe(true); - const handedOff = { - ...base, - sequence: 2, - stages: [stage("setup-script", "running"), stage("agent", "done")], - }; - expect(progress(false, "starting", handedOff, base)).toEqual({ - snapshot: handedOff, - isPreparingWorktree: false, - }); - expect(progress(false, "running", null, handedOff).snapshot).toBe(handedOff); - }); - it("uses streamed setup progress immediately without reverting to an older held snapshot", () => { const newest = { ...settledDone, sequence: 9 }; const resolve = (latest: WorktreeSetupSnapshot | null, held: WorktreeSetupSnapshot | null) => @@ -2148,22 +724,6 @@ describe("worktree setup visibility", () => { expect(resolve(other, other)).toEqual({ snapshot: null, isPreparingWorktree: false }); }); - it.each(["failed", "cancelled"] as const)( - "does not keep %s setup in the preparing state", - (phase) => { - const snapshot = { ...base, phase }; - expect( - resolveWorktreeSetupProgress({ - threadId, - localPreparing: false, - runStatus: "failed", - latest: snapshot, - held: base, - }), - ).toEqual({ snapshot, isPreparingWorktree: false }); - }, - ); - it("reads the settled snapshot back from the thread's activities", () => { const activities = [ { kind: "setup-script.started", payload: {} }, @@ -2173,69 +733,6 @@ describe("worktree setup visibility", () => { expect(findRecordedWorktreeSetup(activities, threadId)).toEqual(settledDone); expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull(); }); - - it("shows a running setup and drops a clean one once the turn started", () => { - const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) => - resolveVisibleWorktreeSetup({ - live: null, - recorded: snapshot, - turnStarted, - followUpSent: false, - }); - expect( - resolveVisibleWorktreeSetup({ - live: base, - recorded: null, - turnStarted: false, - followUpSent: false, - }), - ).toEqual(base); - expect(visible(settledDone, false)).toEqual(settledDone); - expect(visible(settledDone, true)).toBeNull(); - expect(visible(null, true)).toBeNull(); - }); - - it("keeps a failed script, a failed setup, and a cancelled setup visible", () => { - const scriptFailed = { - ...settledDone, - stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], - }; - const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) => - resolveVisibleWorktreeSetup({ - live: null, - recorded: snapshot, - turnStarted: true, - followUpSent, - }); - expect(visible(scriptFailed)).toEqual(scriptFailed); - const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; - expect(visible(failed)).toEqual(failed); - const cancelled = { ...settledDone, phase: "cancelled" as const }; - expect(visible(cancelled)).toEqual(cancelled); - - expect(visible(scriptFailed, true)).toBeNull(); - expect(visible(failed, true)).toBeNull(); - expect(visible(cancelled, true)).toBeNull(); - expect(visible(settledDone, true)).toBeNull(); - const stillRunning = { - ...base, - stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")], - }; - expect(visible(stillRunning, true)).toEqual(stillRunning); - }); - - it("prefers whichever snapshot is newer by sequence", () => { - const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => - resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false }); - expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ - ...settledDone, - sequence: 7, - }); - expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({ - ...settledDone, - sequence: 9, - }); - }); }); describe("waitForRevertedMessage", () => { @@ -2503,25 +1000,6 @@ describe("waitForRevertedMessage", () => { vi.useRealTimers(); } }); - - it("ignores a failure recorded for an earlier rollback", async () => { - vi.useFakeTimers(); - const { state, projection } = projectionAtom(); - const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {}, 50); - const settled = expect(waiting).rejects.toThrow("Timed out waiting for the thread to rewind."); - appAtomRegistry.set(state, { - data: Option.some({ - ...projection, - thread: { - ...projection.thread, - rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." }, - }, - }), - }); - await vi.advanceTimersByTimeAsync(50); - await settled; - vi.useRealTimers(); - }); }); describe("mergeDirectPreviewAnnotation", () => { @@ -2546,17 +1024,6 @@ describe("mergeDirectPreviewAnnotation", () => { const image = (id: string) => ({ id, type: "image", name: `${id}.png`, mimeType: "image/png", sizeBytes: 1 }) as never; - it("appends the screenshot and claims the crop when the turn has room", () => { - const result = mergeDirectPreviewAnnotation({ - images: [], - fileCount: 0, - previewAnnotations: [], - direct: { annotation: annotation("a"), image: image("crop") }, - }); - expect(result.images.map((entry) => entry.id)).toEqual(["crop"]); - expect(result.previewAnnotations[0]?.screenshot?.dataUrl).toBe(""); - }); - it("drops the screenshot claim when the attachment cap is full", () => { const result = mergeDirectPreviewAnnotation({ images: [], @@ -2567,41 +1034,208 @@ describe("mergeDirectPreviewAnnotation", () => { expect(result.images).toEqual([]); expect(result.previewAnnotations[0]?.screenshot).toBeNull(); }); +}); - it("keeps an annotation already in the draft as it is", () => { - const existing = [annotation("a")]; - const result = mergeDirectPreviewAnnotation({ +describe("snapshotComposerSend", () => { + const expired = { + id: "ctx-expired", + threadId, + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + createdAt: now, + }; + const live = { ...expired, id: "ctx-live", text: "pnpm test", lineStart: 9, lineEnd: 9 }; + + it("drops expired terminal contexts from the contexts and from the prompt", () => { + const snapshot = snapshotComposerSend({ + prompt: ensureInlineContextReferences("run", [ + terminalContextReference(expired), + terminalContextReference(live), + ]), images: [], - fileCount: 0, - previewAnnotations: existing, - direct: { annotation: annotation("a"), image: null }, + files: [], + terminalContexts: [expired, live], + sendableTerminalContexts: [live], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], }); - expect(result.previewAnnotations).toBe(existing); + + expect(snapshot.terminalContexts).toEqual([live]); + expect(snapshot.messageText).toBe( + ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(), + ); }); }); -describe("isThreadWokeUnseen", () => { +describe("planComposerTurn", () => { const base = { - wokeAt: "2026-10-16T10:00:00.000Z", - settled: false, - lastVisitedAt: null, - latestRunCompletedAt: null, + messageText: "hello", + sendQueuesBehindSetup: false, + isServerThread: false, + activeMessageCount: 0, + sendEnvMode: "worktree" as const, + worktreePath: null, + activeThreadBranch: "main", + resumeCompactionTokens: null, + keepFullHistory: false, + phase: "ready" as const, + dispatchMode: "auto" as const, }; - it("shows a wake the user has not seen", () => { - expect(isThreadWokeUnseen({ ...base, lastVisitedAt: "2026-10-16T09:00:00.000Z" })).toBe(true); - expect(isThreadWokeUnseen(base)).toBe(true); + it("prepares a worktree from the selected branch on a new thread", () => { + expect(planComposerTurn(base)).toMatchObject({ + missingWorktreeBaseBranch: false, + baseBranchForWorktree: "main", + shouldQueueBehindActiveRun: false, + }); + }); + + it("refuses a new worktree without a base branch", () => { + expect(planComposerTurn({ ...base, activeThreadBranch: null })).toMatchObject({ + missingWorktreeBaseBranch: true, + baseBranchForWorktree: null, + }); + }); + + it("queues behind a compaction of a stale session, except for /compact itself", () => { + expect(planComposerTurn({ ...base, resumeCompactionTokens: 120_000 })).toMatchObject({ + compactBeforeSend: true, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, + }); + expect( + planComposerTurn({ ...base, resumeCompactionTokens: 120_000, messageText: "/COMPACT" }), + ).toMatchObject({ compactBeforeSend: false, turnDispatchMode: "auto" }); + }); + + it("queues behind a running turn only when asked to", () => { + const running = { + ...base, + isServerThread: true, + activeMessageCount: 3, + phase: "running" as const, + }; + expect(planComposerTurn({ ...running, dispatchMode: "queue" }).shouldQueueBehindActiveRun).toBe( + true, + ); + expect(planComposerTurn({ ...running, dispatchMode: "steer" })).toMatchObject({ + shouldQueueBehindActiveRun: false, + baseBranchForWorktree: null, + }); + }); +}); + +describe("withMessageContext", () => { + const context = { records: [] } as unknown as NonNullable< + Parameters[0]["context"] + >; + + it("sends the context inline when the server supports it", () => { + expect(withMessageContext({ text: "hi", context, supportsInlineMessageContext: true })).toEqual( + { text: "hi", context }, + ); + }); + + it("folds the context into the text for older servers", () => { + const result = withMessageContext({ text: "hi", context, supportsInlineMessageContext: false }); + expect(result.context).toBeUndefined(); + expect(result.text).toContain("hi"); + }); + + it("sends the text alone without context", () => { + expect( + withMessageContext({ text: "hi", context: undefined, supportsInlineMessageContext: false }), + ).toEqual({ text: "hi" }); + }); +}); + +describe("boundAttachmentIds", () => { + it("prefers the uploaded id and falls back to the local one", () => { + expect( + boundAttachmentIds([{ id: "upload-1" }, {}], [{ id: "local-1" }, { id: "local-2" }]), + ).toEqual(["upload-1", "local-2"]); }); +}); + +describe("deriveAvailableRevertTurnCounts", () => { + const providerThreadId = ProviderThreadId.make("provider-thread"); + const otherProviderThreadId = ProviderThreadId.make("other-provider-thread"); + + function projection(input: { readonly providerSessionId: ProviderSessionId | null }) { + const base = makeThreadProjectionFixture(); + const run = (ordinal: number) => ({ + id: RunId.make(`run-${ordinal}`), + ordinal, + activeAttemptId: `attempt-${ordinal}`, + }); + const turn = (ordinal: number, attempt: number, threadId: ProviderThreadId) => ({ + id: `turn-${attempt}-${ordinal}`, + runAttemptId: `attempt-${attempt}`, + ordinal, + providerThreadId: threadId, + }); + return { + ...base, + thread: { ...base.thread, activeProviderThreadId: providerThreadId }, + providerThreads: [ + { + id: providerThreadId, + providerInstanceId: base.thread.modelSelection.instanceId, + providerSessionId: input.providerSessionId, + }, + ], + runs: [run(1), run(2), run(3)], + attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, providerTurnId: null })), + // Run 2's latest turn moved to another provider thread, so its checkpoint cannot restore + // this conversation. + providerTurns: [ + turn(1, 1, providerThreadId), + turn(1, 2, providerThreadId), + turn(2, 2, otherProviderThreadId), + turn(1, 3, providerThreadId), + ], + } as unknown as ReturnType; + } - it("hides a wake seen by a later visit or a later completed run", () => { - expect(isThreadWokeUnseen({ ...base, lastVisitedAt: "2026-10-16T11:00:00.000Z" })).toBe(false); - expect(isThreadWokeUnseen({ ...base, latestRunCompletedAt: "2026-10-16T11:00:00.000Z" })).toBe( - false, + it("offers the thread start and runs whose latest turn is on the active provider thread", () => { + const available = deriveAvailableRevertTurnCounts( + projection({ providerSessionId: ProviderSessionId.make("session") }), ); + expect([...available].toSorted()).toEqual([0, 1, 3]); + }); + + it("offers nothing without a provider session to rewind", () => { + expect(deriveAvailableRevertTurnCounts(projection({ providerSessionId: null })).size).toBe(0); + expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); + }); +}); + +describe("deriveWorkspaceMutationId", () => { + const item = (id: string, type: string, status: string) => + ({ + item: { id: TurnItemId.make(id), type, status }, + }) as unknown as OrchestrationV2ProjectedTurnItem; + + it("keys on the latest finished command or file change and the latest checkpoint", () => { + expect( + deriveWorkspaceMutationId( + [ + item("edit", "file_change", "completed"), + item("reply", "assistant_message", "completed"), + item("command", "command_execution", "running"), + ], + "2026-01-01T00:00:00.000Z", + ), + ).toBe(JSON.stringify(["edit", "2026-01-01T00:00:00.000Z"])); }); - it("hides settled threads and unparseable wake times", () => { - expect(isThreadWokeUnseen({ ...base, settled: true })).toBe(false); - expect(isThreadWokeUnseen({ ...base, wokeAt: "not a date" })).toBe(false); + it("is null before anything could have touched the workspace", () => { + expect(deriveWorkspaceMutationId([item("reply", "assistant_message", "completed")], null)).toBe( + null, + ); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 493d1997715ed..517c8090e54d0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,6 +1,24 @@ import type { IssueContextRecord } from "@t3tools/contracts"; +import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; +import { + deriveLatestThreadRun, + deriveThreadRuntime, +} from "@t3tools/client-runtime/state/thread-execution"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + latestUnheldRun, + usageLimitRunPresentedAsLatest, +} from "@t3tools/shared/orchestrationV2ThreadError"; import * as Option from "effect/Option"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { SetStateAction } from "react"; +import { applyClaudePromptEffortPrefix, resolvePromptInjectedEffort } from "@t3tools/shared/model"; +import { getProviderModelCapabilities } from "../providerModels"; +import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import type { + EnvironmentThread, + ThreadStructure, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -15,6 +33,7 @@ import { type ModelSelection, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2ThreadProjection, + type OrchestrationMessageContext, type PreviewAnnotationPayload, type ProviderInteractionMode, ProviderDriverKind, @@ -50,13 +69,32 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerFileAttachment, + type ComposerImageAttachment, + type DraftId, + type DraftThreadState, +} from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; import { waitForAtomValue } from "../state/waitForAtomValue"; -import { filterTerminalContextsWithText, type TerminalContextDraft } from "../lib/terminalContext"; -import { stripInlineContextReferences } from "~/lib/composerContextReferences"; +import { + filterTerminalContextsWithText, + formatTerminalContextLabel, + type TerminalContextDraft, +} from "../lib/terminalContext"; +import { + removeInlineContextReference, + stripInlineContextReferences, +} from "~/lib/composerContextReferences"; +import { + previewAnnotationContextLabel, + reviewCommentContextLabel, + terminalContextReference, +} from "../lib/composerContextRecords"; +import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; +import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; @@ -70,10 +108,13 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; +type ThreadProjection = EnvironmentThread["projection"]; +type ThreadStructureRow = ThreadStructure["visibleTurnItems"][number]; + export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; -export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], @@ -225,15 +266,6 @@ export function codexArtifactTemplatePromptToAppend( export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); -export function resolveEffectiveInteractionMode(input: { - planModeEnabled: boolean; - composerInteractionMode: ProviderInteractionMode | null; - threadInteractionMode: ProviderInteractionMode | null | undefined; -}): ProviderInteractionMode { - if (!input.planModeEnabled) return "default"; - return input.composerInteractionMode ?? input.threadInteractionMode ?? "default"; -} - export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; @@ -534,25 +566,6 @@ export function buildLocalDraftThread( }); } -export function shouldWriteThreadErrorToCurrentServerThread(input: { - serverThread: - | { - environmentId: EnvironmentId; - id: ThreadId; - } - | null - | undefined; - routeThreadRef: ScopedThreadRef; - targetThreadId: ThreadId; -}): boolean { - return Boolean( - input.serverThread && - input.targetThreadId === input.routeThreadRef.threadId && - input.serverThread.environmentId === input.routeThreadRef.environmentId && - input.serverThread.id === input.targetThreadId, - ); -} - export function resolveComposerProviderSelection(input: { entries: ReadonlyArray; candidateInstanceIds: ReadonlyArray; @@ -799,12 +812,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ export { readFileAsDataUrl } from "../lib/imageCompression"; -export function resolveSendEnvMode(input: { - requestedEnvMode: DraftThreadEnvMode; - isGitRepo: boolean; -}): DraftThreadEnvMode { - return input.isGitRepo ? input.requestedEnvMode : "local"; -} +export { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; export function resolveBackgroundDraftWorkspaceOptions(input: { envMode: DraftThreadEnvMode; @@ -1194,7 +1202,7 @@ export function createLocalDispatchSnapshot( * turn items avoids dropping steer rows in that gap. */ export function deriveCommittedServerUserMessageIds( - visibleTurnItems: ReadonlyArray, + visibleTurnItems: ReadonlyArray, ): ReadonlySet { return new Set( visibleTurnItems.flatMap((row) => @@ -1372,21 +1380,399 @@ export function mergeDirectPreviewAnnotation(input: { return { images, previewAnnotations }; } +export { isThreadWokeUnseen } from "@t3tools/client-runtime/state/thread-settled"; + +export function formatOutgoingPrompt(params: { + provider: ProviderDriverKind; + model: string | null; + models: ReadonlyArray; + effort: string | null; + text: string; +}): string { + const caps = getProviderModelCapabilities(params.models, params.model, params.provider); + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); +} + +export function isCompactCommandMessage(message: ChatMessage): boolean { + const text = message.text.trim().toLowerCase(); + return message.role === "user" && text === "/compact" && !message.attachments?.length; +} + +// Errors surface through two maps (draft-keyed and thread-keyed) whose entries +// can race around promotion, so each write carries its time to let the latest +// one win when they collide. +export type LocalThreadErrorEntry = { + readonly message: string | null; + readonly at: number; +}; + +export function chatActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +export function releaseChatTimelineAnchor( + current: T, +): T { + return current.messageId === null ? current : { ...current, messageId: null }; +} + +// The factories below build functions ChatView calls during render, or hands +// to ChatComposer, which can call them after an await. They live outside the +// component so each closure keeps only its inputs, not a whole render scope +// (see useStableCallback), while still acting on the render that made them. + +/** Records a thread error in local state, keyed by the route the making render saw. */ +export function makeThreadErrorSetter(input: { + readonly routeThreadRef: ScopedThreadRef; + readonly routeThreadKey: string; + readonly draftId: DraftId | null; + readonly serverThreadRef: ScopedThreadRef | null; + readonly setLocalServerErrorsByThreadKey: ( + update: SetStateAction>, + ) => void; + readonly setLocalDraftErrorsByDraftId: ( + update: SetStateAction>, + ) => void; +}) { + return (targetThreadId: ThreadId | null, error: string | null) => { + if (!targetThreadId) return; + const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; + const { routeThreadRef, serverThreadRef } = input; + if ( + serverThreadRef && + targetThreadId === routeThreadRef.threadId && + serverThreadRef.environmentId === routeThreadRef.environmentId && + serverThreadRef.threadId === targetThreadId + ) { + input.setLocalServerErrorsByThreadKey((existing) => { + if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [input.routeThreadKey]: nextEntry }; + }); + return; + } + const localDraftErrorKey = input.draftId ?? targetThreadId; + input.setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [localDraftErrorKey]: nextEntry }; + }); + }; +} + +/** Why the model picker should disable a model, or null when it can be chosen. */ +export function makeModelDisabledReason( + context: Omit< + Parameters[0], + "nextModelSelection" + > | null, +) { + return (instanceId: ProviderInstanceId, model: string): string | null => { + if (!context) return null; + const reason = getStartedThreadModelChangeBlockReason({ + ...context, + nextModelSelection: { instanceId, model }, + }); + return reason ? `${reason.description} Start a new thread to use this model.` : null; + }; +} + +/** What the composer held when the user pressed send, after a direct annotation merged in. */ +export interface ComposerSendContents { + readonly prompt: string; + readonly images: ReadonlyArray; + readonly files: ReadonlyArray; + readonly terminalContexts: ReadonlyArray; + readonly sendableTerminalContexts: ReadonlyArray; + readonly previewAnnotations: ReadonlyArray; + readonly reviewComments: ReadonlyArray; + readonly threadContexts: ReadonlyArray; + readonly issueContexts?: ReadonlyArray; +} + /** - * Whether a woken thread woke after the user last saw it. A run that completed - * after the last visit counts as seeing it, since the user watched it finish. + * Copies the composer contents a send dispatches, so a failed send can put the + * same draft back. Terminal contexts that lost their text are dropped from both + * the contexts and the prompt's inline references. */ -export function isThreadWokeUnseen(input: { - readonly wokeAt: string | null; - readonly settled: boolean; - readonly lastVisitedAt: string | null | undefined; - readonly latestRunCompletedAt: string | null; -}): boolean { - if (input.wokeAt === null || input.settled) return false; - const wokeAtMs = Date.parse(input.wokeAt); - if (Number.isNaN(wokeAtMs)) return false; - const seenAt = [input.lastVisitedAt, input.latestRunCompletedAt] - .map((value) => (value ? Date.parse(value) : NaN)) - .filter((ms) => !Number.isNaN(ms)); - return seenAt.every((ms) => ms < wokeAtMs); +export function snapshotComposerSend(contents: ComposerSendContents) { + const images = [...contents.images]; + const files = [...contents.files]; + const terminalContexts = [...contents.sendableTerminalContexts]; + return { + images, + files, + attachments: [...images, ...files], + terminalContexts, + previewAnnotations: [...contents.previewAnnotations], + reviewComments: [...contents.reviewComments], + threadContexts: [...contents.threadContexts], + issueContexts: [...(contents.issueContexts ?? [])], + messageText: contents.terminalContexts + .filter((context) => !terminalContexts.includes(context)) + .reduce( + (text, context) => + removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, + contents.prompt, + ) + .trim(), + }; +} + +export type ComposerSendSnapshot = ReturnType; + +/** How a composer send dispatches: where it runs and whether it waits behind other work. */ +export function planComposerTurn(input: { + readonly messageText: string; + readonly sendQueuesBehindSetup: boolean; + readonly isServerThread: boolean; + readonly activeMessageCount: number; + readonly sendEnvMode: DraftThreadEnvMode; + readonly worktreePath: string | null; + readonly activeThreadBranch: string | null; + readonly resumeCompactionTokens: number | null; + readonly keepFullHistory: boolean; + readonly phase: SessionPhase; + readonly dispatchMode: ComposerDispatchMode; +}) { + const isFirstMessage = + !input.sendQueuesBehindSetup && (!input.isServerThread || input.activeMessageCount === 0); + const shouldCreateWorktree = + isFirstMessage && input.sendEnvMode === "worktree" && !input.worktreePath; + // A stale Claude session compacts before this message so the turn does not re-read the + // old history. The message queues behind the /compact run, since steering into it is + // rejected. + const compactBeforeSend = + input.resumeCompactionTokens !== null && + !input.keepFullHistory && + input.messageText.toLowerCase() !== "/compact"; + return { + // In worktree mode, require an explicit base branch so we don't silently + // fall back to local execution when branch selection is missing. + missingWorktreeBaseBranch: shouldCreateWorktree && !input.activeThreadBranch, + baseBranchForWorktree: shouldCreateWorktree ? input.activeThreadBranch : null, + compactBeforeSend, + turnDispatchMode: + compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : input.dispatchMode, + shouldQueueBehindActiveRun: + compactBeforeSend || + input.sendQueuesBehindSetup || + (input.phase === "running" && input.dispatchMode === "queue"), + }; +} + +/** + * The attachment ids a message's context records bind to: the upload id the + * server returned, or the local id on the data-URL path. + */ +export function boundAttachmentIds( + turnAttachments: ReadonlyArray<{ readonly id?: string | undefined }>, + attachments: ReadonlyArray<{ readonly id: string }>, +): string[] { + return turnAttachments.map((attachment, index) => + "id" in attachment && attachment.id !== undefined ? attachment.id : attachments[index]!.id, + ); +} + +/** + * The message text and context a turn sends. Servers from before inline + * context drop the records and forward the links as literal text, so their + * turns carry the payload the legacy way. + */ +export function withMessageContext(input: { + readonly text: string; + readonly context: OrchestrationMessageContext | undefined; + readonly supportsInlineMessageContext: boolean; +}): { readonly text: string; readonly context?: OrchestrationMessageContext } { + const { text, context } = input; + if (context === undefined) return { text }; + if (!input.supportsInlineMessageContext) { + return { text: serializeLegacyContextMessage({ text, records: context.records }) }; + } + return { text, context }; +} + +/** The attachments the optimistic user row shows before the server echoes the message. */ +export function optimisticMessageAttachments( + attachments: ReadonlyArray, +) { + return attachments.map((attachment) => + attachment.type === "image" + ? { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + ...(attachment.source ? { source: attachment.source } : {}), + } + : { + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), + }, + ); +} + +/** Fallback title sources for a message with no text of its own. */ +export function sendTitleFallbackLabels(snapshot: ComposerSendSnapshot) { + const terminalContext = snapshot.terminalContexts[0]; + const reviewComment = snapshot.reviewComments[0]; + const previewAnnotation = snapshot.previewAnnotations[0]; + return [ + terminalContext ? formatTerminalContextLabel(terminalContext) : undefined, + reviewComment ? `Review: ${reviewCommentContextLabel(reviewComment)}` : undefined, + previewAnnotation ? previewAnnotationContextLabel(previewAnnotation) : undefined, + ]; +} + +/** + * A thread shell built from its loaded detail, for direct links that hydrate the detail first. + * `updatedAt` and `lastVisitedAt` are passed live: the thread structure leaves them out. + */ +export function presentThreadShellFromProjection( + environmentId: EnvironmentId, + threadId: ThreadId, + projection: ThreadStructure, + live: { + readonly updatedAt: ThreadProjection["updatedAt"]; + readonly lastVisitedAt: ThreadProjection["thread"]["lastVisitedAt"]; + }, +) { + const { updatedAt, lastVisitedAt } = live; + const latestRun = deriveLatestThreadRun(projection); + const runtime = deriveThreadRuntime({ ...projection, updatedAt }); + const pendingRuntimeRequest = + projection.runtimeRequests.findLast((request) => request.status === "pending") ?? null; + // Direct links can hydrate detail before the thread enters the shell index. + return { + ...presentThreadShell(environmentId, { + ...projection.thread, + lastVisitedAt, + latestRunId: latestRun?.runId ?? null, + activeRunId: runtime?.activeRunId ?? null, + status: runtime?.status ?? "idle", + pendingRuntimeRequest, + latestVisibleMessage: null, + latestUserMessageAt: + projection.messages.findLast((message) => message.role === "user")?.updatedAt ?? null, + hasActionableProposedPlan: projection.plans.some( + (plan) => plan.kind === "proposed_plan" && plan.status === "active", + ), + providerInstanceHistory: [ + ...new Set( + projection.providerThreads + .filter((thread) => thread.appThreadId === threadId && thread.ownerNodeId === null) + .map((thread) => thread.providerInstanceId), + ), + ], + goal: + projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + )?.goal ?? null, + itemCount: projection.turnItems.length, + visibleItemCount: projection.visibleTurnItems.length, + updatedAt, + }), + latestRun, + runtime, + }; +} + +/** Background work the provider still runs after the latest run ended. */ +export function deriveThreadPendingBackgroundTasks(projection: ThreadStructure | null) { + if (projection === null) return []; + const sessionError = + projection.providerSessions.findLast( + (session) => session.providerInstanceId === projection.thread.providerInstanceId, + )?.lastError ?? null; + const latestRun = + usageLimitRunPresentedAsLatest(projection.runs, projection.turnItems, sessionError) ?? + latestUnheldRun(projection.runs); + return [ + ...derivePendingBackgroundWork({ + latestRun, + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: projection.thread.activeProviderThreadId, + runs: projection.runs, + pullRequests: projection.thread.pullRequests, + }), + ]; +} + +/** + * Run ordinals the conversation can be reverted to: those whose provider turn belongs to the + * active provider thread, plus 0 for the thread start. Empty without a live provider session + * on the thread's selected instance. + */ +export function deriveAvailableRevertTurnCounts(projection: ThreadStructure | null) { + const available = new Set(); + const providerThread = projection?.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + if ( + !providerThread?.providerSessionId || + providerThread.providerInstanceId !== projection?.thread.modelSelection.instanceId + ) + return available; + available.add(0); + const attemptsById = new Map(projection.attempts.map((attempt) => [attempt.id, attempt])); + const providerTurnsById = new Map< + OrchestrationV2ProviderTurn["id"], + OrchestrationV2ProviderTurn + >(); + const latestTurnByAttempt = new Map(); + for (const turn of projection.providerTurns) { + providerTurnsById.set(turn.id, turn); + if (turn.runAttemptId === null) continue; + const latest = latestTurnByAttempt.get(turn.runAttemptId); + if (latest === undefined || turn.ordinal > latest.ordinal) { + latestTurnByAttempt.set(turn.runAttemptId, turn); + } + } + for (const run of projection.runs) { + if (run.activeAttemptId === null) continue; + const attempt = attemptsById.get(run.activeAttemptId); + const providerTurn = + latestTurnByAttempt.get(run.activeAttemptId) ?? + (attempt?.providerTurnId == null ? undefined : providerTurnsById.get(attempt.providerTurnId)); + // A ready file checkpoint alone cannot restore the provider conversation. + if (providerTurn?.providerThreadId === providerThread.id) available.add(run.ordinal); + } + return available; +} + +/** + * Changes when the agent may have changed the workspace, so file views refresh. File tools are + * explicit; completed commands are included because a shell command can mutate the workspace + * without reporting the paths it touched. + */ +export function deriveWorkspaceMutationId( + visibleTurnItems: ReadonlyArray, + latestCheckpointCompletedAt: string | null, +) { + let itemId: string | null = null; + for (let index = visibleTurnItems.length - 1; index >= 0; index -= 1) { + const item = visibleTurnItems[index]?.item; + if (!item) continue; + if (item.type !== "command_execution" && item.type !== "file_change") continue; + if (item.status === "pending" || item.status === "running" || item.status === "waiting") { + continue; + } + itemId = item.id; + break; + } + return itemId === null && latestCheckpointCompletedAt === null + ? null + : JSON.stringify([itemId, latestCheckpointCompletedAt]); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3783c02a8a244..08f0cd34f1ec4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,42 +1,45 @@ import { ThreadIssueSurface } from "./issues/ThreadIssueSurface"; import { InlineShellRunProvider } from "./chat/InlineShellRun"; -import { BackgroundWorkTaskList } from "./chat/BackgroundWorkTaskList"; -import { describeSidebarBackgroundWork } from "./sidebar/SidebarBackgroundWork.logic"; -import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; -import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; -import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; -import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; -import { - automaticProviderSwitchBannerItem, - usageLimitRecoveryBannerItem, -} from "./chat/UsageLimitRecoveryBanner"; +import { automaticProviderSwitchBannerItem } from "./chat/UsageLimitRecoveryBanner"; +import { threadIssueRepository } from "./chat/ThreadDetailsIssueRows"; +import { issueOriginFor } from "./issues/issuePullRequestOrigin"; +import { useIssueReferenceCopyTarget } from "./issues/useIssueReferenceCopyTarget"; +import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; +import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; import { - isThreadWokeUnseen, - mergeDirectPreviewAnnotation, - resolveBackgroundDraftWorkspaceOptions, - resolveDraftHeroState, - shouldDockDraftHeroForSubmission, + deriveAvailableRevertTurnCounts, + deriveThreadPendingBackgroundTasks, + deriveWorkspaceMutationId, + presentThreadShellFromProjection, } from "./ChatView.logic"; -import { restorePlanFollowUpComposer } from "./ChatView.logic"; +import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; +import { useComposerSelection } from "./chat/useComposerSelection"; +import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; +import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; +import { useTimelineRewind } from "./chat/useTimelineRewind"; +import { useThreadLifecycle } from "./chat/useThreadLifecycle"; +import { useRightPanelActions } from "./chat/useRightPanelActions"; +import { type TerminalLaunchContext, useChatTerminalActions } from "./chat/useChatTerminalActions"; +import { useComposerSend } from "./chat/useComposerSend"; +import { useComposerResume } from "./chat/useComposerResume"; +import { useImplementPlanInNewThread } from "./chat/useProposedPlanSend"; +import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; +import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; +import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; +import { resolveDraftHeroState } from "./ChatView.logic"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; import { isPaintOnlyThreadTimeline, peekHeldThreadTimeline, peekRememberedThreadTimeline, - rememberReadyThreadTimeline, - resolveExplicitDiffOpenOnActivate, resolveThreadSwitchTimeline, - timelineHasEphemeralPreviewUrls, recallCheckoutIsRepo, rememberCheckoutIsRepo, } from "./ChatView.logic"; -import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; -import { useScratchProject } from "../hooks/useScratchProject"; import { useStableCallback } from "../hooks/useStableCallback"; import { useProjectScriptEditing } from "./chat/useProjectScriptEditing"; import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; -import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -45,62 +48,33 @@ import { latestRootProviderFailure, } from "@t3tools/shared/orchestrationV2ThreadError"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; -import { isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; import { useWorktreeSetup } from "./chat/useWorktreeSetup"; -import { useChatShortcuts, type ChatShortcutHandler } from "./chat/useChatShortcuts"; -import { - eventPathContainsSelector, - pasteTextToFocusComposer, - shouldRedirectInputToComposer, - TYPE_TO_FOCUS_EDITABLE_SELECTOR, - TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR, -} from "./chat/composerFocusRedirect"; -import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { useChatShortcuts } from "./chat/useChatShortcuts"; +import { ChatBannerLane } from "./chat/ChatBannerLane"; import * as Schema from "effect/Schema"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, - AuthTerminalReadScope, AuthTerminalOperateScope, type AssistantCitation, type ChatFileAttachment, - CommandId, - DEFAULT_MODEL, isProviderNativeSubagentThread, type EnvironmentId, type MessageId, type ModelSelection, - type OrchestrationV2ProviderTurn, - type ProjectScript, type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, type ScopedThreadRef, type ThreadId, type RunId, - type RunAttemptId, - type RuntimeRequestId, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, - ProviderDriverKind, - resolveProjectProviderInstanceEnabled, + resolveIdleCompaction, RuntimeMode, } from "@t3tools/contracts"; -import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; -import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; -import { - wasBootstrapThreadDeleted, - wasBootstrapThreadNotCreated, -} from "@t3tools/client-runtime/errors"; -import { readPastedComposerContext } from "./composerInlineTokenPaste"; -import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; -import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; -import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, deriveReportedModelSelection, @@ -108,9 +82,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveLatestThreadRun, - deriveThreadRuntime, - presentPendingBackgroundWork, - presentProviderGoal, + deriveThreadStructureRuntime, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveWorkflowOpening, @@ -121,80 +93,41 @@ import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { codexFeedbackMessage, - parseCodexFeedbackCommand, + isCompleteTurnItemRow, shouldShowLoadEarlierControl, - submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; -import { resolveThreadLastVisitedAt } from "./Sidebar.logic"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { - applyClaudePromptEffortPrefix, - createModelSelection, - formatModelSlugName, - resolvePromptInjectedEffort, - resolveSelectableModel, -} from "@t3tools/shared/model"; -import { - projectScriptCwd, - projectScriptTerminalInput, - resolveProjectScripts, -} from "@t3tools/shared/projectScripts"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; -import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { - latestUnheldRun, - usageLimitRunPresentedAsLatest, -} from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/reactivity"; -import { - Fragment, - type SetStateAction, - useEffect, - useEffectEvent, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { flushSync } from "react-dom"; -import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; +import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; -import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, mapAtomCommandResult, - settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; -import { - type ComposerSubmissionIntent, - collapseExpandedComposerCursor, - composerRequiresModifier, - parseStandaloneComposerSlashCommand, -} from "../composer-logic"; +import { composerRequiresModifier } from "../composer-logic"; import { derivePhase, - selectHandoffImageResources, deriveActivePlanState, selectComposerTasksPlan, deriveActiveWorkStartedAt, @@ -205,36 +138,13 @@ import { isLatestRunSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { - CHAT_TIMELINE_ANCHOR_OFFSET, - timelineContentOverflowsViewport, - readTimelinePosition, - observeTimelineRun, - type TimelineRunObservation, - type TimelineScrollMode, -} from "./chat/timelineScrollAnchoring"; -import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; -import { - buildPlanImplementationThreadTitle, - buildPlanImplementationPrompt, - resolvePlanFollowUpSubmission, -} from "../proposedPlan"; -import { - DEFAULT_INTERACTION_MODE, - MAX_TERMINALS_PER_GROUP, - type ChatMessage, - type SessionPhase, - type Thread, -} from "../types"; +import { DEFAULT_INTERACTION_MODE, type ChatMessage } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { settleAndAdvance } from "../settleAndAdvance"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider"; -import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, type ThreadPanelPresentation, @@ -245,33 +155,20 @@ import { selectActiveRightPanelSurface, selectThreadPanelOpen, selectThreadRightPanelState, - type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; -import { - isPreviewSupportedInRuntime, - setActivePreviewTab, - useThreadPreviewState, -} from "../previewStateStore"; -import { BrowserSettingsReadError, openUrlInPreview } from "../browser/openFileInPreview"; -import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver"; +import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; -import { addBrowserSurface } from "./preview/addBrowserSurface"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { usePreviewSession } from "./preview/usePreviewSession"; -import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { - browserMiniPlayerSource, previewMiniPlayerSourceKey, selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { threadPullRequestPanelTarget } from "./pullRequest/pullRequestDetail.logic"; -import { threadIssueRepository } from "./chat/ThreadDetailsIssueRows"; -import { issueOriginFor } from "./issues/issuePullRequestOrigin"; -import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { useDeviceState } from "~/state/device"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; @@ -279,26 +176,12 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; -import { PersistentThreadTerminalDrawer } from "./PersistentThreadTerminals"; -import { - AlarmClockIcon, - CheckCircle2Icon, - MessageCircleIcon, - PaperclipIcon, - ChevronDownIcon, - DownloadIcon, - GitBranchIcon, - ShieldQuestionIcon, - TargetIcon, - WifiOffIcon, -} from "lucide-react"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; -import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newMessageId } from "~/lib/utils"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -314,34 +197,18 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; -import { useIssueReferenceCopyTarget } from "./issues/useIssueReferenceCopyTarget"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; -import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { - getComposerPromptInjectionState, - getComposerProviderState, -} from "./chat/composerProviderState"; -import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; -import { findReusableProjectScriptTerminal } from "../lib/projectScriptTerminal"; -import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; import { - beginBackgroundDraftSubmissionByRef, - clearBackgroundDraftSubmissionByRef, - finalizePromotedDraftThreadByRef, - markPromotedDraftThreadByRef, - restoreFailedDraftThread, composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, @@ -349,35 +216,13 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; -import { - formatTerminalContextLabel, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; -import { - collectInlineContextIds, - ensureInlineContextReferences, - removeInlineContextReference, -} from "../lib/composerContextReferences"; -import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; -import { - buildMessageContext, - previewAnnotationContextLabel, - previewAnnotationContextReference, - reviewCommentContextLabel, - terminalContextReference, -} from "../lib/composerContextRecords"; -import { type ReviewCommentContext } from "../reviewCommentContext"; -import { environmentCatalog } from "../connection/catalog"; -import { ConnectionDiagnosisHeadline } from "./settings/ConnectionDiagnosisDetails"; -import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; -import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; +import { type TerminalContextDraft } from "../lib/terminalContext"; +import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; import { - environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, serverEnvironment, @@ -385,10 +230,6 @@ import { import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; -import { sourceControlEnvironment } from "../state/sourceControl"; -import { useProjectClone } from "../state/projectClones"; -import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; -import { dismissProjectFileCopy, useProjectFileCopy } from "../lib/addProjectToMachine"; import { type EnvironmentPresentation, useEnvironmentProviders, @@ -399,40 +240,31 @@ import { resolveThreadDetailRef, useEnvironmentSupportsServerBrowser, useProject, - useProjects, - useThreadProjection, + useQuietThreadProjection, + useThreadUpdatedAt, + useThreadLastVisitedAt, + useQuietThreadVisibleTurnItems, useThreadStatus, useThreadHistory, useQuietThreadShell, - useThreadShell, - useChildThreadInputs, - useThreadRefs, - useThreadVisibleTurnItems, useThreadCheckpointSummaries, - readThreadShell, - waitForThreadShell, } from "../state/entities"; -import { environmentShell, useEnvironmentShellHasSnapshot } from "../state/shell"; +import { useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; -import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; -import { createUpwardScrollDetector, isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialogHost, openExpandedImagePreview } from "./chat/ExpandedImageDialog"; import { BranchRestoreConfirmHost, DeviceSetupDialogHost, openBranchRestoreConfirm, - openDeviceSetupDialog, - openPullRequestThreadDialog, - openRevertConfirm, PullRequestThreadDialogHost, RevertConfirmHost, } from "./chat/ChatOverlayHosts"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; -import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; +import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; +import { ThreadTimeline } from "./chat/ThreadTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; -import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { overlayComposerIsResting, resolveComposerTimelineInset, @@ -445,7 +277,8 @@ import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; import { PanelLayoutControls, type PanelLayoutControlsProps, - RightPanelMaximizeControl, + ThreadPanelHeaderControl, + WorkspaceTitlebarControls, } from "./chat/PanelLayoutControls"; import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ThreadDetailsPanel } from "./chat/ThreadDetailsPanel"; @@ -458,15 +291,12 @@ import { } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, - ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { - dismissThreadErrorBannerForSession, getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, - ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRunsControl"; import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; @@ -475,8 +305,6 @@ import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEn import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import { useAgentOpenedPreviews } from "./preview/useAgentOpenedPreviews"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; -import { ThreadStatusLine } from "./chat/ThreadStatusLine"; -import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -489,407 +317,66 @@ import { latestProviderTurnTokenUsage, } from "../lib/contextWindow"; import { - DRAFT_HERO_TRANSITION_ANIMATION_ID, - DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, - runMobileComposerTransition, } from "./chat/draftHeroTransition"; -import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - branchMismatchKey, - buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, - createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, - deriveComposerSendState, dismissBranchMismatchForSession, - hasEnvironmentReconnectWarningGraceElapsed, - scheduleEnvironmentReconnectWarning, - hasServerAcknowledgedLocalDispatch, - isBranchMismatchDismissedForSession, - shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, - shouldReleaseTimelineAnchorForToolActivity, shouldRenderPreviewMiniPlayer, - getStartedThreadModelChangeBlockReason, - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - LastInvokedScriptByProjectSchema, - type LocalDispatchSnapshot, - cloneComposerImageForRetry, deriveLockedProvider, - readFileAsDataUrl, resolveFileAttachmentUrl, - prepareRevertedMessageAttachments, - waitForRevertedMessage, - reconcileMountedTerminalThreadIds, resolveComposerInteractionMode, resolveComposerProviderSelection, - getAntigravitySendBlockReason, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; -import { - awaitAttachmentUploads, - getUploadedAttachments, - readAttachmentUpload, - releaseDraftAttachments, - retryAttachmentUpload, - startAttachmentUpload, -} from "../lib/attachmentUploadQueue"; -import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -import { RightPanelSheet } from "./RightPanelSheet"; -import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { Button, InlineButton } from "./ui/button"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ServerUpdateAction, serverUpdateStageLabel } from "./ServerUpdateAction"; -import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; -import { ComposerServerUpdateIcon } from "./chat/ComposerServerUpdateStatus"; +import { observeResize } from "~/lib/observeResize"; +import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; +import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; +import { ChatRightPanel } from "./chat/ChatRightPanel"; +import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting"; +import { branchMismatchBannerItem } from "./chat/ThreadActivityBanners"; +import { useTimelineFollow } from "./chat/useTimelineFollow"; import { - buildVersionMismatchDismissalKey, - dismissServerUpdateFailure, - dismissVersionMismatch, - isServerUpdateFailureDismissed, - isVersionMismatchDismissed, - resolveServerConfigVersionMismatch, - resolveServerSelfUpdateCapability, - serverUpdateGuidance, - supportsDesktopAppUpdate, - supportsServerUpdateThreadContinuation, -} from "../versionSkew"; -import { useAssetUrls } from "../assets/assetUrls"; + type EnvironmentUnavailableState, + useSystemComposerBanners, +} from "./chat/useSystemComposerBanners"; +import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition"; +import { useLocalDispatchState } from "./chat/useLocalDispatchState"; +import { draftFanoutSelectionsAtom, draftSendState } from "./chat/chatViewSend"; import { - ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, - threadContextsOutsideEnvironment, -} from "@t3tools/shared/composerContextReferences"; -import { recallableComposerPrompt } from "./chat/composerPromptHistory"; -import { observeResize } from "~/lib/observeResize"; + isCompactCommandMessage, + type LocalThreadErrorEntry, + makeThreadErrorSetter, +} from "./ChatView.logic"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; -const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; -// During an active turn the thread's updatedAt advances several times per -// second, and every server-side visit is a full command dispatch plus a -// broadcast to all shell subscribers. -const VISIT_DISPATCH_THROTTLE_MS = 10_000; -const RELATED_THREAD_SHELL_WAIT_MS = 1_000; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; -function useDraftHeroLayoutTransition( - isDraftHeroState: boolean, - animationsActive: boolean, - animationDurationMs: number, -) { - const transitionGroupRef = useRef(null); - const composerAnchorRef = useRef(null); - const previousStateRef = useRef(isDraftHeroState); - const previousComposerRectRef = useRef(null); - const animationRef = useRef(null); - const captureComposerRect = useStableCallback(() => { - previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; - }); - - useLayoutEffect(() => { - const transitionGroup = transitionGroupRef.current; - const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; - const stateChanged = previousStateRef.current !== isDraftHeroState; - const mobileComposerTransitionActive = - typeof document !== "undefined" && - document.documentElement.dataset.mobileComposerRouteTransition === "true"; - - animationRef.current?.cancel(); - animationRef.current = null; - const previousComposerRect = previousComposerRectRef.current; - if ( - stateChanged && - animationsActive && - !mobileComposerTransitionActive && - transitionGroup && - previousComposerRect && - nextComposerRect && - typeof transitionGroup.animate === "function" - ) { - const translateX = previousComposerRect.left - nextComposerRect.left; - const translateY = previousComposerRect.top - nextComposerRect.top; - if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { - const animation = transitionGroup.animate( - [ - { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, - ], - { - duration: animationDurationMs, - easing: DRAFT_HERO_TRANSITION_EASING, - }, - ); - animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; - animationRef.current = animation; - void animation.finished - .catch(() => undefined) - .then(() => { - if (animationRef.current === animation) animationRef.current = null; - }); - } - } - previousStateRef.current = isDraftHeroState; - previousComposerRectRef.current = nextComposerRect; - }, [animationDurationMs, animationsActive, isDraftHeroState]); - - return { transitionGroupRef, composerAnchorRef, captureComposerRect } as const; -} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); -type EnvironmentUnavailableState = { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly connection: EnvironmentConnectionPresentation; -}; - -// Kept per route so a send in flight, and the multiple-model selections, survive -// ChatView remounting while the user navigates. -const draftFanoutSelectionsAtom = Atom.family((_routeKey: string) => - Atom.make | null>(null).pipe(Atom.keepAlive), -); -const draftSendStateByRouteKey = new Map< - string, - { - readonly sendInFlight: { current: boolean }; - readonly uncertainSubmissions: { current: Map }; - } ->(); -function draftSendState(routeKey: string) { - let state = draftSendStateByRouteKey.get(routeKey); - if (!state) { - state = { sendInFlight: { current: false }, uncertainSubmissions: { current: new Map() } }; - draftSendStateByRouteKey.set(routeKey, state); - } - return state; -} - -/** - * Starts one thread of a multiple-model send. A request that may have - * reached the server before failing is remembered by `retryKey`, so sending - * again cannot silently create a duplicate thread; its failure toast offers - * to open that thread or to allow the retry. - */ -async function startFanoutThread(input: { - readonly retryKey: string; - readonly model: string; - readonly uncertainSubmissions: Map; - readonly start: (threadId: ThreadId) => Promise>; - readonly openThread: (threadId: ThreadId) => void; -}): Promise { - const { retryKey, uncertainSubmissions } = input; - const uncertainThreadId = uncertainSubmissions.get(retryKey); - const targetThreadId = uncertainThreadId ?? newThreadId(); - let requestMayHaveStarted = false; - try { - if (uncertainThreadId) { - throw new Error( - "The previous request may have started. Open its thread to check before sending again.", - ); - } - requestMayHaveStarted = true; - const result = await input.start(targetThreadId); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) { - requestMayHaveStarted = false; - } - throw error; - } - return true; - } catch (error) { - if (requestMayHaveStarted && !uncertainSubmissions.has(retryKey)) { - uncertainSubmissions.set(retryKey, targetThreadId); - } - const retainedThreadId = uncertainSubmissions.get(retryKey); - const failureToastId = toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not start ${input.model}`, - description: error instanceof Error ? error.message : "Failed to send message.", - ...(retainedThreadId - ? { - timeout: 0, - data: { - secondaryActionProps: { - children: "Allow retry", - onClick: () => { - void readLocalApi() - ?.dialogs.confirm( - "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?", - ) - .then( - (confirmed) => { - if ( - confirmed && - uncertainSubmissions.get(retryKey) === retainedThreadId - ) { - uncertainSubmissions.delete(retryKey); - toastManager.close(failureToastId); - } - }, - () => undefined, - ); - }, - }, - }, - actionProps: { - children: "Open thread", - onClick: () => input.openThread(retainedThreadId), - }, - } - : {}), - }), - ); - return false; - } -} - -/** - * Reverts the thread to before `message` and puts that message's text and - * attachments back in the composer draft. Returns the restored prompt, or the - * error to show; on error the draft is untouched. - */ -async function rewindToMessage(input: { - readonly message: ChatMessage; - readonly environmentId: EnvironmentId; - readonly routeThreadRef: ScopedThreadRef; - readonly threadId: ThreadId; - readonly turnCount: number; - readonly restoreFiles: boolean; - readonly checkpoint: ReturnType; - readonly draftTarget: ScopedThreadRef | DraftId; - readonly composerHasPendingAttachments: boolean; - readonly createAssetUrl: Parameters< - typeof prepareRevertedMessageAttachments - >[0]["createAssetUrl"]; - readonly revertThreadCheckpoint: (value: { - environmentId: EnvironmentId; - input: { - commandId: CommandId; - threadId: ThreadId; - turnCount: number; - restoreFiles: boolean; - checkpointId?: string; - scopeId?: string; - }; - }) => Promise>; -}): Promise< - | { readonly error: string; readonly prompt?: never } - | { readonly error: null; readonly prompt: string } -> { - const { message, environmentId, draftTarget } = input; - try { - if (input.composerHasPendingAttachments) { - throw new Error("Wait for attachments to finish preparing before rewinding."); - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error("The environment is not connected."); - const files = await prepareRevertedMessageAttachments({ - message, - environmentId, - httpBaseUrl: connection.httpBaseUrl, - createAssetUrl: input.createAssetUrl, - }); - const store = useComposerDraftStore.getState(); - const draft = store.getComposerDraft(draftTarget); - if ( - (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - throw new Error("Make room for this message's attachments in the composer before rewinding."); - } - const commandId = CommandId.make(randomUUID()); - await waitForRevertedMessage(input.routeThreadRef, input.turnCount, commandId, async () => { - const result = await input.revertThreadCheckpoint({ - environmentId, - input: { - commandId, - threadId: input.threadId, - turnCount: input.turnCount, - restoreFiles: input.restoreFiles, - ...(input.checkpoint === undefined - ? {} - : { checkpointId: input.checkpoint.id, scopeId: input.checkpoint.scopeId }), - }, - }); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); - }); - const currentPrompt = store.getComposerDraft(draftTarget)?.prompt ?? ""; - const restoredPrompt = recallableComposerPrompt(message.text); - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.length > 0 - ? `${currentPrompt}\n\n${restoredPrompt}` - : restoredPrompt; - store.setPrompt(draftTarget, nextPrompt); - const images: ComposerImageAttachment[] = []; - const restoredFiles: ComposerFileAttachment[] = []; - files.forEach((file, index) => { - const attachment = { - id: randomUUID(), - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }; - if (message.attachments?.[index]?.type === "image") { - images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); - } else { - restoredFiles.push({ ...attachment, type: "file" }); - } - }); - store.addImages(draftTarget, images, { allowDuplicates: true }); - store.addFiles(draftTarget, restoredFiles, { allowDuplicates: true }); - return { error: null, prompt: nextPrompt }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Failed to revert thread state." }; - } -} - -function formatOutgoingPrompt(params: { - provider: ProviderDriverKind; - model: string | null; - models: ReadonlyArray; - effort: string | null; - text: string; -}): string { - const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - const promptEffort = resolvePromptInjectedEffort(caps, params.effort); - return applyClaudePromptEffortPrefix(params.text, promptEffort); -} -const SCRIPT_TERMINAL_COLS = 120; -const SCRIPT_TERMINAL_ROWS = 30; - -function isCompactCommandMessage(message: ChatMessage): boolean { - const text = message.text.trim().toLowerCase(); - return message.role === "user" && text === "/compact" && !message.attachments?.length; -} type ChatViewProps = | { @@ -911,157 +398,11 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -function useLocalDispatchState(input: { - activeThread: Thread | undefined; - activeLatestRun: Thread["latestRun"] | null; - latestUserMessageId: MessageId | null; - phase: SessionPhase; - activePendingApproval: RuntimeRequestId | null; - activePendingUserInput: RuntimeRequestId | null; - threadError: string | null | undefined; -}) { - const [localDispatch, setLocalDispatch] = useState(null); - - const resetLocalDispatch = () => { - setLocalDispatch(null); - }; - - const serverAcknowledgedLocalDispatch = hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: input.phase, - latestRun: input.activeLatestRun, - latestUserMessageId: input.latestUserMessageId, - runtime: input.activeThread?.runtime ?? null, - hasPendingApproval: input.activePendingApproval !== null, - hasPendingUserInput: input.activePendingUserInput !== null, - threadError: input.threadError, - }); - const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; - const beginLocalDispatch = (options?: { - preparingWorktree?: boolean; - submissionIntent?: ComposerSubmissionIntent; - }) => { - const preparingWorktree = Boolean(options?.preparingWorktree); - setLocalDispatch((current) => { - const active = serverAcknowledgedLocalDispatch ? null : current; - if (active) { - const submissionIntent = options?.submissionIntent ?? active.submissionIntent; - return active.preparingWorktree === preparingWorktree && - active.submissionIntent === submissionIntent - ? active - : { ...active, preparingWorktree, submissionIntent }; - } - return createLocalDispatchSnapshot(input.activeThread, { - ...options, - latestUserMessageId: input.latestUserMessageId, - }); - }); - }; - - return { - beginLocalDispatch, - resetLocalDispatch, - localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, - isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, - isSendBusy: activeLocalDispatch !== null, - backgroundSubmissionPending: activeLocalDispatch?.submissionIntent === "background", - }; -} - -// Errors surface through two maps (draft-keyed and thread-keyed) whose entries -// can race around promotion, so each write carries its time to let the latest -// one win when they collide. -type LocalThreadErrorEntry = { - readonly message: string | null; - readonly at: number; -}; - -function chatActionErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "An error occurred."; -} - -const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; const noopHeldRevert = (_targetTurnCount: number) => {}; const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; -function releaseChatTimelineAnchor( - current: T, -): T { - return current.messageId === null ? current : { ...current, messageId: null }; -} - -// The factories below build functions ChatView calls during render, or hands -// to ChatComposer, which can call them after an await. They live outside the -// component so each closure keeps only its inputs, not a whole render scope -// (see useStableCallback), while still acting on the render that made them. - -/** Records a thread error in local state, keyed by the route the making render saw. */ -function makeThreadErrorSetter(input: { - readonly routeThreadRef: ScopedThreadRef; - readonly routeThreadKey: string; - readonly draftId: DraftId | null; - readonly serverThreadRef: ScopedThreadRef | null; - readonly setLocalServerErrorsByThreadKey: ( - update: SetStateAction>, - ) => void; - readonly setLocalDraftErrorsByDraftId: ( - update: SetStateAction>, - ) => void; -}) { - return (targetThreadId: ThreadId | null, error: string | null) => { - if (!targetThreadId) return; - const nextError = sanitizeThreadErrorMessage(error); - const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; - const { routeThreadRef, serverThreadRef } = input; - if ( - serverThreadRef && - targetThreadId === routeThreadRef.threadId && - serverThreadRef.environmentId === routeThreadRef.environmentId && - serverThreadRef.threadId === targetThreadId - ) { - input.setLocalServerErrorsByThreadKey((existing) => { - if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [input.routeThreadKey]: nextEntry }; - }); - return; - } - const localDraftErrorKey = input.draftId ?? targetThreadId; - input.setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { - return existing; - } - return { ...existing, [localDraftErrorKey]: nextEntry }; - }); - }; -} - -/** Why the model picker should disable a model, or null when it can be chosen. */ -function makeModelDisabledReason( - context: Omit< - Parameters[0], - "nextModelSelection" - > | null, -) { - return (instanceId: ProviderInstanceId, model: string): string | null => { - if (!context) return null; - const reason = getStartedThreadModelChangeBlockReason({ - ...context, - nextModelSelection: { instanceId, model }, - }); - return reason ? `${reason.description} Start a new thread to use this model.` : null; - }; -} - export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1073,12 +414,8 @@ export default function ChatView(props: ChatViewProps) { } = props; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = () => - readEnvironmentScope(environmentId, AuthTerminalOperateScope); - const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; const handleNewThread = useNewThreadHandler(); - const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = scopeThreadRef(environmentId, threadId); const routeThreadKey = scopedThreadKey(routeThreadRef); const currentRouteThreadKeyRef = useRef(routeThreadKey); @@ -1088,13 +425,10 @@ export default function ChatView(props: ChatViewProps) { currentRouteThreadKeyRef.current = null; }; }, [routeThreadKey]); - const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false }); - const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false }); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); - const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -1104,12 +438,6 @@ export default function ChatView(props: ChatViewProps) { const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); - const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, { - reportFailure: false, - }); - const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, { - reportFailure: false, - }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, @@ -1121,20 +449,9 @@ export default function ChatView(props: ChatViewProps) { label: "load earlier thread history", reportFailure: false, }); - const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, { - reportFailure: false, - }); - const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, { - reportFailure: false, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const { environments } = useEnvironments(); const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironment = usePrimaryEnvironment(); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); - const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { - reportFailure: false, - }); const environmentById = new Map( environments.map((environment) => [environment.environmentId, environment]), ); @@ -1152,50 +469,19 @@ export default function ChatView(props: ChatViewProps) { shellExists: serverThreadShell !== null, waitForShell: draftThread !== null, }); - const serverThreadProjection = useThreadProjection(routeThreadDetailRef); + const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; - const serverThread = (() => { - if (serverThreadShell !== null || serverProjection === null) { - return serverThreadShell; - } - const latestRun = deriveLatestThreadRun(serverProjection); - const runtime = deriveThreadRuntime(serverProjection); - const pendingRuntimeRequest = - serverProjection.runtimeRequests.findLast((request) => request.status === "pending") ?? null; - // Direct links can hydrate detail before the thread enters the shell index. - return { - ...presentThreadShell(environmentId, { - ...serverProjection.thread, - latestRunId: latestRun?.runId ?? null, - activeRunId: runtime?.activeRunId ?? null, - status: runtime?.status ?? "idle", - pendingRuntimeRequest, - latestVisibleMessage: null, - latestUserMessageAt: - serverProjection.messages.findLast((message) => message.role === "user")?.updatedAt ?? - null, - hasActionableProposedPlan: serverProjection.plans.some( - (plan) => plan.kind === "proposed_plan" && plan.status === "active", - ), - providerInstanceHistory: [ - ...new Set( - serverProjection.providerThreads - .filter((thread) => thread.appThreadId === threadId && thread.ownerNodeId === null) - .map((thread) => thread.providerInstanceId), - ), - ], - goal: - serverProjection.providerThreads.find( - (thread) => thread.id === serverProjection.thread.activeProviderThreadId, - )?.goal ?? null, - itemCount: serverProjection.turnItems.length, - visibleItemCount: serverProjection.visibleTurnItems.length, - updatedAt: serverProjection.updatedAt, - }), - latestRun, - runtime, - }; - })(); + // Only the shell-less fallback needs these; with a shell, the shell carries them. + const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null; + const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef); + const fallbackLastVisitedAt = useThreadLastVisitedAt(fallbackDetailRef); + const serverThread = + serverThreadShell !== null || serverProjection === null || fallbackUpdatedAt === null + ? serverThreadShell + : presentThreadShellFromProjection(environmentId, threadId, serverProjection, { + updatedAt: fallbackUpdatedAt, + lastVisitedAt: fallbackLastVisitedAt, + }); const promptRef = useRef(""); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; @@ -1227,7 +513,7 @@ export default function ChatView(props: ChatViewProps) { serverProjection, activeProviderThread, ); - const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef); + const serverVisibleTurnItems = useQuietThreadVisibleTurnItems(routeThreadDetailRef); const serverThreadHistory = useThreadHistory(routeThreadDetailRef); const threadHistoryControls = (() => { if (routeThreadDetailRef === null || !shouldShowLoadEarlierControl(serverThreadHistory)) { @@ -1261,12 +547,8 @@ export default function ChatView(props: ChatViewProps) { })(); const settings = useEnvironmentSettings(environmentId); const clientSettingsHydrated = useClientSettingsHydrated(); - const setStickyComposerModelSelection = useComposerDraftStore( - (store) => store.setStickyModelSelection, - ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - const router = useRouter(); // Selects primitives: an object here is new on every router store update, which // re-renders ChatView on unrelated navigation state changes. const citationHref = useLocation({ select: (location) => location.href }); @@ -1314,36 +596,11 @@ export default function ChatView(props: ChatViewProps) { return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); - const setComposerDraftPreviewAnnotations = useComposerDraftStore( - (store) => store.setPreviewAnnotations, - ); - const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); - const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); - const setComposerDraftIssueContexts = useComposerDraftStore((store) => store.setIssueContexts); - const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); - const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); - const setComposerDraftInteractionMode = useComposerDraftStore( - (store) => store.setInteractionMode, - ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftSessionByLogicalProjectKey = useComposerDraftStore( - (store) => store.getDraftSessionByLogicalProjectKey, - ); - const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const composerImagesRef = useRef([]); const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const branchToolbarRef = useRef(null); - const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -1362,7 +619,6 @@ export default function ChatView(props: ChatViewProps) { return inserted; }; const citeAssistantTextStable = useStableCallback(citeAssistantText); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); // Set by "Work locally": the draft whose restored message should be resent @@ -1378,10 +634,6 @@ export default function ChatView(props: ChatViewProps) { const feedbackUploading = feedbackSubmissions.some( (submission) => submission.status === "uploading", ); - const optimisticUserMessagesRef = useRef(optimisticUserMessages); - useLayoutEffect(() => { - optimisticUserMessagesRef.current = optimisticUserMessages; - }); const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); @@ -1413,9 +665,6 @@ export default function ChatView(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); // Overrides chosen before a server thread's first turn; they belong to that thread only. const [pendingServerThreadEnvModeEntry, setPendingServerThreadEnvModeEntry] = useState<{ readonly threadId: ThreadId; @@ -1429,11 +678,6 @@ export default function ChatView(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - {}, - LastInvokedScriptByProjectSchema, - ); const legendListRef = useRef(null); const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null; const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); @@ -1446,21 +690,9 @@ export default function ChatView(props: ChatViewProps) { const composerRestingRef = useRef(false); const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); - const isAtEndRef = useRef(true); - const isTimelineAtLogicalEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current; - const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd); const [timelineOverflows, setTimelineOverflows] = useState(false); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); - const { sendInFlight: sendInFlightRef, uncertainSubmissions: uncertainMultipleSubmissionsRef } = - draftSendState(routeThreadKey); - const [resumingThreadKeys, setResumingThreadKeys] = useState>( - () => new Set(), - ); - const isResuming = resumingThreadKeys.has(routeThreadKey); - const composerSendGenerationRef = useRef(0); + const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const multipleModelSelections = useAtomValue(fanoutSelectionsAtom); const setMultipleModelSelections = ( selections: SetStateAction | null>, @@ -1470,39 +702,12 @@ export default function ChatView(props: ChatViewProps) { ); }; const setMultipleModelSelectionsStable = useStableCallback(setMultipleModelSelections); - const readMultipleModelSelections = () => appAtomRegistry.get(fanoutSelectionsAtom); - const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); - const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); - const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); - const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); - const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); - const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadRefs = useThreadRefs(); - const serverThreadKeys = serverThreadRefs.map(scopedThreadKey); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1548,7 +753,8 @@ export default function ChatView(props: ChatViewProps) { serverProjection === null ? null : deriveLatestThreadRun(serverProjection); const serverActivityRun = serverProjection === null ? null : deriveThreadActivityRun(serverProjection); - const serverRuntime = serverProjection === null ? null : deriveThreadRuntime(serverProjection); + const serverRuntime = + serverProjection === null ? null : deriveThreadStructureRuntime(serverProjection); const runlessWorkStartedAt = serverProjection === null ? null : deriveRunlessWorkStartedAt(serverProjection); const providerSubagentStatus = @@ -1594,7 +800,7 @@ export default function ChatView(props: ChatViewProps) { thread: serverProjection.thread, messages: serverProjection.messages, }); - const workflowOpeningParentProjection = useThreadProjection( + const workflowOpeningParentProjection = useQuietThreadProjection( workflowOpeningParentId === null || parentSubagentEnvironmentId === null ? null : scopeThreadRef(parentSubagentEnvironmentId, workflowOpeningParentId), @@ -1626,6 +832,8 @@ export default function ChatView(props: ChatViewProps) { ? threadError : null; const [, setThreadErrorBannerDismissTick] = useState(0); + const threadErrorIsServerRuntimeError = + localServerError === null && visibleThreadError === serverRuntime?.lastError; const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) .settings.defaultRuntimeMode; const runtimeMode = @@ -1670,16 +878,11 @@ export default function ChatView(props: ChatViewProps) { return labels; })(); const activeThreadRef = useActiveThreadRef(activeThread); - const terminalActions = useThreadTerminalActions(activeThreadRef); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser( activeThreadRef?.environmentId ?? null, ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; - const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { - container: workspaceLayoutElement, - widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, - }); const activeThreadShell = serverThread; const timelineThreadError = serverRuntime?.status === "failed" && @@ -1755,13 +958,6 @@ export default function ChatView(props: ChatViewProps) { const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); - const activeTerminalDrawerPresence = usePanelPresence( - Boolean(activeThreadKey && terminalUiState.terminalOpen), - true, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); const rightPanelPresenceValue = { activeSurface: activeRightPanelSurface, surfaces: rightPanelState.surfaces, @@ -1838,25 +1034,6 @@ export default function ChatView(props: ChatViewProps) { } }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - const existingOpenTerminalThreadKeys = (() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - })(); - const reconciledMountedTerminalThreadKeys = reconcileMountedTerminalThreadIds({ - currentThreadIds: mountedTerminalThreadKeys, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: activeTerminalDrawerPresence.present, - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - if ( - reconciledMountedTerminalThreadKeys.length !== mountedTerminalThreadKeys.length || - reconciledMountedTerminalThreadKeys.some( - (threadKey, index) => threadKey !== mountedTerminalThreadKeys[index], - ) - ) { - setMountedTerminalThreadKeys(reconciledMountedTerminalThreadKeys); - } const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId); const activeActivityRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime); @@ -1895,110 +1072,8 @@ export default function ChatView(props: ChatViewProps) { activeProject ?? undefined, ); const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; - const activeProjectClone = useProjectClone(activeProjectRef); - const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { - reportFailure: false, - }); - const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { - reportFailure: false, - }); - const removeClonedProject = useRemoveClonedProject(); - const runProjectCloneAction = async ( - title: string, - action: () => Promise>, - ): Promise => { - const result = await action(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }; - const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); - const activeProjectFileCopy = useProjectFileCopy(activeProjectRef); - const projectCloneSendBlockReason = - activeProjectClone?.phase === "running" - ? "Cloning repository" - : activeProjectClone !== null && activeProjectClone.phase !== "done" - ? "Repository not cloned" - : activeProjectFileCopy?.phase === "copying" - ? "Copying files" - : null; - const projectCloneBannerItem = (() => { - if (activeProjectFileCopy?.phase === "failed" && activeProjectRef) { - return { - id: `project-file-copy:${activeProjectRef.projectId}`, - variant: "error", - icon: , - title: activeProjectFileCopy.title, - description: activeProjectFileCopy.failures.join("\n"), - onDismiss: () => dismissProjectFileCopy(activeProjectRef), - }; - } - if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { - return null; - } - const name = projectCloneDisplayName(activeProjectClone); - const { environmentId, projectId } = activeProjectRef; - if (activeProjectClone.phase === "running") { - return { - id: `project-clone:${projectId}`, - variant: "info", - priority: "activity", - icon: , - title: `Cloning ${name}`, - description: projectCloneProgressSummary(activeProjectClone), - actions: ( - - ), - }; - } - const cancelled = activeProjectClone.phase === "cancelled"; - return { - id: `project-clone:${projectId}`, - variant: cancelled ? "warning" : "error", - icon: , - title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, - description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, - actions: ( - <> - - - - ), - }; - })() as ComposerBannerStackItem | null; + const { projectCloneBannerItem, projectCloneSendBlockReason } = + useProjectCloneBanner(activeProjectRef); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); @@ -2052,7 +1127,6 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; useEffect(() => { if (!activeThreadRef || !activeProjectRef) return; @@ -2060,35 +1134,8 @@ export default function ChatView(props: ChatViewProps) { }, [activeProjectRef, activeThreadRef]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); - const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; - const activeReconnectingEnvironmentId = - activeEnvironmentConnectionPhase === "connecting" || - activeEnvironmentConnectionPhase === "reconnecting" - ? (activeEnvironment?.environmentId ?? null) - : null; - const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = - useState(null); - // Each reconnect attempt gets its own grace period, including a later one to - // the same environment. - const [graceReconnectingEnvironmentId, setGraceReconnectingEnvironmentId] = useState( - activeReconnectingEnvironmentId, - ); - if (graceReconnectingEnvironmentId !== activeReconnectingEnvironmentId) { - setGraceReconnectingEnvironmentId(activeReconnectingEnvironmentId); - setReconnectWarningGraceElapsedEnvironmentId(null); - } - const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( - activeReconnectingEnvironmentId, - reconnectWarningGraceElapsedEnvironmentId, - ); - useEffect(() => { - if (activeReconnectingEnvironmentId === null) return; - return scheduleEnvironmentReconnectWarning(() => - setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), - ); - }, [activeReconnectingEnvironmentId]); + activeEnvironment !== null && activeEnvironment.connection.phase !== "connected"; const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = (() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -2101,54 +1148,6 @@ export default function ChatView(props: ChatViewProps) { connection: activeEnvironment.connection, }; })() as EnvironmentUnavailableState | null; - const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - const result = await retryEnvironment(environmentId); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not reconnect environment", - description: error instanceof Error ? error.message : "Failed to reconnect.", - }), - ); - } - }; - const handleReconnectActiveEnvironmentStable = useStableCallback( - handleReconnectActiveEnvironment, - ); - const disconnectDelayElapsed = useEnvironmentDisconnectDelay( - activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, - ); - const canDisconnectActiveEnvironment = - disconnectDelayElapsed && - activeEnvironment !== null && - activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && - !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); - const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); - const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => { - setDisconnectingEnvironment(true); - const result = await setEnvironmentEnabled({ environmentId, enabled: false }); - setDisconnectingEnvironment(false); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not disconnect server", - description: error instanceof Error ? error.message : "Failed to disconnect.", - }), - ); - } - return; - } - void navigate({ to: "/", replace: true }); - }; - const handleDisconnectActiveEnvironmentStable = useStableCallback( - handleDisconnectActiveEnvironment, - ); - const { openScratchProject } = useScratchProject(); const activeProjectIsScratch = activeProject !== null && isScratchProject( @@ -2169,91 +1168,18 @@ export default function ChatView(props: ChatViewProps) { activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); - const openPullRequestDialog = (reference?: string) => { - if (!canCheckoutPullRequestIntoThread) { - return; - } - if (activeThreadKey === null) return; - openPullRequestThreadDialog(activeThreadKey, reference); - }; - const openPullRequestDialogStable = useStableCallback(openPullRequestDialog); - const openOrReuseProjectDraftThread = async (input: { - branch: string; - worktreePath: string | null; - envMode: DraftThreadEnvMode; - }) => { - if (!activeProject) { - throw new Error("No active project is available for this pull request."); - } - const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKeyFromSettings( - activeProject, + const { handlePreparedPullRequestThreadStable, openPullRequestDialogStable } = + usePullRequestDraftThread({ + routeKind, + draftId, + settings, projectGroupingSettings, - ); - const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); - if (storedDraftSession) { - setDraftThreadContext(storedDraftSession.draftId, input); - setLogicalProjectDraftThreadId( - logicalProjectKey, - activeProjectRef, - storedDraftSession.draftId, - { - threadId: storedDraftSession.threadId, - ...input, - }, - ); - if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(storedDraftSession.draftId), - }); - } - return storedDraftSession.threadId; - } - - const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; - if (!isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId) { - setDraftThreadContext(draftId, input); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { - threadId: activeDraftSession.threadId, - createdAt: activeDraftSession.createdAt, - runtimeMode: activeDraftSession.runtimeMode, - interactionMode: activeDraftSession.interactionMode, - ...input, - }); - return activeDraftSession.threadId; - } - - const nextDraftId = newDraftId(); - const nextThreadId = newThreadId(); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { - threadId: nextThreadId, - createdAt: new Date().toISOString(), - runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings - .defaultRuntimeMode, - interactionMode: DEFAULT_INTERACTION_MODE, - ...input, - }); - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(nextDraftId), - }); - return nextThreadId; - }; - - const handlePreparedPullRequestThread = async (input: { - branch: string; - worktreePath: string | null; - }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", + activeProject, + activeThreadKey, + isServerThread, + canCheckoutPullRequestIntoThread, }); - }; - const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread); - const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); @@ -2317,182 +1243,22 @@ export default function ChatView(props: ChatViewProps) { return environment ? [environment] : []; }) : EMPTY_AUTO_UPDATE_ENVIRONMENTS; - const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); - const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); - const versionMismatchDismissKey = - versionMismatch && activeThread - ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) - : null; - const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( - null, - ); - const versionMismatchDismissed = - versionMismatchDismissKey === dismissedVersionMismatchKey || - isVersionMismatchDismissed(versionMismatchDismissKey); - const showVersionMismatchBanner = - versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = environments.length > 1; - const versionMismatchServerLabel = - hasMultipleRegisteredEnvironments && activeThread - ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` - : "server"; - const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; - const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); - const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; - const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); - const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); - const serverUpdateState = useAtomValue( - serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), - ); - const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< - typeof serverUpdateState | null - >(null); - const serverUpdateFailureDismissed = - serverUpdateState === dismissedServerUpdateState || - isServerUpdateFailureDismissed(serverUpdateState); - const systemComposerBannerItems = (() => { - const items: ComposerBannerStackItem[] = []; - const updateRunning = serverUpdateState.status === "running"; - const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; - const disconnectAction = - canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( - - ) : undefined; - const environmentReconnecting = - unavailableConnection !== null && - (unavailableConnection.phase === "connecting" || - unavailableConnection.phase === "reconnecting"); - const suppressUnavailableBanner = - environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); - if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: unavailableConnection.phase === "error" ? "error" : "warning", - icon: , - title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: activeEnvironment ? ( - - ) : undefined, - actions: ( - <> - {!environmentReconnecting ? ( - - ) : null} - {disconnectAction} - - ), - }); - } - if ( - !automaticEnvironment && - serverUpdateEnvironmentId && - (serverUpdateState.status === "idle" - ? showVersionMismatchBanner - : !serverUpdateFailureDismissed) - ) { - const updateInProgress = serverUpdateState.status === "running"; - const updateFailed = serverUpdateState.status === "failed"; - items.push({ - id: `server-version:${serverUpdateEnvironmentId}`, - variant: updateFailed ? "error" : "default", - priority: updateInProgress ? "urgent" : "notice", - icon: , - title: - updateInProgress || updateFailed ? ( - `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` - ) : versionMismatch ? ( - - - Server update available - - } - /> - - {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "} - {versionMismatch.clientVersion} - - - ) : ( - "Server update available" - ), - description: - serverUpdateState.status === "failed" - ? serverUpdateState.message - : serverUpdateState.status === "running" - ? serverUpdateStageLabel(serverUpdateState.stage) - : versionMismatchSelfUpdate !== null && - (versionMismatchSelfUpdate !== "desktop-managed" || - !versionMismatchDesktopAppUpdate) - ? serverUpdateGuidance(versionMismatchSelfUpdate) - : undefined, - actions: updateInProgress ? ( - disconnectAction - ) : !versionMismatch || - (versionMismatchSelfUpdate === "desktop-managed" && - !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), - ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) - ? {} - : { - dismissLabel: "Dismiss update notice", - onDismiss: () => { - if (updateFailed) { - dismissServerUpdateFailure(serverUpdateState); - setDismissedServerUpdateState(serverUpdateState); - } - dismissVersionMismatch(versionMismatchDismissKey); - setDismissedVersionMismatchKey(versionMismatchDismissKey); - }, - }), - }); - } - if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); - return items; - })() as ComposerBannerStackItem[]; - const providerInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - activeProject?.id ?? null, - ), + const systemComposerBannerItems = useSystemComposerBanners({ + activeEnvironment, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableState, + activeThreadEnvironmentId: activeThread?.environmentId ?? null, + serverConfig, + hasMultipleRegisteredEnvironments: environments.length > 1, + automaticEnvironment, + autoUpdateEnvironments, + }); + const providerInstanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + activeProject?.id ?? null, + ), ); const activeThreadModelInstanceId = activeThread?.modelSelection.instanceId; const { selectedProviderEntry, requestedDriverKind } = resolveComposerProviderSelection({ @@ -2664,31 +1430,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadLiveTokenUsage, activeProviderThread, ); - const pendingBackgroundTasks = (() => { - if (serverProjection === null || serverProjection === undefined) { - return []; - } - const sessionError = - serverProjection.providerSessions.findLast( - (session) => session.providerInstanceId === serverProjection.thread.providerInstanceId, - )?.lastError ?? null; - const latestRun = - usageLimitRunPresentedAsLatest( - serverProjection.runs, - serverProjection.turnItems, - sessionError, - ) ?? latestUnheldRun(serverProjection.runs); - return [ - ...derivePendingBackgroundWork({ - latestRun, - providerThreads: serverProjection.providerThreads, - turnItems: serverProjection.turnItems, - activeProviderThreadId: serverProjection.thread.activeProviderThreadId, - runs: serverProjection.runs, - pullRequests: serverProjection.thread.pullRequests, - }), - ]; - })(); + const pendingBackgroundTasks = deriveThreadPendingBackgroundTasks(serverProjection); const activeWorkStartedAt = deriveActiveWorkStartedAt(activeActivityRun, activeRuntime, localDispatchStartedAt) ?? runlessWorkStartedAt; @@ -2696,69 +1438,6 @@ export default function ChatView(props: ChatViewProps) { const sendQueuesBehindSetup = isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState(null); - useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; - }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoff = ( - messageId: MessageId, - previewUrls?: ReadonlyArray, - ) => { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - const currentPreviewUrls = - previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) { - return existing; - } - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - }; - const clearAttachmentPreviewHandoffStable = useStableCallback(clearAttachmentPreviewHandoff); - const clearAttachmentPreviewHandoffs = () => { - attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { - for (const previewUrl of previewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - attachmentPreviewHandoffByMessageIdRef.current = {}; - setAttachmentPreviewHandoffByMessageId({}); - }; - const clearAttachmentPreviewHandoffsStable = useStableCallback(clearAttachmentPreviewHandoffs); - useEffect(() => { - return () => { - clearAttachmentPreviewHandoffsStable(); - for (const message of optimisticUserMessagesRef.current) { - revokeUserMessagePreviewUrls(message); - } - }; - }, [clearAttachmentPreviewHandoffsStable]); - const handoffAttachmentPreviews = (messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - const nextPreviewUrlSet = new Set(previewUrls); - for (const previewUrl of previousPreviewUrls) { - if (!nextPreviewUrlSet.has(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - }; - const handoffAttachmentPreviewsStable = useStableCallback(handoffAttachmentPreviews); const downloadFileAttachment = async (attachment: ChatFileAttachment) => { const connection = readPreparedConnection(environmentId); if (!connection) { @@ -2793,132 +1472,12 @@ export default function ChatView(props: ChatViewProps) { } }; const openFileAttachmentStable = useStableCallback(openFileAttachment); - const serverAttachmentResources = selectHandoffImageResources( - Object.keys(attachmentPreviewHandoffByMessageId).length === 0 - ? undefined - : serverVisibleTurnItems.flatMap(({ item }) => - item.type === "user_message" - ? [{ id: item.messageId, role: "user" as const, attachments: item.attachments }] - : [], - ), - attachmentPreviewHandoffByMessageId, - ); - const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); - const serverAttachmentUrlById = new Map( - serverAttachmentResources.flatMap((resource, index) => { - const url = serverAttachmentUrls[index]; - return url ? [[resource.attachmentId, url] as const] : []; - }), - ); - useEffect(() => { - if (typeof Image === "undefined" || serverVisibleTurnItems.length === 0) { - return; - } - - const cleanups: Array<() => void> = []; - const userMessagesById = new Map( - serverVisibleTurnItems.flatMap((row) => - row.item.type === "user_message" ? [[String(row.item.messageId), row.item] as const] : [], - ), - ); - - for (const [messageId, handoffPreviewUrls] of Object.entries( - attachmentPreviewHandoffByMessageId, - )) { - if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { - continue; - } - - const serverMessage = userMessagesById.get(messageId); - if (serverMessage === undefined || serverMessage.attachments.length === 0) { - continue; - } - - const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" - ? [serverAttachmentUrlById.get(attachment.id)].filter( - (previewUrl): previewUrl is string => previewUrl !== undefined, - ) - : [], - ); - if ( - serverPreviewUrls.length === 0 || - serverPreviewUrls.length !== handoffPreviewUrls.length || - serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) - ) { - continue; - } - - attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; - - let cancelled = false; - const imageInstances: HTMLImageElement[] = []; - - const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( - (previewUrl) => - new Promise((resolve, reject) => { - const image = new Image(); - imageInstances.push(image); - const handleLoad = () => resolve(); - const handleError = () => - reject(new Error(`Failed to load server preview for ${messageId}.`)); - image.addEventListener("load", handleLoad, { once: true }); - image.addEventListener("error", handleError, { once: true }); - image.src = previewUrl; - }), - ), - ); - - void preloadServerPreviews - .then(() => { - if (cancelled) { - return; - } - clearAttachmentPreviewHandoffStable(messageId as MessageId, handoffPreviewUrls); - }) - .catch(() => { - if (!cancelled) { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - } - }); - - cleanups.push(() => { - cancelled = true; - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - for (const image of imageInstances) { - image.src = ""; - } - }); - } - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [ - attachmentPreviewHandoffByMessageId, - clearAttachmentPreviewHandoffStable, - serverAttachmentUrlById, - serverVisibleTurnItems, - ]); - const timelineAttachmentUrlById = (() => { - const urls = new Map(serverAttachmentUrlById); - for (const row of serverVisibleTurnItems) { - if (row.item.type !== "user_message") continue; - const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId]; - if (handoffUrls === undefined) continue; - let imageIndex = 0; - for (const attachment of row.item.attachments) { - if (attachment.type !== "image") continue; - const handoffUrl = handoffUrls[imageIndex]; - imageIndex += 1; - if (handoffUrl !== undefined) urls.set(attachment.id, handoffUrl); - } - } - return urls; - })(); + const { handoffAttachmentPreviewsStable, timelineAttachmentUrlById } = + useAttachmentPreviewHandoff({ + environmentId, + serverVisibleTurnItems, + optimisticUserMessages, + }); const anchoredTimelineMessages = feedbackSubmissions.flatMap((submission) => submission.status === "interrupted" ? [] @@ -2927,8 +1486,7 @@ export default function ChatView(props: ChatViewProps) { { ...codexFeedbackMessage(submission, "assistant"), runId: null }, ], ); - const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, { - visibleTurnItems: serverVisibleTurnItems, + const timelineEntriesInput = { optimisticMessages: optimisticUserMessages, anchoredMessages: anchoredTimelineMessages, attachmentUrlById: timelineAttachmentUrlById, @@ -2939,6 +1497,16 @@ export default function ChatView(props: ChatViewProps) { nodes: serverProjection.nodes, plans: serverProjection.plans, }), + }; + // The structure has no assistant or reasoning text, so those rows stay out of + // ChatView's own entries (anchoring, rewind, prompt history); ThreadTimeline + // renders every row from the live turn items. + const serverTimelineRows = serverVisibleTurnItems.filter(isCompleteTurnItemRow); + const hasStreamedTimelineRows = + isServerThread && serverTimelineRows.length !== serverVisibleTurnItems.length; + const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, { + ...timelineEntriesInput, + visibleTurnItems: serverTimelineRows, }); const draftTimelineEntries = optimisticUserMessages.map( (message) => @@ -2953,8 +1521,9 @@ export default function ChatView(props: ChatViewProps) { const timelineMessages = timelineEntries.flatMap((entry) => entry.kind === "message" ? [entry.message] : [], ); + const timelineEmpty = timelineEntries.length === 0 && !hasStreamedTimelineRows; const displayedTimeline = resolveThreadSwitchTimeline({ - loading: timelineEntries.length === 0 && threadSyncPhase !== null, + loading: timelineEmpty && threadSyncPhase !== null, activeThreadKey, nextEntries: timelineEntries, rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), @@ -2993,7 +1562,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; const isDraftHeroState = resolveDraftHeroState({ isLocalDraftThread, - hasTimelineEntries: timelineEntries.length > 0, + hasTimelineEntries: !timelineEmpty, isWorking, draftHeroDockRequested, backgroundSubmissionPending, @@ -3009,70 +1578,16 @@ export default function ChatView(props: ChatViewProps) { panelAnimationDurationMs, ); const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadDetailRef); - const availableRevertTurnCounts = (() => { - const available = new Set(); - const providerThread = serverProjection?.providerThreads.find( - (candidate) => candidate.id === serverProjection.thread.activeProviderThreadId, - ); - if ( - !providerThread?.providerSessionId || - providerThread.providerInstanceId !== serverProjection?.thread.modelSelection.instanceId - ) - return available; - available.add(0); - const attemptsById = new Map(serverProjection.attempts.map((attempt) => [attempt.id, attempt])); - const providerTurnsById = new Map< - OrchestrationV2ProviderTurn["id"], - OrchestrationV2ProviderTurn - >(); - const latestTurnByAttempt = new Map(); - for (const turn of serverProjection.providerTurns) { - providerTurnsById.set(turn.id, turn); - if (turn.runAttemptId === null) continue; - const latest = latestTurnByAttempt.get(turn.runAttemptId); - if (latest === undefined || turn.ordinal > latest.ordinal) { - latestTurnByAttempt.set(turn.runAttemptId, turn); - } - } - for (const run of serverProjection.runs) { - if (run.activeAttemptId === null) continue; - const attempt = attemptsById.get(run.activeAttemptId); - const providerTurn = - latestTurnByAttempt.get(run.activeAttemptId) ?? - (attempt?.providerTurnId == null - ? undefined - : providerTurnsById.get(attempt.providerTurnId)); - // A ready file checkpoint alone cannot restore the provider conversation. - if (providerTurn?.providerThreadId === providerThread.id) available.add(run.ordinal); - } - return available; - })(); + const availableRevertTurnCounts = deriveAvailableRevertTurnCounts(serverProjection); // A paged history window cannot identify the first loaded run as the thread start. const threadStartCheckpointReady = !serverThreadHistory.hasMoreHistory && findRevertCheckpoint(serverProjection?.checkpoints ?? [], 0) !== undefined; - /** - * File tools are explicit; completed commands are included because a shell - * command can mutate the workspace without reporting the paths it touched. - */ - const workspaceMutationId = (() => { - let itemId: string | null = null; - for (let index = serverVisibleTurnItems.length - 1; index >= 0; index -= 1) { - const item = serverVisibleTurnItems[index]?.item; - if (!item) continue; - if (item.type !== "command_execution" && item.type !== "file_change") continue; - if (item.status === "pending" || item.status === "running" || item.status === "waiting") { - continue; - } - itemId = item.id; - break; - } - const latestCheckpointCompletedAt = turnDiffSummaries.at(-1)?.completedAt ?? null; - return itemId === null && latestCheckpointCompletedAt === null - ? null - : JSON.stringify([itemId, latestCheckpointCompletedAt]); - })(); + const workspaceMutationId = deriveWorkspaceMutationId( + serverVisibleTurnItems, + turnDiffSummaries.at(-1)?.completedAt ?? null, + ); const gitCwd = activeProject ? projectScriptCwd({ @@ -3162,21 +1677,6 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - useLayoutEffect(() => { - if ( - threadDetailLoading || - timelineEntries.length === 0 || - timelineHasEphemeralPreviewUrls(timelineEntries) - ) { - return; - } - rememberReadyThreadTimeline({ - threadKey: activeThreadKey, - entries: timelineEntries, - markdownCwd: gitCwd, - workspaceRoot: activeWorkspaceRoot ?? null, - }); - }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); const heldPaintContext = paintOnlyDisplayedTimeline ? peekHeldThreadTimeline() : null; @@ -3262,212 +1762,33 @@ export default function ChatView(props: ChatViewProps) { "terminal.close", terminalShortcutLabelOptions, ); - const onToggleDiff = () => { - if (!isServerThread) { - return; - } - if (!diffOpen) { - onDiffPanelOpen?.(); - } - if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); - } - }; - const onToggleDiffStable = useStableCallback(onToggleDiff); - const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; - const loadBalancingCandidates = needsLoadBalancing - ? logicalProjectEnvironments - .filter((candidate) => { - const environment = environmentById.get(candidate.environmentId); - return ( - environment?.connection.phase === "connected" && - (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && - referencedThreadContexts.every( - (record) => record.environmentId === candidate.environmentId, - ) && - providersByEnvironment - .get(candidate.environmentId) - ?.some( - (provider) => - (activeProviderInstanceId === null || - provider.instanceId === activeProviderInstanceId) && - provider.driver === selectedProvider && - (environment.serverConfig?.settings - ? resolveProjectProviderInstanceEnabled( - environment.serverConfig.settings, - candidate.projectId, - provider.instanceId, - ) - : provider.enabled) && - provider.installed && - provider.status !== "error" && - provider.auth.status !== "unauthenticated" && - provider.availability !== "unavailable", - ) - ); - }) - .map((candidate) => candidate.environmentId) - : []; - const loadBalancing = useLoadBalancedEnvironment( - loadBalancingCandidates, - loadBalancingSettings.loadBalancingWeights, - ); - useEffect(() => { - if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; - const target = logicalProjectEnvironments.find( - (environment) => environment.environmentId === loadBalancing.environmentId, - ); - if (!target?.projectId) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "auto", - loadBalancedEnvironmentId: target.environmentId, - }); - }, [ + const { needsLoadBalancing, - loadBalancing.pending, - loadBalancing.environmentId, + loadBalancing, + onAutoEnvironmentStable, + autoEnvironmentLabel, + isEnvironmentChanging, + readPendingEnvironmentChange, + onEnvironmentChangeStable, + } = useDraftEnvironmentRouting({ draftId, + draftThread, + envLocked, + automaticEnvironment, logicalProjectEnvironments, + environmentById, + providersByEnvironment, + loadBalancingSettings, + referencedThreadContexts, + activeProviderInstanceId, + selectedProvider, + composerHasAttachments, + activeProjectKey, + activeProjectIsScratch, + projectGroupingSettings, sendInFlightRef, - setDraftThreadContext, - ]); - const onAutoEnvironment = () => { - if (envLocked || !draftId) return; - if (composerHasAttachments) { - toastManager.add({ - type: "warning", - id: "load-balancing-attachments", - title: "Keep attachments on this machine", - description: - "Remove attachments before choosing automatic routing, then attach them on the selected machine.", - }); - return; - } - loadBalancing.refresh( - logicalProjectEnvironments.map((environment) => environment.environmentId), - ); - setDraftThreadContext(draftId, { - environmentSelection: "auto", - loadBalancedEnvironmentId: null, - branch: null, - worktreePath: null, - }); - }; - const onAutoEnvironmentStable = useStableCallback(onAutoEnvironment); - const autoEnvironmentLabel = automaticEnvironment - ? draftThread?.loadBalancedEnvironmentId - ? "Auto balance" - : loadBalancing.pending - ? "Checking machines…" - : loadBalancing.failed - ? "Auto balance unavailable" - : "Auto balance" - : undefined; - - // A change in flight belongs to the draft and project it started on. - const environmentChangeScope = `${draftId ?? ""}:${activeProjectKey ?? ""}`; - const environmentChangeRef = useRef<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const environmentChangeScopeRef = useRef(""); - useLayoutEffect(() => { - environmentChangeScopeRef.current = environmentChangeScope; }); - // Leaving the scope, or unmounting, abandons a change still in flight. - useLayoutEffect( - () => () => { - if (environmentChangeRef.current?.scope === environmentChangeScope) { - environmentChangeRef.current = null; - } - }, - [environmentChangeScope], - ); - const readPendingEnvironmentChange = () => { - const pending = environmentChangeRef.current; - return pending !== null && pending.scope === environmentChangeScopeRef.current ? pending : null; - }; - // The busy state belongs to the request that set it, so an older request settling late - // (abandoned, or superseded by a newer switch) can never clear a newer one's. - const [busyEnvironmentChange, setBusyEnvironmentChange] = useState<{ - readonly environmentId: EnvironmentId; - readonly scope: string; - } | null>(null); - const isEnvironmentChanging = busyEnvironmentChange?.scope === environmentChangeScope; - - const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId || sendInFlightRef.current) return; - const originalDraft = getDraftSession(draftId); - if (!originalDraft || originalDraft.promotedTo) return; - const target = logicalProjectEnvironments.find( - (env) => env.environmentId === nextEnvironmentId, - ); - if (!target) return; - const request = { environmentId: target.environmentId, scope: environmentChangeScope }; - environmentChangeRef.current = request; - setBusyEnvironmentChange(null); - const retarget = (project: (typeof allProjects)[number]) => { - const currentDraft = getDraftSession(draftId); - if ( - environmentChangeRef.current !== request || - environmentChangeScopeRef.current !== request.scope || - sendInFlightRef.current || - !currentDraft || - currentDraft.promotedTo || - currentDraft.environmentId !== originalDraft.environmentId || - currentDraft.projectId !== originalDraft.projectId - ) - return; - const projectRef = scopeProjectRef(target.environmentId, project.id); - if (activeProjectIsScratch) { - setLogicalProjectDraftThreadId( - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - projectRef, - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); - } else { - setDraftThreadContext(draftId, { - projectRef, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } - }; - const finish = () => { - if (environmentChangeRef.current === request) environmentChangeRef.current = null; - setBusyEnvironmentChange((busy) => (busy === request ? null : busy)); - }; - if (target.projectId !== null) { - const project = allProjects.find( - (project) => - project.environmentId === target.environmentId && project.id === target.projectId, - ); - if (project) retarget(project); - finish(); - return; - } - setBusyEnvironmentChange(request); - void openScratchProject(target.environmentId, "Could not switch machine") - .then((project) => { - if (project) retarget(project); - }) - .finally(finish); - }; - const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange); - - const activeTerminalGroup = - terminalUiState.terminalGroups.find( - (group) => group.id === terminalUiState.activeTerminalGroupId, - ) ?? - terminalUiState.terminalGroups.find((group) => - group.terminalIds.includes(terminalUiState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const focusComposer = () => { composerRef.current?.focusAtEnd(); @@ -3539,210 +1860,39 @@ export default function ChatView(props: ChatViewProps) { }; const sendAppMessageStable = useStableCallback(sendAppMessage); const queuedRunsControlRef = useRef(null); - const addTerminalContextToDraft = (selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }; - const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft); - const setTerminalOpen = (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }; - const setTerminalOpenStable = useStableCallback(setTerminalOpen); - const toggleTerminalVisibility = () => { - if (!activeThreadRef) return; - const nextOpen = !terminalUiState.terminalOpen; - if ( - nextOpen && - !readEnvironmentScope(environmentId, AuthTerminalReadScope) && - !hasTerminalWriteAccess() - ) - return; - if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { - // Server-created setup terminals exist before this client's drawer mounts. - // Reuse them before allocating a shell from the still-empty UI state. - const existingTerminalId = - runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ?? - activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); - if (existingTerminalId !== undefined) { - storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); - return; - } - void terminalActions.launchTerminal({ - place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - })?.result; - return; - } - setTerminalOpen(nextOpen); - }; - const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility); - const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - direction === "vertical" - ? storeSplitTerminalVertical(activeThreadRef, terminalId) - : storeSplitTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitTerminalStable = useStableCallback(splitTerminal); - const createNewTerminal = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const createNewTerminalStable = useStableCallback(createNewTerminal); - const closeTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess() || !activeThreadRef) return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const runProjectScript = async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - shellHistory?: "preserve" | "suppress"; - }, - ) => { - if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; - const reusableTerminalId = - options?.preferNewTerminal || - !canReuseTerminal || - !readEnvironmentScope(environmentId, AuthTerminalReadScope) - ? null - : findReusableProjectScriptTerminal({ - // Server sessions cover drawer terminals this client has not opened yet, such as a - // worktree setup shell; the helper drops the panel-owned ones. - drawerTerminalIds: [ - ...new Set([...terminalUiState.terminalIds, ...activeServerOrderedTerminalIds]), - ], - activeTerminalId: terminalUiState.activeTerminalId, - panelTerminalIds, - runningTerminalIds, - }); - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - - setTerminalUiLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - if (!activeThreadRef) { - return; - } - setTerminalFocusRequestId((value) => value + 1); - - const launched = terminalActions.launchTerminal({ - // The drawer opens with the target already placed: opening it empty would claim - // DEFAULT_THREAD_TERMINAL_ID, which a right-panel terminal may already own. - place: (terminalId) => - reusableTerminalId === null - ? storeNewTerminal(activeThreadRef, terminalId) - : storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), - ...(reusableTerminalId === null - ? { size: { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } } - : { terminalId: reusableTerminalId }), - location: { cwd: targetCwd, worktreePath: targetWorktreePath }, - ...(options?.env ? { extraEnv: options.env } : {}), - }); - if (!launched) return; - const targetTerminalId = launched.terminalId; - const openResult = await launched.result; - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - - const writeResult = await terminalActions.writeToTerminal( - targetTerminalId, - options?.shellHistory === "preserve" - ? `${script.command}\r` - : projectScriptTerminalInput(script.command), - ); - if (!writeResult) return; - if (writeResult._tag === "Failure") { - if (!isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { - const previewResult = await openUrlInPreview({ - threadRef: activeThreadRef, - url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), - openPreview, - }); - if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { - const error = squashAtomCommandFailure(previewResult); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not open preview", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - } - } - }; - const runProjectScriptStable = useStableCallback(runProjectScript); - - const openInlineShellTerminal = (terminalId: string) => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; - useTerminalUiStateStore.getState().ensureTerminal(activeThreadRef, terminalId, { open: true }); - setTerminalUiLaunchContext({ - threadId: activeThreadId, - cwd: gitCwd ?? activeProject.workspaceRoot, - worktreePath: activeThreadWorktreePath, - }); - setTerminalOpen(true); - setTerminalFocusRequestId((value) => value + 1); - }; - const runProjectScriptRef = useRef(runProjectScriptStable); - useLayoutEffect(() => { - runProjectScriptRef.current = runProjectScriptStable; - }, [runProjectScriptStable]); - const runShellCommand = (command: string) => { - void runProjectScriptRef.current( - { - id: "chat-code-block", - name: "Chat code block", - command, - icon: "play", - runOnWorktreeCreate: false, - }, - { rememberAsLastInvoked: false, shellHistory: "preserve" }, - ); - }; - const openInlineShellTerminalStable = useStableCallback(openInlineShellTerminal); + const { + activatePanelTerminalStable, + addTerminalContextToDraftStable, + addTerminalSurfaceStable, + closePanelTerminalStable, + lastInvokedScriptByProjectId, + runProjectScriptStable, + runShellCommandStable, + openInlineShellTerminalStable, + splitPanelTerminalStable, + splitPanelTerminalVerticalStable, + toggleTerminalVisibilityStable, + shortcuts: terminalShortcuts, + } = useChatTerminalActions({ + environmentId, + composerRef, + terminalUiState, + activeThread, + activeThreadId, + activeThreadRef, + activeProject, + activeRightPanelSurface, + runningTerminalIds, + activeServerOrderedTerminalIds, + activeTerminalLabelsById, + panelTerminalIds, + canReuseTerminal, + gitCwd, + setTerminalFocusRequestId, + setTerminalUiLaunchContext, + setThreadError, + }); const { saveProjectScript: saveProjectScriptStable, updateProjectScript: updateProjectScriptStable, @@ -3753,80 +1903,10 @@ export default function ChatView(props: ChatViewProps) { scripts: activeProjectScripts, settings, }); - - const handleRuntimeModeChange = (mode: RuntimeMode) => { - if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); - } - scheduleComposerFocus(); - }; - const handleRuntimeModeChangeStable = useStableCallback(handleRuntimeModeChange); - - const handleInteractionModeChange = (mode: ProviderInteractionMode) => { - if (mode === "plan" && !interactionModeEnabled) return; - if (mode === interactionMode) return; - setComposerDraftInteractionMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); - } - scheduleComposerFocus(); - }; - const handleInteractionModeChangeStable = useStableCallback(handleInteractionModeChange); - const toggleInteractionMode = () => { - if (!interactionModeEnabled) return; - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }; - const toggleInteractionModeStable = useStableCallback(toggleInteractionMode); - const openProviderSetup = (instanceId: ProviderInstanceId) => { - void navigate({ - to: "/settings/providers", - search: { environmentId, instanceId }, - }); - }; - const openProviderSetupStable = useStableCallback(openProviderSetup); const canOperatePreview = useEnvironmentScope( activeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); - const createBrowserSurface = (profileId?: string) => { - if (!activeThreadRef || !canOperatePreview) return; - void addBrowserSurface({ - threadRef: activeThreadRef, - openPreview, - ...(profileId === undefined ? {} : { profileId }), - }).then((result) => { - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - if (error instanceof BrowserSettingsReadError) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open browser", - description: error.message, - }), - ); - } - }); - }; - const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); - const addDiffSurface = () => { - if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }; - const addDiffSurfaceStable = useStableCallback(addDiffSurface); - const openChangesFromThreadPanel = () => { - addDiffSurface(); - }; - const openChangesFromThreadPanelStable = useStableCallback(openChangesFromThreadPanel); - const addFilesSurface = () => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().open(activeThreadRef, "files"); - }; - const addFilesSurfaceStable = useStableCallback(addFilesSurface); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; const visiblePullRequests = visibleThreadPullRequests( @@ -3835,32 +1915,9 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const addPullRequestsSurface = () => { - if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; - useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); - }; - const addPullRequestsSurfaceStable = useStableCallback(addPullRequestsSurface); - const issuesSurfaceAvailable = - isServerThread && serverConfig?.environment.capabilities.githubIssues === true; - const addIssuesSurface = () => { - if (!activeThreadRef || !issuesSurfaceAvailable) return; - useRightPanelStore - .getState() - .openIssueList(activeThreadRef, threadIssueRepository(activeProject?.repositoryIdentity)); - }; - const addIssuesSurfaceStable = useStableCallback(addIssuesSurface); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); - const addDeviceSurface = () => { - if (!activeThreadRef) return; - if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") { - openDeviceSetupDialog(scopedThreadKey(activeThreadRef)); - return; - } - useRightPanelStore.getState().open(activeThreadRef, "device"); - }; - const addDeviceSurfaceStable = useStableCallback(addDeviceSurface); useAgentOpenedPreviews({ threadRef: activeThreadRef, deviceState, @@ -3871,11 +1928,6 @@ export default function ChatView(props: ChatViewProps) { miniPlayerSource: activePreviewMiniPlayer?.source ?? null, sheetLayout: shouldUsePlanSidebarSheet, }); - const openFileSurface = (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }; - const openFileSurfaceStable = useStableCallback(openFileSurface); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const activeProjectRepository = sourceControlRepositorySelector( @@ -3921,159 +1973,12 @@ export default function ChatView(props: ChatViewProps) { visiblePullRequestCount, onDiffPanelOpen, }); - const closePreviewPanel = () => { - if (activeThreadRef) { - if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); - } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); - } - useRightPanelStore.getState().setMaximized(activeThreadRef, false); - useRightPanelStore.getState().close(activeThreadRef); - } - }; - const closePreviewPanelStable = useStableCallback(closePreviewPanel); - const togglePreviewPanel = () => { - if (!activeThreadRef || !browserAvailable) return; - if (previewPanelOpen) { - closePreviewPanel(); - return; - } - if (!canOperatePreview) return; - const activeTabId = activePreviewState.activeTabId; - if (activeTabId) { - useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); - } else { - createBrowserSurface(); - } - }; - const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); - const addTerminalSurface = () => { - if (!activeThreadRef) return; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); - const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { - if ( - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" || - activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP - ) { - return; - } - const surfaceId = activeRightPanelSurface.id; - const launched = terminalActions.launchTerminal({ - place: (terminalId) => - useRightPanelStore - .getState() - .splitTerminal(activeThreadRef, surfaceId, terminalId, direction), - }); - if (!launched) return; - setTerminalFocusRequestId((value) => value + 1); - void launched.result; - }; - const splitPanelTerminalStable = useStableCallback(splitPanelTerminal); - const splitPanelTerminalVertical = () => { - splitPanelTerminal("vertical"); - }; - const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical); - const activatePanelTerminal = (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; - useRightPanelStore - .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); - const closePanelTerminal = (terminalId: string) => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" - ) - return; - terminalActions.closeTerminalSession(terminalId); - storeCloseTerminal(activeThreadRef, terminalId); - useRightPanelStore - .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }; - const closePanelTerminalStable = useStableCallback(closePanelTerminal); - const requestCloseTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closeTerminal(terminalId); - } - }); - }; - const requestCloseTerminalStable = useStableCallback(requestCloseTerminal); - const requestClosePanelTerminal = (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closePanelTerminal(terminalId); - } - }); - }; - const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal); - const activateRightPanelSurface = (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface.kind, - diffOpen, - activeThreadRef, - }); - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); - if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); - } - if (surface.kind === "terminal") { - setTerminalFocusRequestId((value) => value + 1); - } - if (surface.kind === "diff" && !diffOpen) { - onDiffPanelOpen?.(); - } - }; - const activateRightPanelSurfaceStable = useStableCallback(activateRightPanelSurface); - const toggleRightPanel = () => { - if (!activeThreadRef) return; - if (rightPanelOpen) { - closePreviewPanel(); - return; - } - useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }; - const toggleRightPanelStable = useStableCallback(toggleRightPanel); - const toggleThreadPanel = () => { - if (!activeThreadRef) return; - useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); - }; - const toggleThreadPanelStable = useStableCallback(toggleThreadPanel); useEffect(() => { if (!canMaximizeRightPanel) return; if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { useRightPanelStore.getState().setMaximized(routeThreadRef, true); } }, [canMaximizeRightPanel, routeThreadRef]); - const toggleRightPanelMaximized = () => { - if (!canMaximizeRightPanel || !activeThreadRef) return; - useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - }; - const toggleRightPanelMaximizedStable = useStableCallback(toggleRightPanelMaximized); const { closeSurface: closeRightPanelSurface, closeOtherSurfaces: closeOtherRightPanelSurfacesStable, @@ -4084,54 +1989,6 @@ export default function ChatView(props: ChatViewProps) { terminalLabelsById: activeTerminalLabelsById, }); const closeRightPanelSurfaceStable = closeRightPanelSurface; - const moveRightPanelSurface = (surfaceId: string, toIndex: number) => { - if (activeThreadRef) - useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); - }; - const moveRightPanelSurfaceStable = useStableCallback(moveRightPanelSurface); - const copyRightPanelFilePath = (relativePath: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: "Clipboard API unavailable.", - }), - ); - return; - } - - const clipboardPath = formatFilePathForClipboard( - relativePath, - resolvePathLinkTarget(relativePath, gitCwd ?? activeProjectCwd ?? ""), - ); - void navigator.clipboard.writeText(clipboardPath).then( - () => { - toastManager.add({ - type: "success", - title: "Path copied", - description: clipboardPath, - }); - }, - (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - const copyRightPanelFilePathStable = useStableCallback(copyRightPanelFilePath); - useEffect( - () => - subscribePreviewAction((action) => { - if (action === "toggle-panel") togglePreviewPanelStable(); - }), - [togglePreviewPanelStable], - ); const persistThreadSettingsForNextTurn = async (input: { threadId: ThreadId; createdAt: string; @@ -4198,541 +2055,38 @@ export default function ChatView(props: ChatViewProps) { return result; }; - // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during - // thread switches. LegendList fires scroll events with isAtEnd=false while - // initialScrollAtEnd is settling; hiding is always immediate. - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), - ); - const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); - const timelineScrollModeRef = useRef("following-end"); - // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd - // re-pins on its own (independent of the refs), so the timeline needs a - // render-visible flag to switch it off once the user scrolls away. - const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const observedTimelineActivityRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); - const cancelPositionRestoreRef = useRef<(() => void) | null>(null); - const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); - const cancelTimelineLiveFollowForUserNavigation = () => { - cancelPositionRestoreRef.current?.(); - anchorUserScrollGenerationRef.current += 1; - const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - setTimelineLiveFollowEnabled(false); - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } - if (wasProgrammaticScrollMode) { - // While following or anchoring, our scrollToEnd/scrollToOffset calls can - // sit in LegendList's pending-imperative-scroll queue (it defers them - // while layout settles) and fire seconds later with stale targets, - // yanking the view away after the user scrolled. Starting a new - // imperative scroll cancels everything queued; targeting an item that - // is not in the data makes the new request itself resolve without ever - // scrolling, so this is a pure cancel. - void legendListRef.current?.scrollToItem({ - item: TIMELINE_SCROLL_CANCEL_SENTINEL, - animated: false, - }); - // An already-started animated scroll (behavior: smooth) keeps running in - // the browser regardless of the queue; a same-position instant write is - // the only way to halt it where it is. - const scrollNode = legendListRef.current?.getScrollableNode() as - | { scrollTop?: number } - | null - | undefined; - const currentScrollTop = scrollNode?.scrollTop; - if (scrollNode && typeof currentScrollTop === "number") { - scrollNode.scrollTop = currentScrollTop; - } - } - }; - const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback( - cancelTimelineLiveFollowForUserNavigation, - ); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( + const { + showScrollToBottom, + timelineLiveFollowEnabled, + cancelPositionRestoreRef, + isTimelineAtLogicalEndStable, cancelTimelineLiveFollowForUserNavigationStable, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigationStable; - }, [cancelTimelineLiveFollowForUserNavigationStable]); - useEffect(() => { - const previous = observedTimelineActivityRef.current; - const dispatchedUserItem = - previous?.hydrated && - previous.threadKey === activeThreadKey && - activeActivityRun !== null && - previous.runId !== activeActivityRun.runId - ? serverProjection?.visibleTurnItems.find( - (row) => row.item.type === "user_message" && row.item.runId === activeActivityRun.runId, - ) - : undefined; - const { observation, anchorMessageId: messageId } = observeTimelineRun(previous, { - threadKey: activeThreadKey, - hydrated: !isServerThread || (serverProjection !== null && threadStatus === "live"), - runId: activeActivityRun?.runId ?? null, - queued: activeActivityRun?.status === "queued", - messageId: - dispatchedUserItem?.item.type === "user_message" ? dispatchedUserItem.item.messageId : null, - }); - observedTimelineActivityRef.current = observation; - if (messageId === null) return; - if ( - pendingTimelineAnchorRef.current !== null || - timelineScrollModeRef.current === "free-scrolling" - ) { - return; - } - - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageId; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ threadKey: activeThreadKey, messageId }); - }, [activeActivityRun, activeThreadKey, isServerThread, serverProjection, threadStatus]); - const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) => - timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { - composerInset: composerTimelineInset, - anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, - }); - const timelineRealContentOverflowsViewportStable = useStableCallback( - timelineRealContentOverflowsViewport, - ); - const pageScrollControllerRef = useRef | null>( - null, - ); - const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { - timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end"; - composerRef.current?.collapseForTimelineScrollKey(key); - if ( - (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || - !isTimelineAtLogicalEndStable() - ) { - cancelTimelineLiveFollowForUserNavigationStable(); - } - }); - useEffect(() => { - const controller = createPageScrollController({ - getContainer: () => legendListRef.current?.getScrollableNode() ?? null, - getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, - onScrollStart: handlePageScrollStart, - }); - pageScrollControllerRef.current = controller; - - return () => { - controller.dispose(); - if (pageScrollControllerRef.current === controller) { - pageScrollControllerRef.current = null; - } - }; - }, [composerOverlayElement]); - const onComposerPageScrollKeyDown = (key: PageScrollKey) => { - pageScrollControllerRef.current?.handleKeyDown(key); - }; - const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown); - const onComposerPageScrollKeyUp = (key: string) => { - pageScrollControllerRef.current?.handleKeyUp(key); - }; - const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp); - const onComposerPageScrollRelease = () => { - pageScrollControllerRef.current?.releaseActiveKey(); - }; - const onComposerPageScrollReleaseStable = useStableCallback(onComposerPageScrollRelease); - const scrollToEnd = (animated: boolean = false) => { - cancelPositionRestoreRef.current?.(); - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor(releaseChatTimelineAnchor); - // The anchored end space must be gone before the scroll measures, or the - // list lands short of the real end (#6519). - requestAnimationFrame(() => { - void legendListRef.current?.scrollToEnd?.({ - animated: animated && !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - }); - }); - }; - const scrollToEndStable = useStableCallback(scrollToEnd); - useEffect(() => { - const composer = composerRef; - let removeListeners: (() => void) | null = null; - let frame: number | null = null; - const attach = (remainingAttempts: number) => { - frame = requestAnimationFrame(() => { - frame = null; - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - // The list may not have mounted on the first frame after a thread - // switch — without a retry the opt-out listeners never attach and - // live-follow becomes impossible to escape for the whole thread. - if (remainingAttempts > 0) { - attach(remainingAttempts - 1); - } - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); - } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { - if (contentScrollsUp()) { - handleManualNavigation(); - } - return; - } - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composer.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - composer.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEndStable()) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; - } - }; - // Input inside an embedded frame reaches none of the listeners above, - // but its scroll still chains out to the timeline (#17056). - const scrolledUp = createUpwardScrollDetector({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - }); - const handleScroll = () => { - if ( - scrolledUp({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - programmatic: - positionedTimelineAnchorRef.current !== null && - positionedTimelineAnchorRef.current !== settledTimelineAnchorRef.current, - }) && - timelineScrollModeRef.current !== "free-scrolling" && - // LegendList updates its scroll state on the next animation frame. - resolveTimelineIsAtEnd({ - contentLength: scrollNode.scrollHeight, - scroll: scrollNode.scrollTop, - scrollLength: scrollNode.clientHeight, - }) === false - ) { - handleManualNavigation(); - } - }; - scrollNode.addEventListener("scroll", handleScroll, { passive: true }); - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("scroll", handleScroll); - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }); - }; - attach(12); - - return () => { - if (frame !== null) { - cancelAnimationFrame(frame); - } - removeListeners?.(); - }; - }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch remounts the list's scroll node, so the listeners reattach. - activeThread?.id, + onComposerPageScrollKeyDownStable, + onComposerPageScrollKeyUpStable, + onComposerPageScrollReleaseStable, + scrollToEnd, + onTimelineAnchorReadyStable, + onTimelineAnchorSizeChangedStable, + onToolOutputCollapsedAtEndStable, + onIsAtEndChangeStable, + anchorNewTurn, + } = useTimelineFollow({ + legendListRef, composerRef, - isTimelineAtLogicalEndStable, - timelineRealContentOverflowsViewportStable, - ]); - - const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = legendListRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } - return; - } - // LegendList waits for queued layout and the animation before resolving. - void list - .scrollToIndex({ - index: anchorIndex, - animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }) - .then(() => { - if ( - legendListRef.current === list && - positionedTimelineAnchorRef.current === messageId - ) { - settledTimelineAnchorRef.current = messageId; - } - }); - }); - }; - requestAnimationFrame(() => positionAnchor(12)); - }; - const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); - const onTimelineAnchorSizeChanged = (messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }; - const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged); - - const onToolOutputCollapsedAtEnd = () => { - composerRef.current?.restoreAfterTimelineReachedEnd(); - }; - const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); - - const onIsAtEndChange = (isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - setTimelineAnchor(releaseChatTimelineAnchor); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }; - const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); - - useLayoutEffect(() => { - if (timelineScrollModeRef.current !== "anchoring-new-turn") return; - if ( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId: timelineAnchorMessageId, - liveFollowEnabled: timelineLiveFollowEnabled, - runningTurnId: activeRunningTurnId, - timelineEntries, - }) - ) { - scrollToEndStable(); - } - }, [ + composerOverlayElement, + composerTimelineInset, + routeThreadKey, + activeThreadKey, + activeThreadId: activeThread?.id, + isServerThread, + serverProjection, + threadStatus, + activeActivityRun, activeRunningTurnId, - scrollToEndStable, - timelineAnchorMessageId, timelineEntries, - timelineLiveFollowEnabled, - ]); - - // A thread switch restores that thread's remembered follow state; the refs - // the scroll handlers read catch up after commit. - const [timelineThreadReset, setTimelineThreadReset] = useState({ - threadKey: activeThreadKey, - followEnd: true, + timelineAnchorMessageId, + setTimelineAnchor, }); - if (timelineThreadReset.threadKey !== activeThreadKey) { - const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false; - setTimelineThreadReset({ threadKey: activeThreadKey, followEnd }); - setTimelineLiveFollowEnabled(followEnd); - setShowScrollToBottom(!followEnd); - } - useEffect(() => { - const { followEnd } = timelineThreadReset; - isAtEndRef.current = followEnd; - timelineScrollIntentRef.current = null; - timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling"; - liveFollowUserScrollGenerationRef.current = followEnd - ? anchorUserScrollGenerationRef.current - : null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - }, [timelineThreadReset]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; @@ -5046,7 +2400,6 @@ export default function ChatView(props: ChatViewProps) { }, ); }; - const copyActiveThreadReferenceStable = useStableCallback(copyActiveThreadReference); const pullRequestPanelTarget = activeThread ? threadPullRequestPanelTarget({ projectId: activeThread.projectId, @@ -5055,531 +2408,107 @@ export default function ChatView(props: ChatViewProps) { branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }) : null; - const addPullRequestSurface = () => { - if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) - return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); - }; - const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); - const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; - const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; - const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; - const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; - // The compiler caches render results on their inputs, so a clock read in render - // stays frozen until the shell changes. The clock is state: reset with the shell, - // and moved by the wake timer. - const [snoozeClockState, setSnoozeClockState] = useState(() => ({ - shell: activeThreadShell, - now: new Date().toISOString(), - })); - if (snoozeClockState.shell !== activeThreadShell) { - setSnoozeClockState({ shell: activeThreadShell, now: new Date().toISOString() }); - } - const snoozeClock = snoozeClockState.now; - const activeThreadSnoozed = - activeThreadShell !== null && - supportsSnooze && - effectiveSnoozed(activeThreadShell, { now: snoozeClock }); - useEffect(() => { - if (!activeThreadSnoozed) return; - const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); - if (!Number.isFinite(wakeAtMs)) return; - const id = window.setTimeout( - () => setSnoozeClockState((clock) => ({ ...clock, now: new Date().toISOString() })), - Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), - ); - return () => window.clearTimeout(id); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A clock that lands before the wake time (timer clamping, clock skew) re-arms the timer. - }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeClock]); - const activeThreadWokeAt = - activeThreadShell !== null && supportsSnooze - ? threadWokeAt(activeThreadShell, { now: snoozeClock }) - : null; - const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); - const acknowledgeActiveThreadWoke = () => { - if (activeThreadRef === null || activeThreadWokeAt === null) return; - acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); - }; - const acknowledgeActiveThreadWokeStable = useStableCallback(acknowledgeActiveThreadWoke); - const activeThreadLocalVisitedAt = useUiStateStore((store) => - activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], - ); - const activeThreadLastVisitedAt = resolveThreadLastVisitedAt( - activeThreadShell?.lastVisitedAt, - activeThreadLocalVisitedAt, - ); - const activeThreadWokeVisible = isThreadWokeUnseen({ - wokeAt: activeThreadWokeAt, - settled: activeThreadShell?.settledOverride === "settled", - lastVisitedAt: activeThreadLastVisitedAt, - latestRunCompletedAt: activeLatestRun?.completedAt ?? null, - }); - const activeThreadSettled = - supportsSettlement && activeThreadShell?.settledOverride === "settled"; - const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { - reportFailure: false, - }); - const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); - const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; - const handleUnsettleActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsettlingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const result = await unsettleThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread); - const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { - reportFailure: false, + const issuesSurfaceAvailable = + isServerThread && serverConfig?.environment.capabilities.githubIssues === true; + const { + activateRightPanelSurfaceStable, + addDeviceSurfaceStable, + addDiffSurfaceStable, + addFilesSurfaceStable, + addPullRequestSurfaceStable, + addPullRequestsSurfaceStable, + addIssuesSurfaceStable, + closePreviewPanelStable, + copyRightPanelFilePathStable, + createBrowserSurface, + createBrowserSurfaceStable, + moveRightPanelSurfaceStable, + onOpenTurnDiffStable, + openChangesFromThreadPanelStable, + openFileSurfaceStable, + toggleRightPanelMaximizedStable, + toggleRightPanelStable, + toggleThreadPanelStable, + shortcuts: rightPanelShortcuts, + } = useRightPanelActions({ + activeThreadRef, + activeProject, + activeRightPanelSurface, + activePreviewState, + deviceState, + pullRequestPanelTarget, + threadPanelPresentation, + isServerThread, + isGitRepo, + browserAvailable, + canOperatePreview, + diffOpen, + previewPanelOpen, + rightPanelOpen, + canMaximizeRightPanel, + rightPanelMaximized, + supportsPullRequests, + pullRequestsSurfaceAvailable, + issuesSurfaceAvailable, + gitCwd, + activeProjectCwd, + explicitDiffOpenRef, + setTerminalFocusRequestId, + onDiffPanelOpen, + closeRightPanelSurface, }); - const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); - const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; - const handleUnsnoozeActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; - const threadKey = scopedThreadKey(activeThreadRef); - setUnsnoozingThreadKey(threadKey); - // Commands settle into a result, so this always runs. - const recovery = activeThreadShell?.limitRecovery; - const recoveryOwnsSnooze = - recovery?.snooze === true && - recovery.runId === activeThreadShell?.latestRun?.runId && - activeThreadShell?.snoozedUntil != null && - Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt); - const result = recoveryOwnsSnooze - ? await updateThreadMetadata({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadRef.threadId, - limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false }, - }, - }) - : await unsnoozeThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to wake thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleUnsnoozeActiveThreadStable = useStableCallback(handleUnsnoozeActiveThread); - const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); - const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); - const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => - composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), - ); + const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const nowMinute = useNowMinute(); - const activeBranchMismatchKey = branchMismatchKey( - activeThread?.id ?? null, + const { + acknowledgeActiveThreadWoke, + activeBranchMismatchKey, + backgroundWorkBanner, + childInputBanner, + goalBanner, + handleSwitchCheckoutToThread, + handleSwitchCheckoutToThreadStable, + isRestoringThreadBranch, + onOpenRelatedThread, + onOpenRelatedThreadStable, + sendStandaloneCommand, + setBranchMismatchDismissTick, + showBranchMismatchBanner, + threadStatusLine, + shortcuts: threadLifecycleShortcuts, + } = useThreadLifecycle({ + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + canOperateThread, + clientSettingsHydrated, + activeThread, + activeThreadId, + activeThreadRef, + activeThreadKey, + activeThreadShell, + activeLatestRun, + activeProjectCwd, + serverConfig, + runtimeMode, + isWorking, + pendingBackgroundTasks, + activePendingApproval, + activePendingUserInput, localCheckoutBranchMismatch, - ); - const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ - hasMismatch: localCheckoutBranchMismatch !== null, - isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, - wasShownForCurrentMismatch: - revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, - }); - const nextRevealedBranchMismatchKey = showBranchMismatchBanner - ? activeBranchMismatchKey - : revealedBranchMismatchKey !== activeBranchMismatchKey - ? null - : revealedBranchMismatchKey; - if (nextRevealedBranchMismatchKey !== revealedBranchMismatchKey) { - setRevealedBranchMismatchKey(nextRevealedBranchMismatchKey); - } - const handleSwitchCheckoutToThread = async () => { - if (!canWriteSourceControl) return; - if ( - !activeProjectCwd || - !activeThread || - !localCheckoutBranchMismatch || - isRestoringThreadBranch - ) { - return; - } - setIsRestoringThreadBranch(true); - const checkoutResult = await switchGitRef({ - environmentId, - input: { - cwd: activeProjectCwd, - refName: localCheckoutBranchMismatch.threadBranch, - }, - }); - if (checkoutResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(checkoutResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to switch checkout", - description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), - }), - ); - } - return; - } - - if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) { - setIsRestoringThreadBranch(false); - return; - } - const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; - if (nextBranch !== activeThread.branch) { - const updateResult = await updateThreadMetadata({ - environmentId, - input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, - }); - if (updateResult._tag === "Failure") { - setIsRestoringThreadBranch(false); - if (!isAtomCommandInterrupted(updateResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Checkout switched, but the thread could not be updated", - description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), - }), - ); - } - gitStatusQuery.refresh(); - return; - } - } - gitStatusQuery.refresh(); - setIsRestoringThreadBranch(false); - scheduleComposerFocus(); - }; - const handleSwitchCheckoutToThreadStable = useStableCallback(() => { - void handleSwitchCheckoutToThread(); + gitStatusQuery, + nowMinute, + composerRef, + composerDraftTarget, + sendInFlightRef, + setOptimisticUserMessages, + setThreadError, + clearUsageLimitsFor, + beginLocalDispatch, + resetLocalDispatch, + scheduleComposerFocus, + scrollToEnd, + persistThreadSettingsForNextTurn, }); - // The interrupt path also accepts a completed run while its provider still - // has background work. - const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : []; - const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); - const isStoppingBackgroundWork = - stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; - const handleStopBackgroundWork = async () => { - if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - const requestKey = `${environmentId}:${activeThread.id}`; - setStoppingBackgroundWorkKey(requestKey); - const result = await interruptThreadTurn({ - environmentId, - input: { threadId: activeThread.id }, - }); - // Acceptance does not confirm termination. Allow retry while the provider - // finishes stopping the tasks or reports a failure. - setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current)); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to stop background work.", - ); - } - } - }; - const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork); - const openRelatedThreadGenerationRef = useRef(0); - const onOpenRelatedThread = async (threadId: ThreadId) => { - const generation = ++openRelatedThreadGenerationRef.current; - const threadRef = scopeThreadRef(environmentId, threadId); - const isEnvironmentLive = () => - appAtomRegistry.get(environmentShell.stateValueAtom(environmentId)).status === "live"; - // A just-launched child can reach this timeline before its shell syncs. - if (isEnvironmentLive() && readThreadShell(threadRef) === null) { - const location = router.latestLocation; - await waitForThreadShell(threadRef, RELATED_THREAD_SHELL_WAIT_MS); - if ( - generation !== openRelatedThreadGenerationRef.current || - currentRouteThreadKeyRef.current !== routeThreadKey || - router.latestLocation !== location - ) { - return; - } - } - const shell = readThreadShell(threadRef); - if (shell === null ? isEnvironmentLive() : shell.deletedAt != null) { - toastManager.add({ type: "info", title: "This thread is no longer available" }); - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }; - const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread); - const childThreadInputs = useChildThreadInputs(activeThreadRef); - const childInputBannerItem = (() => { - const first = childThreadInputs[0]; - const parentAwaitingUser = - activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; - if (!first || parentAwaitingUser) return null; - return { - id: `child-input:${first.id}`, - variant: "info", - priority: "activity", - icon: first.hasPendingApprovals ? : , - title: childThreadInputsTitle(childThreadInputs), - description: childThreadInputs.map((child, index) => ( - - {index > 0 ? " " : null} - - onOpenRelatedThreadStable(child.id)}> - {child.title} - - {index < childThreadInputs.length - 1 ? "," : null} - - - )), - actions: ( - - ), - }; - })() as ComposerBannerStackItem | null; - - const sendStandaloneCommand = async (text: string, failureMessage: string) => { - if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; - const context = composerRef.current?.getSendContext(); - if (!context?.providerAvailable) return; - - const threadId = activeThread.id; - const messageId = newMessageId(); - const createdAt = new Date().toISOString(); - sendInFlightRef.current = true; - beginLocalDispatch(); - setThreadError(threadId, null); - setOptimisticUserMessages((messages) => [ - ...messages, - { - id: messageId, - role: "user", - text, - runId: null, - createdAt, - updatedAt: createdAt, - streaming: false, - }, - ]); - scrollToEnd(); - // Commands settle into a result, so this always runs. - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode: context.interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId, - message: { messageId, role: "user", text, attachments: [] }, - modelSelection: context.selectedModelSelection, - runtimeMode, - interactionMode: context.interactionMode, - createdAt, - }, - }); - if (result._tag === "Failure") { - setOptimisticUserMessages((messages) => - messages.filter((message) => message.id !== messageId), - ); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError(threadId, error instanceof Error ? error.message : failureMessage); - } - } else { - clearUsageLimitsFor(routeThreadKey); - } - sendInFlightRef.current = false; - }; - const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); - const activeGoal = activeThreadShell?.goal ?? null; - const goalBannerItem = (() => { - if (!activeThread || activeGoal === null) return null; - const presentation = presentProviderGoal(activeGoal, isWorking); - return { - id: `goal:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: , - title: - presentation.usage === null - ? presentation.title - : `${presentation.title} · ${presentation.usage}`, - description: presentation.objective, - actions: isWorking ? undefined : ( - <> - {presentation.canResume ? ( - - ) : null} - - - ), - }; - })() as ComposerBannerStackItem | null; - - const [backgroundWorkExpanded, setBackgroundWorkExpanded] = useState(false); - const backgroundWorkBannerItem = (() => { - const presentation = presentPendingBackgroundWork(activeBackgroundTasks); - if (presentation === null || !activeThread) { - return null; - } - return { - id: `background-work:${activeThread.id}`, - variant: "default", - priority: "activity", - icon: ( - - } - /> - - This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will - continue on {localCheckoutBranchMismatch.currentBranch}. - - - - ), - actions: ( - - ), - dismissLabel: "Dismiss branch change notice", + branchMismatchBannerItem({ + mismatchKey: activeBranchMismatchKey, + mismatch: localCheckoutBranchMismatch, + restoreDisabled: !canWriteSourceControl || isRestoringThreadBranch, + restoring: isRestoringThreadBranch, + onRestore: handleRestoreThreadBranchStable, onDismiss: () => { dismissBranchMismatchForSession(activeBranchMismatchKey); setBranchMismatchDismissTick((tick) => tick + 1); }, - }, + }), ]; })() as ComposerBannerStackItem[]; @@ -5844,2071 +2747,240 @@ export default function ChatView(props: ChatViewProps) { const threadFindControlsRef = useRef(null); const [isThreadFindActive, setIsThreadFindActive] = useState(false); const openThreadFind = () => threadFindControlsRef.current?.open(); - const openThreadFindStable = useStableCallback(openThreadFind); const closeThreadFind = () => threadFindControlsRef.current?.close(); - const closeThreadFindStable = useStableCallback(closeThreadFind); - - const toastPinFailure = (title: string) => (result: AtomCommandResult) => { - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }; - const openTerminalIfClosed = () => { - if (!terminalUiState.terminalOpen) setTerminalOpenStable(true); - }; - const openComposerControl = (command: Parameters[0]) => - (({ event }) => { - if (!event.repeat) composerRef.current?.openControl(command); - }) satisfies ChatShortcutHandler; - useChatShortcuts({ - enabled: activeThreadId !== null, - keybindings, - scriptKeybindings, - getShortcutContext: getShortcutContextStable, - threadFindActive: isThreadFindActive, - typeIntoComposer: (key) => composerRef.current?.insertTextAtEnd(key) === true, - closeThreadFind: () => { - closeThreadFindStable(); - focusComposerStable(); - }, - commands: { - "thread.copyReference": ({ event }) => { - if (!event.repeat) copyActiveThreadReferenceStable(); - }, - "thread.settle": () => { - if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; - if (!isServerThread || !activeThreadRef || !supportsSettlement) return; - if (activeThreadSettled) { - void handleUnsettleActiveThreadStable(); - return; - } - if (settleAndAdvance(activeThreadRef)) return; - void settleThread(activeThreadRef).then(toastPinFailure("Failed to settle thread")); - }, - "thread.pin": () => { - if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; - if (!isServerThread || !activeThreadRef || !supportsPinning) return; - void ( - activeThreadPinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef) - ).then( - toastPinFailure(activeThreadPinned ? "Failed to unpin thread" : "Failed to pin thread"), - ); - }, - "terminal.toggle": () => { - if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return false; - toggleTerminalVisibilityStable(); - }, - "chat.find": () => { - if (!isServerThread || serverConfig?.threadFind !== true) return false; - openThreadFindStable(); - }, - "rightPanel.toggle": () => toggleRightPanelStable(), - "rightPanel.toggleMaximized": ({ event }) => { - if (!event.repeat) toggleRightPanelMaximizedStable(); - }, - "threadPanel.toggle": () => toggleThreadPanelStable(), - "rightPanel.close": ({ event }) => { - if (!activeRightPanelSurface) return false; - if (!event.repeat) closeRightPanelSurfaceStable(activeRightPanelSurface); - }, - "terminal.split": ({ terminalFocusOwner }) => { - if (!canOperateTerminal) return false; - if (terminalFocusOwner === "right-panel") return void splitPanelTerminalStable(); - openTerminalIfClosed(); - splitTerminalStable(); - }, - "terminal.splitVertical": ({ terminalFocusOwner }) => { - if (!canOperateTerminal) return false; - if (terminalFocusOwner === "right-panel") return void splitPanelTerminalStable("vertical"); - openTerminalIfClosed(); - splitTerminalStable("vertical"); - }, - "terminal.close": ({ terminalFocusOwner }) => { - if (!canOperateTerminal) return false; - if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { - requestClosePanelTerminalStable(activeRightPanelSurface.activeTerminalId); - return; - } - if (terminalUiState.terminalOpen) - requestCloseTerminalStable(terminalUiState.activeTerminalId); - }, - "terminal.new": ({ terminalFocusOwner }) => { - if (!canOperateTerminal) return false; - if (terminalFocusOwner === "right-panel") return void addTerminalSurfaceStable(); - openTerminalIfClosed(); - createNewTerminalStable(); - }, - "diff.toggle": () => onToggleDiffStable(), - "modelPicker.toggle": ({ event }) => { - if (!event.repeat) composerRef.current?.toggleModelPicker(); - }, - "composer.increaseEffort": ({ event }) => { - if (!event.repeat) composerRef.current?.stepReasoningEffort(1); - }, - "composer.decreaseEffort": ({ event }) => { - if (!event.repeat) composerRef.current?.stepReasoningEffort(-1); - }, - "composer.host": openComposerControl("composer.host"), - "composer.effort": openComposerControl("composer.effort"), - "composer.mode": openComposerControl("composer.mode"), - "composer.workspace": openComposerControl("composer.workspace"), - "composer.cycleHost": ({ event }) => { - if (!canChangeEnvironment) return false; - if (event.repeat) return; - const currentId = readPendingEnvironmentChange()?.environmentId ?? environmentId; - const index = logicalProjectEnvironments.findIndex( - (env) => env.environmentId === currentId, - ); - const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length]; - if (next) onEnvironmentChangeStable(next.environmentId); - }, - "composer.branch": ({ event }) => { - if (!event.repeat) branchToolbarRef.current?.openBranchPicker(); - }, - "composer.previousWorktree": ({ event }) => { - if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree(); - }, - "thread.steerQueuedMessage": ({ event }) => - routeKind !== "draft" && queuedRunsControlRef.current?.steerNext(event.repeat) === true, - "thread.editQueuedMessage": ({ event }) => - routeKind !== "draft" && - composerRef.current?.isCaretAtStart() === true && - queuedRunsControlRef.current?.editLatest(event.repeat) === true, - "thread.stop": ({ event }) => { - if (!canInterruptRunningThread) return false; - if (!event.repeat) void onInterruptStable(); - }, - }, - fallback: (command) => { - const scriptId = projectScriptIdFromCommand(command); - if (!scriptId || !activeProject || !canOperateTerminal) return undefined; - const script = activeProjectScripts.find((entry) => entry.id === scriptId); - return script ? () => void runProjectScriptStable(script) : undefined; - }, - }); - - // A focused desktop browser page forwards these chords as menu actions. - useEffect(() => { - return window.desktopBridge?.onMenuAction((action) => { - if (action === "rightPanel.toggle") toggleRightPanelStable(); - else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); - }); - }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); - - // Paste-to-focus: the resting composer blurs on a click into the timeline, - // so a paste that follows has no editable target and would be dropped. - useEffect(() => { - const keyHandler = (event: KeyboardEvent) => { - if ( - shouldRedirectInputToComposer(event) && - isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) - ) { - pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; - } - }; - const handler = (event: ClipboardEvent) => { - if (!activeThreadId || isCommandPaletteOpen()) return; - if (getTerminalFocusOwner() !== null) return; - if (composerRef.current?.isModelPickerOpen()) return; - const text = pasteTextToFocusComposer(event); - const clipboardData = event.clipboardData; - if (text === null || clipboardData === null) return; - const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; - pasteAsTextShortcutUntilRef.current = 0; - if ( - ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && - composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || - composerRef.current?.insertTextAtEnd(text, { clipboardData }) - ) { - event.preventDefault(); - event.stopPropagation(); - } - }; - window.addEventListener("keydown", keyHandler, true); - window.addEventListener("paste", handler, true); - return () => { - window.removeEventListener("keydown", keyHandler, true); - window.removeEventListener("paste", handler, true); - }; - }, [activeThreadId, composerRef]); - - const onRevertToTurnCount = async ( - turnCount: number, - messageId: MessageId, - restoreFiles?: boolean, - ) => { - const localApi = readLocalApi(); - if ( - !localApi || - !activeThread || - isRevertingCheckpoint || - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) - ) - return; - const message = timelineMessages.find((message) => message.id === messageId); - if (!message || message.role !== "user") return; - - if (!supportsConversationRollback) { - setThreadError( - activeThread.id, - "This provider does not support reverting conversation history. Start a new thread instead.", - ); - return; - } - if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { - setThreadError( - activeThread.id, - `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, - ); - return; - } - if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; - } - if (restoreFiles === undefined) { - openRevertConfirm(routeThreadKey, { turnCount, messageId }); - return; - } - - useComposerDraftStore.setState((store) => ({ - rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), - })); - setThreadError(activeThread.id, null); - const rewind = await rewindToMessage({ - message, - environmentId, - routeThreadRef, - threadId: activeThread.id, - turnCount, - restoreFiles, - // Keep the live checkpoint identity when command context refetches a bounded window. - checkpoint: findRevertCheckpoint(serverProjection?.checkpoints ?? [], turnCount), - draftTarget: composerDraftTarget, - composerHasPendingAttachments: composerRef.current?.hasPendingAttachments() === true, - createAssetUrl: createAttachmentAssetUrl, - revertThreadCheckpoint, - }); - useComposerDraftStore.setState((store) => { - const remaining = new Set(store.rewindingThreadKeys); - remaining.delete(routeThreadKey); - return { rewindingThreadKeys: remaining }; - }); - if (rewind.error !== null) { - setThreadError(activeThread.id, rewind.error); - return; - } - if (currentRouteThreadKeyRef.current === routeThreadKey) { - const nextPrompt = rewind.prompt; - promptRef.current = nextPrompt; - composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); - requestAnimationFrame(() => { - if (currentRouteThreadKeyRef.current === routeThreadKey) composerRef.current?.focusAtEnd(); - }); - } - }; - const onRevertToTurnCountStable = useStableCallback(onRevertToTurnCount); - const onConfirmRevert = (turnCount: number, messageId: MessageId, restoreFiles: boolean) => { - void onRevertToTurnCount(turnCount, messageId, restoreFiles); - }; - const onConfirmRevertStable = useStableCallback(onConfirmRevert); - - const onRollbackCheckpoint = async (input: { - readonly checkpointId: string; - readonly scopeId: string; - }) => { - if (!activeThread || isRevertingCheckpoint) return; - if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { - setThreadError( - activeThread.id, - `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, - ); - return; - } - if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; - } - const localApi = readLocalApi(); - const confirmed = - localApi == null - ? window.confirm("Roll back this thread to the selected checkpoint?") - : await localApi.dialogs.confirm( - "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.", - ); - if (!confirmed) return; - - useComposerDraftStore.setState((store) => ({ - rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), - })); - setThreadError(activeThread.id, null); - const result = await revertThreadCheckpoint({ - environmentId, - input: { - threadId: activeThread.id, - checkpointId: input.checkpointId, - scopeId: input.scopeId, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to revert thread state.", - ); - } - useComposerDraftStore.setState((store) => { - const remaining = new Set(store.rewindingThreadKeys); - remaining.delete(routeThreadKey); - return { rewindingThreadKeys: remaining }; - }); - }; - - const onForkFromRun = async (input: { - readonly sourceThreadId: ThreadId; - readonly runId: RunId; - }) => { - if (!activeThread || activeEnvironmentUnavailable) return; - const targetThreadId = newThreadId(); - const targetThreadRef = scopeThreadRef(environmentId, targetThreadId); - const result = await forkThreadFromRun({ - environmentId, - input: { - sourceThreadId: input.sourceThreadId, - targetThreadId, - runId: input.runId, - title: `${activeThread.title} fork`, - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to fork this response.", - ); - } - return; - } - const targetThreadReady = await waitForThreadShell(targetThreadRef); - if (!targetThreadReady) { - setThreadError( - activeThread.id, - "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.", - ); - return; - } - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(targetThreadRef), - }); - }; - const onForkFromRunStable = useStableCallback(onForkFromRun); - const onCompactContext = () => { - if (compactDisabled) return; - void sendStandaloneCommand("/compact", "Failed to compact context."); - }; - - const onResume = async () => { - if ( - !activeThread || - (resumableRunId === null && !hasHeldQueuedRuns) || - isSendBusy || - isResuming || - isConnecting || - isRevertingCheckpoint || - threadDetailLoading || - activeEnvironmentUnavailable || - sendInFlightRef.current - ) { - return; - } - const threadId = activeThread.id; - sendInFlightRef.current = true; - setResumingThreadKeys((current) => new Set(current).add(routeThreadKey)); - setThreadError(threadId, null); - // Commands settle into a result, so this always runs. - const resume = async () => { - if (resumableRunId === null) { - return resumeThreadQueue({ environmentId, input: { threadId } }); - } - const createdAt = new Date().toISOString(); - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode, - }); - if (settingsResult._tag === "Failure") return settingsResult; - const modelSelection = composerRef.current?.getSendContext().selectedModelSelection; - const turnResult = await startThreadTurn({ - environmentId, - input: { - threadId, - manualContinuationOfRunId: resumableRunId, - ...(modelSelection ? { modelSelection } : {}), - message: { - messageId: newMessageId(), - role: "user", - text: "Continue where you left off.", - attachments: [], - }, - runtimeMode, - interactionMode, - dispatchMode: "start", - }, - }); - if (turnResult._tag === "Failure" || !hasHeldQueuedRuns) return turnResult; - clearUsageLimitsFor(routeThreadKey); - return resumeThreadQueue({ environmentId, input: { threadId } }); - }; - const result = await resume(); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - threadId, - error instanceof Error ? error.message : "Could not resume thread.", - ); - } - } else { - clearUsageLimitsFor(routeThreadKey); - if (currentRouteThreadKeyRef.current === routeThreadKey) scrollToEnd(); - } - sendInFlightRef.current = false; - setResumingThreadKeys((current) => { - const next = new Set(current); - next.delete(routeThreadKey); - return next; - }); - }; - - const onSend = async ( - e?: { preventDefault: () => void }, - dispatchMode: ComposerDispatchMode = "auto", - submissionIntent: ComposerSubmissionIntent = "foreground", - directAnnotation?: { - annotation: PreviewAnnotationPayload; - image: ComposerImageAttachment | null; - }, - ) => { - e?.preventDefault(); - if ( - usageLimitsOffered && - usageLimitsKey !== null && - !directAnnotation && - !composerHasNonPromptContent && - isUsageLimitsCommand(promptRef.current) - ) { - if (openUsageLimits()) { - promptRef.current = ""; - setComposerDraftPrompt(composerDraftTarget, ""); - composerRef.current?.resetCursorState(); - } - return; - } - - const notifyDirectAnnotationAttached = () => { - if (!directAnnotation) return; - toastManager.add( - stackedThreadToast({ - type: "info", - title: "Annotation attached to draft", - description: "Sending is unavailable right now. Finish the current action, then send.", - }), - ); - }; - if ( - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || - !activeThread || - (isSendBusy && !sendQueuesBehindSetup) || - isConnecting || - isRevertingCheckpoint || - !clientSettingsHydrated || - threadDetailLoading || - readPendingEnvironmentChange() !== null || - sendInFlightRef.current || - feedbackUploadsInFlightRef.current.has(routeThreadKey) - ) { - notifyDirectAnnotationAttached(); - return; - } - if (needsLoadBalancing) { - toastManager.add({ - type: "warning", - title: loadBalancing.pending - ? "Checking machine resources" - : "Choose a machine to continue", - description: loadBalancing.pending - ? "Resource checks are still running. You can choose a machine in the composer." - : referencedThreadContexts.length > 0 && - !logicalProjectEnvironments.some((candidate) => - referencedThreadContexts.every( - (record) => record.environmentId === candidate.environmentId, - ), - ) - ? "No machine can read every attached thread. Choose a machine in the composer or remove the threads." - : "No eligible machine has available resources. Choose a machine in the composer to override.", - }); - return; - } - if (activeEnvironmentUnavailable) { - const toastSlot = environmentUnavailableSendToastSlotRef.current; - environmentUnavailableSendToastSlotRef.current = - (toastSlot + 1) % ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE; - toastManager.add({ - ...stackedThreadToast({ - type: "warning", - title: "Not connected: message not sent", - description: "Reconnecting to the environment. Try again once it is connected.", - }), - id: `chat-send-environment-unavailable:${toastSlot}`, - }); - return; - } - const sendCtx = composerRef.current?.getSendContext(); - if (activePendingProgress && sendCtx?.answeringPendingUserInput !== false) { - if (directAnnotation) { - notifyDirectAnnotationAttached(); - return; - } - onAdvanceActivePendingUserInput(); - return; - } - if (!sendCtx?.providerAvailable) { - notifyDirectAnnotationAttached(); - return; - } - const multipleModelSelections = sendCtx.multipleModelSelections; - if ( - multipleModelSelections !== null && - serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true - ) { - setThreadError(activeThread.id, "Update this server before starting multiple models."); - return; - } - if ( - multipleModelSelections !== null && - (!isLocalDraftThread || - !isGitRepo || - !activeThreadBranch || - multipleModelSelections.length === 0) - ) { - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Choose models and a base branch", - description: - "Multiple models need a new thread in a Git project. Each gets its own worktree.", - }), - ); - return; - } - const { - images: sendContextImages, - files: composerFiles, - terminalContexts: composerTerminalContexts, - previewAnnotations: sendContextPreviewAnnotations, - reviewComments: composerReviewComments, - threadContexts: composerThreadContexts, - issueContexts: composerIssueContexts, - selectedProvider: ctxSelectedProvider, - selectedModel: ctxSelectedModel, - selectedProviderModels: ctxSelectedProviderModels, - selectedPromptEffort: ctxSelectedPromptEffort, - selectedModelSelection: ctxSelectedModelSelection, - interactionMode: sendInteractionMode, - interactionModeEnabled: sendInteractionModeEnabled, - } = sendCtx; - const { images: composerImages, previewAnnotations: composerPreviewAnnotations } = - mergeDirectPreviewAnnotation({ - images: sendContextImages, - fileCount: composerFiles.length, - previewAnnotations: sendContextPreviewAnnotations, - direct: directAnnotation, - }); - // A direct "send annotation" writes the draft and sends in the same tick; the reference - // must be in the text now, not after the next render. - const promptForSend = directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ - previewAnnotationContextReference(directAnnotation.annotation), - ]) - : promptRef.current; - const strandedThread = threadContextsOutsideEnvironment({ - text: promptForSend, - records: composerThreadContexts, - environmentId, - })[0]; - if (strandedThread) { - const owner = environmentById.get(strandedThread.environmentId)?.label ?? "its machine"; - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Attached thread is on another machine", - description: `This machine's agent can't read "${strandedThread.title}". Remove it, or switch back to ${owner} to send.`, - }), - ); - return; - } - if (queuedRunEdit.isEditing) { - // Edit mode repurposes the composer: sending saves the queued message - // in place instead of dispatching a new turn. - await queuedRunEdit.saveEdit( - { - prompt: promptForSend, - images: composerImages, - files: composerFiles, - terminalContexts: composerTerminalContexts, - reviewComments: composerReviewComments, - previewAnnotations: composerPreviewAnnotations, - threadContexts: composerThreadContexts, - issueContexts: composerIssueContexts, - }, - setThreadError, - ); - return; - } - const { - trimmedPrompt: trimmed, - sendableTerminalContexts: sendableComposerTerminalContexts, - expiredTerminalContextCount, - hasSendableContent, - } = deriveComposerSendState({ - prompt: promptForSend, - imageCount: composerImages.length + composerFiles.length, - terminalContexts: composerTerminalContexts, - elementContextCount: - composerPreviewAnnotations.length + - composerReviewComments.length + - composerThreadContexts.length + - composerIssueContexts.length, - }); - const feedbackCommand = - ctxSelectedProvider === "codex" && - composerImages.length === 0 && - composerFiles.length === 0 && - sendableComposerTerminalContexts.length === 0 && - composerPreviewAnnotations.length === 0 && - composerReviewComments.length === 0 && - composerIssueContexts.length === 0 && - composerThreadContexts.length === 0 - ? parseCodexFeedbackCommand(trimmed) - : null; - if (feedbackCommand && multipleModelSelections === null) { - if (!isServerThread || activeThread.activeProviderThreadId === null) { - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Start a Codex thread first", - description: "Send a message before you submit feedback.", - }), - ); - return; - } - feedbackUploadsInFlightRef.current.add(routeThreadKey); - await submitCodexFeedback({ - submission: { - id: newMessageId(), - command: trimmed, - createdAt: new Date().toISOString(), - }, - clearDraft: () => { - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - }, - onUpdate: (submission) => { - setFeedbackSubmissionsByThreadKey((current) => { - const existing = current[routeThreadKey] ?? []; - const found = existing.some((entry) => entry.id === submission.id); - return { - ...current, - [routeThreadKey]: found - ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) - : [...existing, submission], - }; - }); - }, - upload: () => - uploadThreadFeedback({ - environmentId: activeThread.environmentId, - input: { threadId: activeThread.id, ...feedbackCommand }, - }), - }).finally(() => { - feedbackUploadsInFlightRef.current.delete(routeThreadKey); - }); - - return; - } - if ( - !directAnnotation && - sendInteractionModeEnabled && - showPlanFollowUpPrompt && - activeProposedPlan && - composerImages.length === 0 && - composerFiles.length === 0 - ) { - const followUp = resolvePlanFollowUpSubmission({ - draftText: promptForSend, - planMarkdown: activeProposedPlan.planMarkdown, - }); - const outgoingFollowUpText = formatOutgoingPrompt({ - provider: ctxSelectedProvider, - model: ctxSelectedModel, - models: ctxSelectedProviderModels, - effort: ctxSelectedPromptEffort, - text: followUp.text.trim(), - }); - if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { - return; - } - const followUpPromptSnapshot = promptRef.current; - const followUpTerminalContexts = [...sendableComposerTerminalContexts]; - const followUpReviewComments = [...composerReviewComments]; - const followUpPreviewAnnotations = [...composerPreviewAnnotations]; - const followUpThreadContexts = [...composerThreadContexts]; - const followUpIssueContexts = [...composerIssueContexts]; - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - const followUpSent = await onSubmitPlanFollowUp({ - text: followUp.text, - context: buildMessageContext({ - terminalContexts: sendableComposerTerminalContexts, - reviewComments: composerReviewComments, - previewAnnotations: composerPreviewAnnotations, - threadContexts: composerThreadContexts, - issueContexts: composerIssueContexts, - }), - interactionMode: followUp.interactionMode, - }); - if (!followUpSent) { - promptRef.current = followUpPromptSnapshot; - composerTerminalContextsRef.current = [...followUpTerminalContexts]; - restorePlanFollowUpComposer({ - snapshot: { - prompt: followUpPromptSnapshot, - terminalContexts: followUpTerminalContexts, - reviewComments: followUpReviewComments, - previewAnnotations: followUpPreviewAnnotations, - threadContexts: followUpThreadContexts, - issueContexts: followUpIssueContexts, - }, - writePrompt: (prompt) => setComposerDraftPrompt(composerDraftTarget, prompt), - writeTerminalContexts: (contexts) => - setComposerDraftTerminalContexts(composerDraftTarget, [...contexts]), - writeReviewComments: (comments) => - setComposerDraftReviewComments(composerDraftTarget, [...comments]), - writePreviewAnnotations: (annotations) => - setComposerDraftPreviewAnnotations(composerDraftTarget, [...annotations]), - writeThreadContexts: (records) => - setComposerDraftThreadContexts(composerDraftTarget, [...records]), - writeIssueContexts: (records) => - setComposerDraftIssueContexts(composerDraftTarget, [...records]), - resetCursor: (options) => composerRef.current?.resetCursorState(options), - }); - } else if ( - submissionIntent === "background" && - currentRouteThreadKeyRef.current === routeThreadKey - ) { - handleNewThreadInActiveProject(); - } - return; - } - const standaloneSlashCommand = - sendInteractionModeEnabled && - composerImages.length === 0 && - composerFiles.length === 0 && - sendableComposerTerminalContexts.length === 0 && - composerPreviewAnnotations.length === 0 && - composerReviewComments.length === 0 && - composerIssueContexts.length === 0 && - composerThreadContexts.length === 0 - ? parseStandaloneComposerSlashCommand(trimmed) - : null; - if (standaloneSlashCommand && multipleModelSelections === null) { - handleInteractionModeChange(standaloneSlashCommand); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - return; - } - if (!hasSendableContent) { - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "empty", - ); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }), - ); - } - return; - } - if (!activeProject) { - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Choose a project first", - description: "This draft no longer points to an available project.", - }), - ); - return; - } - const threadIdForSend = activeThread.id; - const isFirstMessage = !sendQueuesBehindSetup && (!isServerThread || activeMessageCount === 0); - const baseBranchForWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath - ? activeThreadBranch - : null; - - // In worktree mode, require an explicit base branch so we don't silently - // fall back to local execution when branch selection is missing. - const shouldCreateWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThreadBranch) { - setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); - return; - } - - const composerImagesSnapshot = [...composerImages]; - const composerFilesSnapshot = [...composerFiles]; - const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; - const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; - const composerThreadContextsSnapshot = [...composerThreadContexts]; - const composerIssueContextsSnapshot = [...composerIssueContexts]; - const messageTextForSend = composerTerminalContexts - .filter((context) => !composerTerminalContextsSnapshot.includes(context)) - .reduce( - (text, context) => - removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, - promptForSend, - ) - .trim(); - // Records bind attachments by the id each side knows: the local id for the optimistic - // row, the upload id (or local id on the data-URL path) on the wire; the server - // rebinds them to the persisted id. - const buildOutgoingMessageContext = (attachmentIds: ReadonlyArray) => - buildMessageContext({ - terminalContexts: composerTerminalContextsSnapshot, - reviewComments: composerReviewCommentsSnapshot, - previewAnnotations: composerPreviewAnnotationsSnapshot, - threadContexts: composerThreadContextsSnapshot, - issueContexts: composerIssueContextsSnapshot, - attachments: composerAttachmentsSnapshot.map((attachment, index) => ({ - attachment, - attachmentId: attachmentIds[index] ?? attachment.id, - })), - }); - const outgoingMessageContext = buildOutgoingMessageContext( - composerAttachmentsSnapshot.map((attachment) => attachment.id), - ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); - // A stale Claude session compacts before this message so the turn does not re-read the - // old history. The message queues behind the /compact run, since steering into it is - // rejected. - const compactBeforeSend = - resumeCompactionTokens !== null && - !keepFullHistory && - messageTextForSend.toLowerCase() !== "/compact"; - const turnDispatchMode = compactBeforeSend || sendQueuesBehindSetup ? "queue" : dispatchMode; - const shouldQueueBehindActiveRun = - compactBeforeSend || - sendQueuesBehindSetup || - (phase === "running" && dispatchMode === "queue"); - const outgoingMessageText = formatOutgoingPrompt({ - provider: ctxSelectedProvider, - model: ctxSelectedModel, - models: ctxSelectedProviderModels, - effort: ctxSelectedPromptEffort, - text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, - }); - if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { - return; - } - - const readLiveAttachmentCapabilities = () => { - const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null; - const liveSupportsAttachmentUploads = - config?.environment.capabilities.attachmentUploads === true; - return { - supportsAttachmentUploads: liveSupportsAttachmentUploads, - fileBlockReason: fileAttachmentCapabilityBlockReason({ - files: composerFilesSnapshot, - attachmentUploadsCapabilityKnown: config !== null, - supportsAttachmentUploads: liveSupportsAttachmentUploads, - maxFileAttachmentBytes: - config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null, - }), - }; - }; - - const multipleTargets: Array<{ - readonly selection: ModelSelection; - readonly text: string; - readonly interactionMode: ProviderInteractionMode; - }> = []; - for (const selection of multipleModelSelections ?? []) { - const provider = providerInstanceEntries.find( - (entry) => entry.instanceId === selection.instanceId, - ); - if (!provider?.enabled || !provider.isAvailable || provider.status !== "ready") { - setThreadError(threadIdForSend, `Provider for ${selection.model} is unavailable.`); - return; - } - const providerBlockReason = getAntigravitySendBlockReason(provider.snapshot, selection.model); - if (providerBlockReason) { - setThreadError(threadIdForSend, providerBlockReason); - return; - } - const providerState = getComposerProviderState({ - provider: provider.driverKind, - model: selection.model, - models: provider.models, - modelOptions: selection.options, - promptInjectionState: getComposerPromptInjectionState(messageTextForSend), - planModeEnabled: settings.planModeEnabled, - }); - const text = formatOutgoingPrompt({ - provider: provider.driverKind, - model: selection.model, - models: provider.models, - effort: providerState.promptEffort, - text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, - }); - if (composerRef.current?.validateProviderInput(text) === false) return; - multipleTargets.push({ - selection: createModelSelection( - selection.instanceId, - selection.model, - providerState.modelOptionsForDispatch, - ), - text, - interactionMode: resolveComposerInteractionMode({ - planModeEnabled: settings.planModeEnabled, - provider: provider.snapshot, - interactionMode: sendInteractionMode, - }).interactionMode, - }); - } - - sendInFlightRef.current = true; - const sendGeneration = ++composerSendGenerationRef.current; - const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); - if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { - sendInFlightRef.current = false; - setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason); - return; - } - const turnUsesAttachmentUploads = - composerFilesSnapshot.length > 0 - ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads - : supportsAttachmentUploads; - if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) { - for (const attachment of composerAttachmentsSnapshot) { - const upload = - readAttachmentUpload(attachment.id)?.status === "failed" - ? retryAttachmentUpload - : startAttachmentUpload; - upload({ - environmentId, - image: attachment, - draftTarget: composerDraftTarget, - }); - } - await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id)); - const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities(); - if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { - sendInFlightRef.current = false; - setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason); - return; - } - if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) { - sendInFlightRef.current = false; - setThreadError(threadIdForSend, "Retry or remove failed uploads before sending."); - return; - } - } - - if ( - multipleModelSelections === null && - shouldDockDraftHeroForSubmission({ isDraftHeroState, activeThreadKey, submissionIntent }) && - activeThreadKey - ) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; - }); - const dockTransition = runMobileComposerTransition( - () => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }, - { - active: panelAnimationsActive, - durationMs: panelAnimationDurationMs, - }, - ); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; - } - - const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities(); - if ( - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || - attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null - ) { - sendInFlightRef.current = false; - setThreadError( - threadIdForSend, - attachmentCapabilitiesBeforeDispatch.fileBlockReason ?? - "This connection cannot change threads.", - ); - setDockedDraftHeroThreadKey((currentThreadKey) => - currentThreadKey === activeThreadKey ? null : currentThreadKey, - ); - return; - } - if (sendQueuesBehindSetup) { - setSetupQueueSendThreadKey(activeThreadKey); - } else { - beginLocalDispatch({ - preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), - submissionIntent: - submissionIntent === "background" && !isLocalDraftThread - ? "foreground" - : submissionIntent, - }); - beginWorktreeSetup( - multipleModelSelections === null && baseBranchForWorktree - ? { environmentId: activeThread.environmentId, threadId: threadIdForSend } - : null, - ); - } - - const turnAttachmentsPromise = Promise.all( - composerAttachmentsSnapshot.map(async (attachment) => { - if (turnUsesAttachmentUploads) { - const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; - if (!uploaded) { - throw new Error(`Attachment '${attachment.name}' did not finish uploading.`); - } - return uploaded; - } - if (attachment.type !== "image") { - throw new Error("This server does not support file attachments."); - } - return { - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - dataUrl: await readFileAsDataUrl(attachment.file), - ...(attachment.source ? { source: attachment.source } : {}), - }; - }), - ); - if (multipleModelSelections !== null) { - const failedSelections: ModelSelection[] = []; - let clearedDraft = false; - let releasedComposer = false; - let canRestoreDraft = () => false; - let startedCount = 0; - // Anything thrown here (an attachment the server can no longer take) - // fails every selection; the draft restore below still runs. - const sendResult = await settlePromise(async () => { - const attachments = await turnAttachmentsPromise; - const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; - if (fileBlockReason !== null) throw new Error(fileBlockReason); - const context = buildOutgoingMessageContext( - attachments.map((attachment, index) => - "id" in attachment && attachment.id !== undefined - ? attachment.id - : composerAttachmentsSnapshot[index]!.id, - ), - ); - const title = deriveThreadTitleSeed({ - text: messageTextForSend, - attachments: composerAttachmentsSnapshot, - }); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - clearedDraft = true; - const clearedDraftSnapshot = useComposerDraftStore - .getState() - .getComposerDraft(composerDraftTarget); - const submittedSelections = readMultipleModelSelections(); - canRestoreDraft = () => - currentRouteThreadKeyRef.current === routeThreadKey && - composerSendGenerationRef.current === sendGeneration && - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === - clearedDraftSnapshot && - readMultipleModelSelections() === submittedSelections; - setThreadError(threadIdForSend, null); - const starts = Promise.all( - multipleTargets.map(async (target) => { - const started = await startFanoutThread({ - retryKey: JSON.stringify([ - routeThreadKey, - target.selection.instanceId, - target.selection.model, - ]), - model: target.selection.model, - uncertainSubmissions: uncertainMultipleSubmissionsRef.current, - start: (targetThreadId) => { - const supportsInlineMessageContext = - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment - .capabilities.inlineMessageContext === true; - return startThreadTurn({ - environmentId, - input: { - threadId: targetThreadId, - message: { - messageId: newMessageId(), - role: "user", - text: - context && !supportsInlineMessageContext - ? serializeLegacyContextMessage({ - text: target.text, - records: context.records, - }) - : target.text, - attachments, - ...(context && supportsInlineMessageContext ? { context } : {}), - }, - modelSelection: target.selection, - titleSeed: title, - runtimeMode, - interactionMode: target.interactionMode, - bootstrap: { - createThread: { - projectId: activeProject.id, - title, - modelSelection: target.selection, - runtimeMode, - interactionMode: target.interactionMode, - branch: activeThreadBranch, - worktreePath: null, - createdAt: messageCreatedAt, - }, - prepareWorktree: { - projectCwd: activeProject.workspaceRoot, - baseBranch: activeThreadBranch!, - requireWorktree: true, - ...(startFromOrigin ? { startFromOrigin: true } : {}), - }, - runSetupScript: true, - }, - createdAt: messageCreatedAt, - }, - }); - }, - openThread: (threadId) => - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), - }), - }); - if (started) startedCount += 1; - else failedSelections.push(target.selection); - }), - ); - sendInFlightRef.current = false; - resetLocalDispatch(); - releasedComposer = true; - await starts; - if (startedCount > 0) { - toastManager.add( - stackedThreadToast({ - type: "success", - title: `Started ${startedCount} ${startedCount === 1 ? "thread" : "threads"} in background`, - }), - ); - } - if (failedSelections.length === 0 && turnUsesAttachmentUploads) { - releaseDraftAttachments(composerAttachmentsSnapshot); - } - }); - if (sendResult._tag === "Failure") { - const error = squashAtomCommandFailure(sendResult); - failedSelections.push(...multipleModelSelections); - setThreadError( - threadIdForSend, - error instanceof Error ? error.message : "Failed to send messages.", - ); - } - const restoreFailedDraft = () => { - setMultipleModelSelections(failedSelections); - if (clearedDraft) { - setComposerDraftPrompt(composerDraftTarget, messageTextForSend); - addComposerDraftImages( - composerDraftTarget, - composerImagesSnapshot.map(cloneComposerImageForRetry), - ); - addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftPreviewAnnotations( - composerDraftTarget, - composerPreviewAnnotationsSnapshot, - ); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot); - setComposerDraftIssueContexts(composerDraftTarget, composerIssueContextsSnapshot); - if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = messageTextForSend; - composerRef.current.resetCursorState({ - cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), - prompt: messageTextForSend, - detectTrigger: true, - }); - } - } - }; - if (failedSelections.length > 0) { - if (canRestoreDraft() && composerRef.current) { - restoreFailedDraft(); - } else if (clearedDraft) { - const recoveryToastId = toastManager.add( - stackedThreadToast({ - type: "error", - title: "A background prompt could not be sent", - description: - "Your newer draft is unchanged. Restore the failed prompt when this composer is empty.", - timeout: 0, - actionProps: { - children: "Restore prompt", - onClick: () => { - if ( - !draftId || - !useComposerDraftStore.getState().getDraftSession(draftId) || - sendInFlightRef.current || - composerDraftHasUserContent( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), - ) - ) { - toastManager.update(recoveryToastId, { - description: - "Return to the original draft and send or clear its current prompt before restoring.", - }); - return; - } - restoreFailedDraft(); - void navigate({ to: "/draft/$draftId", params: { draftId } }); - toastManager.close(recoveryToastId); - }, - }, - }), - ); - } - } - if (!releasedComposer) { - sendInFlightRef.current = false; - resetLocalDispatch(); - } - return; - } - const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => - attachment.type === "image" - ? { - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: attachment.previewUrl, - ...(attachment.source ? { source: attachment.source } : {}), - } - : { - type: "file" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - downloadable: false, - ...(attachment.source ? { source: attachment.source } : {}), - }, - ); - if (!shouldQueueBehindActiveRun) { - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); - } - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), - ...(outgoingMessageContext !== undefined ? { context: outgoingMessageContext } : {}), - runId: null, - createdAt: messageCreatedAt, - updatedAt: messageCreatedAt, - streaming: false, - ...(shouldQueueBehindActiveRun - ? { inputIntent: "queued_turn" as const } - : phase === "running" && dispatchMode === "steer" - ? { inputIntent: "steer" as const } - : {}), - }, - ]); - setThreadError(threadIdForSend, null); - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "omitted", - ); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }), - ); - } - const sentDraftThreadRef = isLocalDraftThread - ? scopeThreadRef(environmentId, threadIdForSend) - : null; - // Keep new-thread flows from reusing this draft while attachments and worktree setup settle. - if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - - const title = deriveThreadTitleSeed({ - text: messageTextForSend, - attachments: composerAttachmentsSnapshot, - fallbackLabels: [ - composerTerminalContextsSnapshot[0] - ? formatTerminalContextLabel(composerTerminalContextsSnapshot[0]) - : undefined, - composerReviewCommentsSnapshot[0] - ? `Review: ${reviewCommentContextLabel(composerReviewCommentsSnapshot[0])}` - : undefined, - composerPreviewAnnotationsSnapshot[0] - ? previewAnnotationContextLabel(composerPreviewAnnotationsSnapshot[0]) - : undefined, - ], - }); - const threadCreateModelSelection = createModelSelection( - ctxSelectedModelSelection.instanceId, - ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL, - ctxSelectedModelSelection.options, - ); - - let failure: AtomCommandResult | null = null; - - if (failure === null && isServerThread) { - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode: sendInteractionMode, - }); - if (settingsResult._tag === "Failure") { - failure = settingsResult; - } - } - - const turnAttachmentsResult = await settlePromise(async () => { - const turnAttachments = await turnAttachmentsPromise; - const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; - if (liveFileBlockReason !== null) { - throw new Error(liveFileBlockReason); - } - return turnAttachments; - }); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } - - if (failure === null && compactBeforeSend) { - const compactResult = await startThreadTurn({ - environmentId, - input: { - threadId: threadIdForSend, - message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, - modelSelection: ctxSelectedModelSelection, - runtimeMode, - interactionMode: sendInteractionMode, - }, - }); - if (compactResult._tag === "Failure") { - failure = compactResult; - } - } - - let backgroundDraftOpened = false; - let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { - const bootstrap = - isLocalDraftThread || baseBranchForWorktree - ? { - ...(isLocalDraftThread - ? { - createThread: { - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode: sendInteractionMode, - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt: activeThread.createdAt, - }, - } - : {}), - ...(baseBranchForWorktree - ? { - prepareWorktree: { - projectCwd: activeProject.workspaceRoot, - baseBranch: baseBranchForWorktree, - ...(isLocalDraftThread && draftThread?.worktreeBranch - ? { branch: draftThread.worktreeBranch } - : {}), - ...(startFromOrigin ? { startFromOrigin: true } : {}), - }, - runSetupScript: true, - } - : {}), - } - : undefined; - const backgroundThreadRef = - submissionIntent === "background" && isLocalDraftThread - ? scopeThreadRef(environmentId, threadIdForSend) - : null; - if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); - const startPromise = startThreadTurn({ - environmentId, - input: { - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: turnAttachmentsResult.value, - ...(() => { - const context = buildOutgoingMessageContext( - turnAttachmentsResult.value.map((attachment, index) => - "id" in attachment && attachment.id !== undefined - ? attachment.id - : composerAttachmentsSnapshot[index]!.id, - ), - ); - if (context === undefined) return {}; - // Read the capability at dispatch time: the upload and persistence - // awaits above can span a server reconnect that changes it. Servers - // from before inline context drop the records and forward the links - // as literal text, so their turns carry the payload the legacy way. - const supportsInlineMessageContext = - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment - .capabilities.inlineMessageContext === true; - if (!supportsInlineMessageContext) { - return { - text: serializeLegacyContextMessage({ - text: outgoingMessageText, - records: context.records, - }), - }; - } - return { context }; - })(), - }, - modelSelection: ctxSelectedModelSelection, - titleSeed: title, - runtimeMode, - interactionMode: sendInteractionMode, - dispatchMode: turnDispatchMode, - ...(bootstrap ? { bootstrap } : {}), - createdAt: messageCreatedAt, - }, - }); - if (backgroundThreadRef) { - try { - backgroundDraftOpened = Boolean( - await handleNewThread( - scopeProjectRef(activeProject.environmentId, activeProject.id), - resolveBackgroundDraftWorkspaceOptions({ - envMode: sendEnvMode, - branch: activeThreadBranch, - startFromOrigin, - }), - ), - ); - } catch (error) { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Could not open a fresh composer", - description: error instanceof Error ? error.message : undefined, - }), - ); - } - } - const startResult = await startPromise; - if (startResult._tag === "Failure") { - failure = startResult; - } else { - turnStartSucceeded = true; - setKeepFullHistory(routeThreadKey, false); - clearUsageLimitsFor(routeThreadKey); - if (turnUsesAttachmentUploads) { - releaseDraftAttachments(composerAttachmentsSnapshot); - } - acknowledgeActiveThreadWoke(); - // A draft still on screen finalizes once its thread route takes over. - if (sentDraftThreadRef) { - if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) { - finalizePromotedDraftThreadByRef(sentDraftThreadRef); - } else { - clearBackgroundDraftSubmissionByRef(sentDraftThreadRef); - } - } - if (backgroundThreadRef) { - if (backgroundDraftOpened) { - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Started in background", - timeout: 5_000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(backgroundThreadRef), - }); - }, - }, - }), - ); - } - } else if ( - submissionIntent === "background" && - currentRouteThreadKeyRef.current === routeThreadKey - ) { - handleNewThreadInActiveProject(); - } - } - } - - if (failure !== null) { - if (isLocalDraftThread && draftId && draftThread) { - restoreFailedDraftThread( - draftId, - draftThread, - wasBootstrapThreadDeleted(squashAtomCommandFailure(failure)) - ? newThreadId() - : threadIdForSend, - ); - clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); - } - if ( - !composerDraftHasUserContent( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), - ) - ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - setComposerDraftPrompt(composerDraftTarget, messageTextForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot); - setComposerDraftIssueContexts(composerDraftTarget, composerIssueContextsSnapshot); - if (currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = messageTextForSend; - composerImagesRef.current = retryComposerImages; - composerFilesRef.current = composerFilesSnapshot; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), - prompt: messageTextForSend, - detectTrigger: true, - }); - } - } - if (!isAtomCommandInterrupted(failure)) { - const error = squashAtomCommandFailure(failure); - setThreadError( - threadIdForSend, - error instanceof Error ? error.message : "Failed to send message.", - ); - if ( - draftId && - (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) - ) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: - submissionIntent === "background" - ? "Background task failed" - : "Could not start thread", - description: error instanceof Error ? error.message : "Failed to send message.", - actionProps: { - children: "Open draft", - onClick: () => { - void navigate({ to: "/draft/$draftId", params: { draftId } }); - }, - }, - }), - ); - } - } - } - sendInFlightRef.current = false; - setSetupQueueSendThreadKey((current) => (current === activeThreadKey ? null : current)); - if (!turnStartSucceeded) { - setDockedDraftHeroThreadKey((currentThreadKey) => - currentThreadKey === activeThreadKey ? null : currentThreadKey, - ); - if (!sendQueuesBehindSetup) resetLocalDispatch(); - } - }; - - async function onSubmitPlanFollowUp({ - text, - context, - interactionMode: nextInteractionMode, - }: { - text: string; - context?: ReturnType; - interactionMode: "default" | "plan"; - }) { - if ( - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || - !activeThread || - !isServerThread || - isSendBusy || - isConnecting || - sendInFlightRef.current - ) { - return false; - } - - const trimmed = text.trim(); - if (!trimmed) { - return false; - } - - const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) { - return false; - } - const { - selectedProvider: ctxSelectedProvider, - selectedModel: ctxSelectedModel, - selectedProviderModels: ctxSelectedProviderModels, - selectedPromptEffort: ctxSelectedPromptEffort, - selectedModelSelection: ctxSelectedModelSelection, - } = sendCtx; - - const threadIdForSend = activeThread.id; - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: ctxSelectedProvider, - model: ctxSelectedModel, - models: ctxSelectedProviderModels, - effort: ctxSelectedPromptEffort, - text: trimmed, - }); - - sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: false }); - setThreadError(threadIdForSend, null); - - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); - - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - ...(context ? { context } : {}), - runId: null, - createdAt: messageCreatedAt, - updatedAt: messageCreatedAt, - streaming: false, - }, - ]); - - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), - runtimeMode, - interactionMode: nextInteractionMode, - }); - let failure: AtomCommandResult | null = - settingsResult._tag === "Failure" ? settingsResult : null; - - if (failure === null) { - setComposerDraftInteractionMode( - scopeThreadRef(activeThread.environmentId, threadIdForSend), - nextInteractionMode, - ); - - const startResult = await startThreadTurn({ - environmentId, - input: { - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - ...(context ? { context } : {}), - attachments: [], - }, - modelSelection: ctxSelectedModelSelection, - titleSeed: activeThread.title, - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - } - : {}), - createdAt: messageCreatedAt, - }, - }); - failure = startResult._tag === "Failure" ? startResult : null; - } - - if (failure === null) { - sendInFlightRef.current = false; - resetLocalDispatch(); - return true; - } - - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); - if (!isAtomCommandInterrupted(failure)) { - const error = squashAtomCommandFailure(failure); - setThreadError( - threadIdForSend, - error instanceof Error ? error.message : "Failed to send plan follow-up.", - ); - } - sendInFlightRef.current = false; - resetLocalDispatch(); - return false; - } - - const onImplementPlanInNewThread = async () => { - if ( - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || - !activeThread || - !activeProject || - !activeProposedPlan || - !isServerThread || - isSendBusy || - isConnecting || - activeEnvironmentUnavailable || - sendInFlightRef.current - ) { - return; - } - - const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) { - return; - } - const { - selectedProvider: ctxSelectedProvider, - selectedModel: ctxSelectedModel, - selectedProviderModels: ctxSelectedProviderModels, - selectedPromptEffort: ctxSelectedPromptEffort, - selectedModelSelection: ctxSelectedModelSelection, - } = sendCtx; - - const createdAt = new Date().toISOString(); - const nextThreadId = newThreadId(); - const planMarkdown = activeProposedPlan.planMarkdown; - const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); - const outgoingImplementationPrompt = formatOutgoingPrompt({ - provider: ctxSelectedProvider, - model: ctxSelectedModel, - models: ctxSelectedProviderModels, - effort: ctxSelectedPromptEffort, - text: implementationPrompt, - }); - const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); - const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; - - sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: false }); - const finish = () => { - sendInFlightRef.current = false; - resetLocalDispatch(); - }; - const createResult = await createThread({ - environmentId, - input: { - threadId: nextThreadId, - projectId: activeProject.id, - title: nextThreadTitle, - modelSelection: nextThreadModelSelection, - runtimeMode: defaultRuntimeMode, - interactionMode: "default", - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt, + useChatShortcuts({ + enabled: activeThreadId !== null, + keybindings, + scriptKeybindings, + getShortcutContext, + threadFindActive: isThreadFindActive, + composerRef, + branchToolbarRef, + closeThreadFind: () => { + closeThreadFind(); + focusComposerStable(); + }, + commands: { + ...terminalShortcuts, + ...rightPanelShortcuts, + ...threadLifecycleShortcuts, + "thread.copyReference": ({ event }) => { + if (!event.repeat) copyActiveThreadReference(); }, - }); - let failure: AtomCommandResult | null = - createResult._tag === "Failure" ? createResult : null; - - if (failure === null) { - const startResult = await startThreadTurn({ - environmentId, - input: { - threadId: nextThreadId, - message: { - messageId: newMessageId(), - role: "user", - text: outgoingImplementationPrompt, - attachments: [], - }, - modelSelection: ctxSelectedModelSelection, - titleSeed: nextThreadTitle, - runtimeMode: defaultRuntimeMode, - interactionMode: "default", - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - createdAt, - }, - }); - failure = startResult._tag === "Failure" ? startResult : null; - } - - if (failure === null) { - const startedResult = await settlePromise(() => - waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)), - ); - failure = startedResult._tag === "Failure" ? startedResult : null; - } - - if (failure === null) { - const navigateResult = await settlePromise(() => - navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId: activeThread.environmentId, - threadId: nextThreadId, - }, - }), - ); - failure = navigateResult._tag === "Failure" ? navigateResult : null; - } - - if (failure !== null) { - const cleanupResult = await deleteThread({ - environmentId, - input: { - threadId: nextThreadId, - }, - }); - if (cleanupResult._tag === "Failure" && !isAtomCommandInterrupted(cleanupResult)) { - console.warn( - "Failed to clean up implementation thread after start failure.", - squashAtomCommandFailure(cleanupResult), - ); - } - if (!isAtomCommandInterrupted(failure)) { - const error = squashAtomCommandFailure(failure); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start implementation thread", - description: - error instanceof Error - ? error.message - : "An error occurred while creating the new thread.", - }), + "chat.find": () => { + if (!isServerThread || serverConfig?.threadFind !== true) return false; + openThreadFind(); + }, + "composer.cycleHost": ({ event }) => { + if (!canChangeEnvironment) return false; + if (event.repeat) return; + const currentId = readPendingEnvironmentChange()?.environmentId ?? environmentId; + const index = logicalProjectEnvironments.findIndex( + (env) => env.environmentId === currentId, ); - } - } - finish(); - }; - const onImplementPlanInNewThreadStable = useStableCallback(onImplementPlanInNewThread); - - // Keyed on the fields it reads: the thread and runtime objects change with - // every streamed event, and a new function re-renders the whole model picker. - const hasStartedSession = activeRuntime !== null; - const runtimeProviderInstanceId = activeRuntime?.providerInstanceId ?? null; - const currentModelSelection = activeThread?.modelSelection ?? null; - const getModelDisabledReason = makeModelDisabledReason( - currentModelSelection - ? { - providers: providerStatuses, - hasStartedSession, - supportsProviderSwitchingViaHandoff, - currentModelSelection, - currentProviderInstanceId: runtimeProviderInstanceId, - } - : null, - ); + const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length]; + if (next) onEnvironmentChangeStable(next.environmentId); + }, + "thread.steerQueuedMessage": ({ event }) => + routeKind !== "draft" && queuedRunsControlRef.current?.steerNext(event.repeat) === true, + "thread.editQueuedMessage": ({ event }) => + routeKind !== "draft" && + composerRef.current?.isCaretAtStart() === true && + queuedRunsControlRef.current?.editLatest(event.repeat) === true, + "thread.stop": ({ event }) => { + if (!canInterruptRunningThread) return false; + if (!event.repeat) void onInterruptStable(); + }, + }, + fallback: (command) => { + const scriptId = projectScriptIdFromCommand(command); + if (!scriptId || !activeProject || !canOperateTerminal) return undefined; + const script = activeProjectScripts.find((entry) => entry.id === scriptId); + return script ? () => void runProjectScriptStable(script) : undefined; + }, + }); - const onProviderModelSelect = ( - instanceId: ProviderInstanceId, - model: string, - options?: { focusComposer?: boolean }, - ) => { - if (!activeThread) return; - const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); - const resolvedDriverKind = entry?.driver ?? null; - if ( - !supportsProviderSwitchingViaHandoff && - lockedProvider !== null && - resolvedDriverKind !== null && - resolvedDriverKind !== lockedProvider - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - if ( - !supportsProviderSwitchingViaHandoff && - lockedProvider !== null && - activeRuntime?.providerInstanceId - ) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - } - const resolvedModel = resolveAppModelSelectionForInstance( - instanceId, - settings, - providerStatuses, - model, - ); - if (!resolvedModel) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - const rememberedOptions = - useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[resolvedModel]; - const nextModelSelection: ModelSelection = - rememberedOptions !== undefined && rememberedOptions.length > 0 - ? { instanceId, model: resolvedModel, options: [...rememberedOptions] } - : { instanceId, model: resolvedModel }; - const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ - providers: providerStatuses, - hasStartedSession: activeRuntime !== null, - supportsProviderSwitchingViaHandoff, - currentModelSelection: activeThread.modelSelection, - currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, - nextModelSelection, + // A focused desktop browser page forwards these chords as menu actions. + useEffect(() => { + return window.desktopBridge?.onMenuAction((action) => { + if (action === "rightPanel.toggle") toggleRightPanelStable(); + else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); }); - if (modelChangeBlockReason) { - toastManager.add({ - type: "warning", - title: modelChangeBlockReason.title, - description: modelChangeBlockReason.description, - }); - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - setComposerDraftModelSelection( - scopeThreadRef(activeThread.environmentId, activeThread.id), - nextModelSelection, - { explicit: true, replaceOptions: true }, - ); - setStickyComposerModelSelection(nextModelSelection); - if (options?.focusComposer !== false) scheduleComposerFocus(); - }; - const onProviderModelSelectStable = useStableCallback(onProviderModelSelect); - const onEnvModeChange = (mode: DraftThreadEnvMode) => { - if (multipleModelSelections !== null) return; - if (canOverrideServerThreadEnvMode) { - if (activeThread) setPendingServerThreadEnvModeEntry({ threadId: activeThread.id, mode }); - scheduleComposerFocus(); - return; - } - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { - envMode: mode, - startFromOrigin: resolveNewDraftStartFromOrigin({ - envMode: mode, - newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, - }), - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), - }); - } - scheduleComposerFocus(); + }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); + + usePasteToFocusComposer(activeThreadId, composerRef); + const { + onConfirmRevertStable, + onForkFromRunStable, + onRevertTimelineTurnStable, + onTimelineRollbackCheckpointStable, + } = useTimelineRewind({ + environmentId, + routeThreadRef, + routeThreadKey, + currentRouteThreadKeyRef, + serverProjection, + activeThread, + timelineMessages, + paintOnlyDisplayedTimeline, + phase, + isConnecting, + isSendBusy, + isRevertingCheckpoint, + supportsConversationRollback, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableLabel, + promptRef, + composerRef, + composerDraftTarget, + setThreadError, + }); + const onCompactContext = () => { + if (compactDisabled) return; + void sendStandaloneCommand("/compact", "Failed to compact context."); }; - const onEnvModeChangeStable = useStableCallback(onEnvModeChange); + + const { + getModelDisabledReason, + handleInteractionModeChange, + handleInteractionModeChangeStable, + handleRuntimeModeChangeStable, + onEnvModeChangeStable, + onProviderModelSelectStable, + onStartFromOriginChange, + openProviderSetupStable, + toggleInteractionModeStable, + } = useComposerSelection({ + environmentId, + composerDraftTarget, + draftThread, + activeThread, + activeRuntime, + activeProjectSettings, + settings, + providerStatuses, + lockedProvider, + supportsProviderSwitchingViaHandoff, + multipleModelSelections, + runtimeMode, + interactionMode, + interactionModeEnabled, + isLocalDraftThread, + canOverrideServerThreadEnvMode, + setPendingServerThreadEnvModeEntry, + setPendingServerThreadStartFromOriginByThreadId, + scheduleComposerFocus, + }); + const { onResume, isResuming } = useComposerResume({ + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + activeThread, + resumableRunId, + hasHeldQueuedRuns, + isSendBusy, + isConnecting, + isRevertingCheckpoint, + threadDetailLoading, + activeEnvironmentUnavailable, + runtimeMode, + interactionMode, + localCheckoutBranchMismatch, + composerRef, + persistThreadSettingsForNextTurn, + clearUsageLimitsFor, + scrollToEnd, + setThreadError, + }); + const onImplementPlanInNewThread = useImplementPlanInNewThread({ + environmentId, + routeThreadKey, + activeThread, + activeThreadBranch, + activeProposedPlan, + isServerThread, + isSendBusy, + isConnecting, + composerRef, + beginLocalDispatch, + resetLocalDispatch, + activeProject, + activeEnvironmentUnavailable, + defaultRuntimeMode, + }); + const { onSend } = useComposerSend({ + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + draftId, + draftThread, + activeThread, + activeThreadKey, + activeThreadBranch, + activeProject, + activeProjectDefaultModelSelection, + activeProposedPlan, + activeMessageCount, + isServerThread, + isLocalDraftThread, + isGitRepo, + phase, + runtimeMode, + sendEnvMode, + startFromOrigin, + localCheckoutBranchMismatch, + logicalProjectEnvironments, + providerInstanceEntries, + composerRef, + composerDraftTarget, + promptRef, + composerImagesRef, + composerFilesRef, + composerTerminalContextsRef, + feedbackUploadsInFlightRef, + queuedRunEdit, + isConnecting, + isRevertingCheckpoint, + threadDetailLoading, + activeEnvironmentUnavailable, + isSendBusy, + sendQueuesBehindSetup, + isDraftHeroState, + panelAnimationsActive, + panelAnimationDurationMs, + keepFullHistory, + resumeCompactionTokens, + showPlanFollowUpPrompt, + usageLimitsKey, + usageLimitsOffered, + openUsageLimits, + clearUsageLimitsFor, + needsLoadBalancing, + loadBalancing, + readPendingEnvironmentChange, + pendingApprovals, + respondingRequestIds, + activePendingProgress, + onAdvanceActivePendingUserInput, + onRespondToApproval: onRespondToApprovalStable, + beginLocalDispatch, + resetLocalDispatch, + beginWorktreeSetup, + captureDraftHeroComposerRect, + setDockedDraftHeroThreadKey, + setSetupQueueSendThreadKey, + setOptimisticUserMessages, + setFeedbackSubmissionsByThreadKey, + setThreadError, + setKeepFullHistory, + anchorNewTurn, + acknowledgeActiveThreadWoke, + handleInteractionModeChange, + handleNewThreadInActiveProject, + persistThreadSettingsForNextTurn, + }); + const onImplementPlanInNewThreadStable = useStableCallback(onImplementPlanInNewThread); const onWorktreeSetupWorkLocally = () => { if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) { @@ -7990,47 +3062,10 @@ export default function ChatView(props: ChatViewProps) { workLocallyResendReady, ]); - const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { - if (canOverrideServerThreadEnvMode && activeThread) { - setPendingServerThreadStartFromOriginByThreadId((current) => - current[activeThread.id] === nextStartFromOrigin - ? current - : { ...current, [activeThread.id]: nextStartFromOrigin }, - ); - return; - } - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { - startFromOrigin: nextStartFromOrigin, - }); - } - }; - const onExpandTimelineImage = (preview: ExpandedImagePreview) => { openExpandedImagePreview(routeThreadKey, preview); }; const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage); - const onOpenTurnDiff = (runId: RunId, filePath?: string) => { - if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; - useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }; - const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff); - const onTimelineRollbackCheckpoint = (input: { - readonly checkpointId: string; - readonly scopeId: string; - }) => { - if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); - }; - const onTimelineRollbackCheckpointStable = useStableCallback(onTimelineRollbackCheckpoint); - const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => { - void onRevertToTurnCountStable(targetTurnCount, messageId); - }; - const onRevertTimelineTurnStable = useStableCallback(onRevertTimelineTurn); const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); const consumePendingFileDrop = useSidebarPendingFileDropStore( @@ -8165,46 +3200,17 @@ export default function ChatView(props: ChatViewProps) { showThreadPanelControl={!inlineRightPanelOwnsTitleBar} /> ); - const threadPanelHeaderControl = ( -

- -
- ); + const threadPanelHeaderControl = ; const panelLayoutControls = ( -
- {!shouldUsePlanSidebarSheet ? ( - - - - ) : null} -
{panelToggleControls}
-
+ {panelToggleControls} + ); const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, @@ -8279,7 +3285,6 @@ export default function ChatView(props: ChatViewProps) { thread: activeThreadRef, enabled: isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline, - content: serverProjection ?? undefined, }} controlsRef={threadFindControlsRef} onOpenChange={setIsThreadFindActive} @@ -8292,46 +3297,38 @@ export default function ChatView(props: ChatViewProps) { onDrop={workspaceFileDropHandlers.onDrop} > - {isWorkspaceFileDragActive ? ( -
-
-
-
- ) : null} -
- setDismissedProviderStatusBannerKey(providerStatusBannerKey)} - onOpenProviderSetup={openProviderSetupStable} - /> - { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} - /> -
+ {isWorkspaceFileDragActive ? : null} + - {showScrollToBottom && !isThreadFindActive && ( -
- -
+ { + composerRef.current?.restoreAfterTimelineReachedEnd(); + scrollToEnd(true); + }} + /> )}
@@ -8908,35 +3892,37 @@ export default function ChatView(props: ChatViewProps) {
- {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} +
- {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( - {panelToggleControls}
+ ) : null + } surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} activeSurfaceId={renderedRightPanelSurface?.id ?? null} @@ -8975,65 +3961,7 @@ export default function ChatView(props: ChatViewProps) { deviceAvailable={activeThreadRef !== null} > {rightPanelContent} - - ) : null} - {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? ( - - {panelToggleControls}
- ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} - pendingSurfaceIds={pendingFileSurfaceIds} - previewSessions={activePreviewState.sessions} - desktopByTabId={activePreviewState.desktopByTabId} - previewRuntimeTabId={resolvePreviewRuntimeTabId} - terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurfaceStable} - onCloseSurface={closeRightPanelSurfaceStable} - onRenameDevice={(surfaceId, title) => { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} - onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} - onCloseAllSurfaces={closeAllRightPanelSurfacesStable} - onMoveSurface={moveRightPanelSurfaceStable} - onCopyFilePath={copyRightPanelFilePathStable} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurfaceStable} - onAddTerminal={addTerminalSurfaceStable} - onAddDiff={addDiffSurfaceStable} - onAddFiles={addFilesSurfaceStable} - onAddPullRequest={addPullRequestSurfaceStable} - onAddPullRequests={addPullRequestsSurfaceStable} - onAddIssues={addIssuesSurfaceStable} - onAddDevice={addDeviceSurfaceStable} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - issuesAvailable={issuesSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - > - {rightPanelContent} - - + ) : null} @@ -9057,76 +3985,3 @@ function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) { usePreviewSession(props.threadRef); return null; } - -// The visit watermark is the one part of the chat view that follows the -// shell's updatedAt, which ticks while a run streams. Keeping it in a leaf lets -// ChatView read the quiet shell. -function ThreadVisitWatermark(props: { readonly threadRef: ScopedThreadRef }) { - const serverThread = useThreadShell(props.threadRef); - const threadKey = scopedThreadKey(props.threadRef); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLocalLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadKey], - ); - const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); - const lastDispatchedVisitRef = useRef(null); - const lastVisitDispatchAtRef = useRef(0); - - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const effectiveLastVisitedAt = resolveThreadLastVisitedAt( - serverThread.lastVisitedAt, - activeThreadLocalLastVisitedAt, - ); - const lastVisitedAt = effectiveLastVisitedAt ? Date.parse(effectiveLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - if (serverThread.lastVisitedAt !== undefined) { - // Dedupe per watermark — the effect re-runs before the command's echo - // lands. The dedupe also keeps a mark-unread on the open thread sticky: - // the rewind leaves updatedAt untouched, so the already-dispatched key - // skips a fresh visit until new activity lands or the thread is reopened. - const dispatchKey = `${threadKey}:${serverThread.updatedAt}`; - if (lastDispatchedVisitRef.current === dispatchKey) return; - const dispatch = () => { - lastDispatchedVisitRef.current = dispatchKey; - lastVisitDispatchAtRef.current = Date.now(); - void visitThreadMutation({ - environmentId: serverThread.environmentId, - input: { threadId: serverThread.id, visitedAt: serverThread.updatedAt }, - }); - }; - const latestRunCompletedAtMs = serverThread.latestRun?.completedAt - ? Date.parse(serverThread.latestRun.completedAt) - : NaN; - const hasUnseenCompletion = - !Number.isNaN(latestRunCompletedAtMs) && - (Number.isNaN(lastVisitedAt) || latestRunCompletedAtMs > lastVisitedAt); - const elapsed = Date.now() - lastVisitDispatchAtRef.current; - if (hasUnseenCompletion || elapsed >= VISIT_DISPATCH_THROTTLE_MS) { - dispatch(); - return; - } - const timer = setTimeout(dispatch, VISIT_DISPATCH_THROTTLE_MS - elapsed); - return () => clearTimeout(timer); - } - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLocalLastVisitedAt, - markThreadVisited, - threadKey, - serverThread?.environmentId, - serverThread?.id, - serverThread?.lastVisitedAt, - serverThread?.latestRun?.completedAt, - serverThread?.updatedAt, - visitThreadMutation, - ]); - return null; -} diff --git a/apps/web/src/components/CommandPalette.focus.browser.test.tsx b/apps/web/src/components/CommandPalette.focus.browser.test.tsx deleted file mode 100644 index a48f8865a1d6e..0000000000000 --- a/apps/web/src/components/CommandPalette.focus.browser.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useImperativeHandle, useRef, type ReactNode } from "react"; -import { describe, expect, it, vi } from "vite-plus/test"; -import { act, fakeTimers, render } from "../../test/render"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { CommandPalette } from "./CommandPalette"; -import { useComposerHandleContext } from "../composerHandleContext"; -import { openCommandPalette } from "../commandPaletteBus"; -import { primaryServerKeybindingsAtom } from "../state/server"; - -vi.mock("@tanstack/react-router", async (importOriginal) => ({ - ...(await importOriginal()), - useNavigate: () => vi.fn(), - useParams: () => null, - useLocation: () => "/settings", -})); -vi.mock("@effect/atom-react", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - useAtomValue: (atom: unknown) => - atom === primaryServerKeybindingsAtom ? DEFAULT_RESOLVED_KEYBINDINGS : [], - }; -}); -vi.mock("../hooks/useTheme", () => ({ - useRenderedAppearance: () => "light", - useTheme: () => ({ theme: "default", resolvedTheme: "light", appearanceMode: "light" }), -})); -vi.mock("../hooks/useCustomThemes", () => ({ useCustomThemes: () => [] })); -vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions: () => [] })); -vi.mock("../hooks/useSettings", async (importOriginal) => ({ - ...(await importOriginal()), - useClientSettings: () => ({}), -})); -vi.mock("../hooks/useScratchProject", () => ({ - useScratchProject: () => ({ scratchEnvironmentId: () => null }), -})); -vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => vi.fn() })); -vi.mock("../hooks/useHandleNewThread", () => ({ useHandleNewThread: () => ({}) })); -vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null })); -vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] })); -vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); -vi.mock("../state/environments", async (importOriginal) => ({ - ...(await importOriginal()), - useEnvironments: () => ({ environments: [] }), - usePrimaryEnvironmentId: () => null, -})); -vi.mock("../state/entities", async (importOriginal) => ({ - ...(await importOriginal()), - useProjects: () => [], - useThreadShells: () => [], - useServerConfigs: () => new Map(), -})); -vi.mock("../state/queries", async (importOriginal) => ({ - ...(await importOriginal()), - useThreadSearch: () => ({ matches: [] }), -})); -vi.mock("../state/session", async (importOriginal) => ({ - ...(await importOriginal()), - useEnvironmentScope: () => false, -})); -vi.mock("../state/query", async (importOriginal) => ({ - ...(await importOriginal()), - useEnvironmentQuery: () => ({}), -})); -vi.mock("./settings/useAvailableSettingsSearchItems", () => ({ - useAvailableSettingsSearchItems: () => [], -})); - -// Navigation hooks are mocked above, and the test router would load on the fake clock too. -async function renderPalette(children: ReactNode) { - await using stack = new AsyncDisposableStack(); - stack.use(fakeTimers()); - // Runs after unmount, so timers the palette leaves behind fire on the fake clock. - stack.defer(async () => { - await act(() => vi.runOnlyPendingTimersAsync()); - }); - const screen = stack.use( - await render({children}, { router: false }), - ); - const environment = stack.move(); - return Object.assign(screen, { [Symbol.asyncDispose]: () => environment.disposeAsync() }); -} - -async function flushFocus() { - await act(() => vi.advanceTimersByTimeAsync(0)); - await act(() => vi.advanceTimersToNextFrame()); -} - -async function dismissPalette() { - await act(() => - document.activeElement?.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), - ), - ); - await flushFocus(); - expect(document.querySelector("[data-command-palette]")).toBeNull(); -} - -function Composer() { - const handle = useComposerHandleContext(); - const input = useRef(null); - useImperativeHandle( - handle, - () => - ({ - focusAtEnd: () => { - input.current?.focus(); - input.current?.setSelectionRange(input.current.value.length, input.current.value.length); - }, - }) as NonNullable["current"]>, - ); - return