diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..32d5f9b --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "packageRules": [ + { + "matchPackageNames": [ + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui", + "typebox" + ], + "groupName": "Pi compatibility dependencies", + "rangeStrategy": "pin" + } + ] +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a5d5bf..86f9759 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,7 @@ on: pull_request: branches: [main] paths-ignore: ['*.md', '**/docs/**'] + workflow_dispatch: jobs: # ── Cross-platform test matrix ────────────────────────────────────── @@ -71,22 +72,44 @@ jobs: - name: E2E tests run: npm run test:e2e - # Compatibility contracts run from a foreign cwd on the floor and Windows lanes. - - name: Exact-floor compatibility contract - if: (matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest' - working-directory: ${{ runner.temp }} - run: node "${{ github.workspace }}/scripts/test-compat-floor.mjs" - - - name: Packed Pi 0.82.0 host contract + # The packed extension is exercised from a foreign cwd with the latest host. + - name: Packed latest Pi host contract if: (matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest' working-directory: ${{ runner.temp }} + env: + COMPAT_ARTIFACT_DIR: ${{ runner.temp }}/packed-host-diagnostics-${{ matrix.os }}-node-${{ matrix.node-version }} run: node "${{ github.workspace }}/scripts/test-package-host.mjs" + - name: Upload packed host diagnostics + if: always() && ((matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest') + uses: actions/upload-artifact@v4 + with: + name: packed-host-diagnostics-${{ matrix.os }}-node-${{ matrix.node-version }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/packed-host-diagnostics-${{ matrix.os }}-node-${{ matrix.node-version }} + if-no-files-found: warn + overwrite: true + retention-days: 30 + + # Runs even when the packed-host contract step fails on Windows, so compat + # diagnostics are captured for both the packed and current-Pi paths. !cancelled() + # (not always()) avoids running on explicit workflow cancellation. - name: Synchronized current Pi compatibility on Windows - if: matrix.os == 'windows-latest' + if: matrix.os == 'windows-latest' && !cancelled() working-directory: ${{ runner.temp }} + env: + COMPAT_ARTIFACT_DIR: ${{ runner.temp }}/current-pi-diagnostics-windows-node-24 run: node "${{ github.workspace }}/scripts/test-compat-current.mjs" + - name: Upload current Pi diagnostics on Windows + if: always() && matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: current-pi-diagnostics-windows-node-24-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/current-pi-diagnostics-windows-node-24 + if-no-files-found: warn + overwrite: true + retention-days: 30 + # Upload test results for debugging — artifacts available for 30 days. - name: Upload test results if: always() @@ -108,4 +131,15 @@ jobs: - run: npm ci - name: Synchronized current Pi compatibility working-directory: ${{ runner.temp }} + env: + COMPAT_ARTIFACT_DIR: ${{ runner.temp }}/current-pi-diagnostics-ubuntu-node-24 run: node "${{ github.workspace }}/scripts/test-compat-current.mjs" + - name: Upload current Pi diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: current-pi-diagnostics-ubuntu-node-24-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/current-pi-diagnostics-ubuntu-node-24 + if-no-files-found: warn + overwrite: true + retention-days: 30 diff --git a/audit-ci.jsonc b/audit-ci.jsonc index 2e93da3..861713f 100644 --- a/audit-ci.jsonc +++ b/audit-ci.jsonc @@ -1,21 +1,5 @@ { "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json", "moderate": true, - "allowlist": [ - // brace-expansion 5.0.7 is pinned by @earendil-works/pi-coding-agent's - // published npm-shrinkwrap.json (the host under test); registry fix 5.0.9 - // exists but cannot be installed without deviating from the host tree — - // remove when a pi-coding-agent release ships brace-expansion ≥5.0.9. - { "GHSA-mh99-v99m-4gvg": { "active": true, "expiry": "2026-10-01", "notes": "brace-expansion <5.0.8 DoS via unbounded expansion; 5.0.7 pinned by @earendil-works/pi-coding-agent's npm-shrinkwrap (host under test) — remove when pi-coding-agent ships brace-expansion ≥5.0.8" } }, - { "GHSA-rgw5-rvv9-x895": { "active": true, "expiry": "2026-10-01", "notes": "brace-expansion <5.0.9 DoS bypassing the CVE-2026-14257 mitigation; 5.0.7 pinned by @earendil-works/pi-coding-agent's npm-shrinkwrap — remove when pi-coding-agent ships brace-expansion ≥5.0.9" } }, - // undici 8.5.0 is pinned exactly by @earendil-works/pi-coding-agent (the host - // under test); no released pi-coding-agent ships undici ≥8.9.0 (upstream - // issue #7049, PR #7225 closed unmerged) — remove when a pi-coding-agent - // release ships undici ≥8.9.0. - { "GHSA-4cwx-7wf7-3272|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0 high: cross-user cache disclosure / parse-time crash; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 (host under test); no upstream fix released — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-8xcm-r25x-g524|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: retry-interceptor response desynchronization; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-jr45-8vmc-qm54|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: Cache-Control whitespace bypass; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-m8rv-5g2x-5cg5|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: CRLF injection via blob-like body type; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-v3r7-h72x-cjcm|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: cookie attribute injection; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } } - ] + "allowlist": [] } diff --git a/package-lock.json b/package-lock.json index 818a355..13ffdd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "0.4.0", "license": "MIT", "devDependencies": { - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", + "@earendil-works/pi-ai": "0.84.1", + "@earendil-works/pi-coding-agent": "0.84.1", + "@earendil-works/pi-tui": "0.84.1", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", "husky": "^9.1.7", - "typebox": "1.1.38", + "typebox": "1.3.7", "typescript": "^6.0.3" }, "engines": { @@ -538,14 +538,15 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.0.tgz", - "integrity": "sha512-8MvW9+zno13sXDuT2kFMnWeTNUufUhPeZDRVO+igGoBRCDWgn7Xh2FkRQI1mRuet6QhF4ENQuLYdIAOyG6BhNw==", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", + "integrity": "sha512-wMsAdJMxuNri08vLqTyYVI201DQQezGhPSTkzYsHdw5dYX3rCNwEmSvpaAwhi7ELKI/2tE/CEgSWg/6iRxSgdQ==", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -554,7 +555,7 @@ "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", - "typebox": "1.1.38" + "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" @@ -564,21 +565,24 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.0.tgz", - "integrity": "sha512-Qnqgn9zhJFQ2HZ8R4iNuGhyCk93XX6+eUw9i+TjTuo47amzCy93ft3bB6yaUCleCrNO58dJDHYSGNHv/GAPWKg==", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz", + "integrity": "sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.82.0", - "@earendil-works/pi-ai": "^0.82.0", - "@earendil-works/pi-tui": "^0.82.0", + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-client": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1", + "@earendil-works/pi-tui": "^0.84.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", + "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", @@ -586,8 +590,8 @@ "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", - "typebox": "1.1.38", - "undici": "8.5.0", + "typebox": "1.3.7", + "undici": "8.9.0", "yaml": "2.9.0" }, "bin": { @@ -1063,15 +1067,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.82.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.1", "diff": "8.0.4", "ignore": "7.0.5", - "typebox": "1.1.38", + "typebox": "1.3.7", "yaml": "2.9.0" }, "engines": { @@ -1079,13 +1084,14 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -1094,7 +1100,7 @@ "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", - "typebox": "1.1.38" + "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" @@ -1103,9 +1109,42 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-protocol": "^0.84.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1656,16 +1695,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { @@ -1930,6 +1969,16 @@ "dev": true, "license": "ISC" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -2399,16 +2448,16 @@ "license": "0BSD" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", "dev": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "dev": true, "license": "MIT", "engines": { @@ -2522,10 +2571,20 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", + "integrity": "sha512-180/xGJtsq7IoR3p9EKWjRd0e9M4DkxInhlo9xyD7prDC7Qrhqq+nhvwrW0lFjPfXcEI2FSHmGCSyvSJE9GsaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-tui": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.0.tgz", - "integrity": "sha512-9IDjQOXne7t9l2s2YcjnIBxsVNVPE7qScVSB3YmFlXsBW4pfo2gOElTxggV84KrRiGqABnlFPBWbf0k54hszHQ==", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", + "integrity": "sha512-udeXFbgEhJ6JiB0uguwNVNkDy2FENfmtQwPcY+/iJ8GWeq18wkal1tKqa5YyeH0IqtX1vG0cGh8zfSYzyzVuLA==", "dev": true, "license": "MIT", "dependencies": { @@ -3822,9 +3881,9 @@ "license": "0BSD" }, "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 0f3ccfc..fad0ec9 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "node": ">=22.19.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-ai": ">=0.84.1", + "@earendil-works/pi-coding-agent": ">=0.84.1", + "@earendil-works/pi-tui": ">=0.84.1", + "typebox": ">=1.3.7" }, "scripts": { "prepare": "husky", @@ -27,20 +27,19 @@ "test:all": "npm run test && npm run test:e2e", "test:snapshots:check": "node ./scripts/run-node-test.mjs tests/unit/render-snapshots.test.ts", "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts", - "test:compat:floor": "node ./scripts/test-compat-floor.mjs", "test:compat:current": "node ./scripts/test-compat-current.mjs", "test:package-host": "node ./scripts/test-package-host.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", + "@earendil-works/pi-ai": "0.84.1", + "@earendil-works/pi-coding-agent": "0.84.1", + "@earendil-works/pi-tui": "0.84.1", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", "husky": "^9.1.7", - "typebox": "1.1.38", + "typebox": "1.3.7", "typescript": "^6.0.3" }, "pi": { diff --git a/scripts/compat-fixture.mjs b/scripts/compat-fixture.mjs new file mode 100644 index 0000000..43e62ec --- /dev/null +++ b/scripts/compat-fixture.mjs @@ -0,0 +1,76 @@ +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { runNpmWithRetry } from "./compat-process.mjs"; + +// 3 direct Pi peers for host lane; current lane sync-asserts 4 (PI_SYNC_PACKAGES adds pi-agent-core transitively). +// Keep distinct — don't unify. +export const PI_PACKAGES = [ + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui", +]; + +export function createCompatCopy(root, prefix) { + const temp = mkdtempSync(join(tmpdir(), prefix)); + const copy = join(temp, "source"); + cpSync(root, copy, { + recursive: true, + filter: (source) => ![".git", "node_modules", "openspec"].includes(basename(source)), + }); + return { temp, copy }; +} + +export function prepareCompatCopy(copy) { + rmSync(join(copy, "package-lock.json"), { force: true }); + const packagePath = join(copy, "package.json"); + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); + if (packageJson.scripts) delete packageJson.scripts.prepare; + writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); +} + +/** Resolve one current Pi release and the exact TypeBox version it requires. */ +export async function resolveLatestPi(copy) { + const piVersion = JSON.parse((await runNpmWithRetry(copy, [ + "view", "@earendil-works/pi-coding-agent@latest", "version", "--json", + ], { capture: true })).stdout); + const typeboxVersion = JSON.parse((await runNpmWithRetry(copy, [ + "view", `@earendil-works/pi-coding-agent@${piVersion}`, "dependencies.typebox", "--json", + ], { capture: true })).stdout); + if (typeof piVersion !== "string" || !piVersion || typeof typeboxVersion !== "string" || !typeboxVersion) { + throw new Error("Latest Pi coding-agent did not declare usable Pi and TypeBox versions"); + } + return { piVersion, typeboxVersion }; +} + +export function latestPiDependencies({ piVersion, typeboxVersion }) { + return Object.fromEntries([ + ...PI_PACKAGES.map((name) => [name, piVersion]), + ["typebox", typeboxVersion], + ]); +} + +function copyCompatPackageLock(artifactDir, sourceDir, required = false) { + const packageLock = join(sourceDir, "package-lock.json"); + if (!existsSync(packageLock)) { + if (required) throw new Error(`Expected compatibility package lock at ${packageLock}`); + return; + } + copyFileSync(packageLock, join(artifactDir, "package-lock.json")); +} + +/** Write successful compatibility artifacts if configured. */ +export function writeCompatArtifacts(artifactDir, sourceDir, { piVersion, typeboxVersion }) { + if (!artifactDir) return; + mkdirSync(artifactDir, { recursive: true }); + copyCompatPackageLock(artifactDir, sourceDir, true); + writeFileSync(join(artifactDir, "versions.txt"), `pi=${piVersion}\ntypebox=${typeboxVersion}\nnode=${process.version}\n`); +} + +/** Write failure diagnostics to artifact directory if configured. */ +export function writeCompatDiagnostics(artifactDir, sourceDir, error) { + if (!artifactDir) return; + mkdirSync(artifactDir, { recursive: true }); + copyCompatPackageLock(artifactDir, sourceDir); + writeFileSync(join(artifactDir, "failure.txt"), String(error)); +} diff --git a/scripts/compat-process.mjs b/scripts/compat-process.mjs index e564ebc..a8f8ae8 100644 --- a/scripts/compat-process.mjs +++ b/scripts/compat-process.mjs @@ -12,6 +12,12 @@ function formatInvocation(command, args) { return [command, ...args].map((value) => JSON.stringify(value)).join(" "); } +// Keep CI logs bounded; failure.txt receives the same truncated diagnostics. +function truncateOutput(value, limit = 8192) { + if (!value) return ""; + return value.length > limit ? `${value.slice(0, limit)}\n… [truncated ${value.length - limit} chars]` : value; +} + /** Run a subprocess and fail with launch/status/signal and captured-output context. */ export function runChecked(command, args, options = {}) { const { cwd, capture = false, env = process.env } = options; @@ -28,8 +34,8 @@ export function runChecked(command, args, options = {}) { `error.stack: ${result.error?.stack ?? "none"}`, `status: ${String(result.status)}`, `signal: ${String(result.signal)}`, - `stdout:\n${result.stdout ?? ""}`, - `stderr:\n${result.stderr ?? ""}`, + `stdout:\n${truncateOutput(result.stdout ?? "")}`, + `stderr:\n${truncateOutput(result.stderr ?? "")}`, ].join("\n"); throw new Error(diagnostics); } @@ -84,3 +90,29 @@ export function runNpm(cwd, args, options = {}) { const invocation = npmInvocation(args, options); return runChecked(invocation.command, invocation.args, { cwd, ...options }); } + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retry runNpm with exponential backoff for transient registry failures. + * Retries on any non-zero status/signal/error (including permanent 404s) — + * worst case ~5.6s backoff per call (800+1600+3200), ~22s for 4 calls in + * the current compat lane; failure artifacts retain bounded output, so rerun + * locally when the truncated subprocess tail is needed. + */ +export async function runNpmWithRetry(cwd, args, options = {}, retryOptions = {}) { + const { retries = 3, baseMs = 800 } = retryOptions; + let lastError; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return runNpm(cwd, args, options); + } catch (error) { + lastError = error; + if (attempt === retries) break; + await sleep(baseMs * 2 ** attempt); + } + } + throw lastError; +} diff --git a/scripts/dependency-graph-assertions.mjs b/scripts/dependency-graph-assertions.mjs index 334aa7a..e2b576c 100644 --- a/scripts/dependency-graph-assertions.mjs +++ b/scripts/dependency-graph-assertions.mjs @@ -24,21 +24,6 @@ function formatVersion(version) { return version ?? "missing version"; } -export function assertExactPackageVersions(graph, expectedVersions) { - const entries = Object.entries(expectedVersions); - const occurrences = collectPackageOccurrences(graph, entries.map(([name]) => name)); - - for (const [name, expectedVersion] of entries) { - const packages = occurrences.get(name); - if (packages.length === 0) throw new Error(`Expected ${name}@${expectedVersion}, found no occurrences`); - const mismatches = packages.filter(({ version }) => version !== expectedVersion); - if (mismatches.length > 0) { - const found = mismatches.map(({ path, version }) => `${formatVersion(version)} at ${path}`).join(", "); - throw new Error(`Expected every ${name}@${expectedVersion} occurrence, found ${found}`); - } - } -} - export function assertSynchronizedPackageVersions(graph, packageNames) { if (packageNames.length === 0) throw new Error("Pass at least one package name to synchronize"); const occurrences = collectPackageOccurrences(graph, packageNames); diff --git a/scripts/test-compat-current.mjs b/scripts/test-compat-current.mjs index ec49244..ca63cc4 100644 --- a/scripts/test-compat-current.mjs +++ b/scripts/test-compat-current.mjs @@ -1,62 +1,56 @@ -import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { repoRootFromScript, runChecked, runNpm } from "./compat-process.mjs"; +import { pathToFileURL } from "node:url"; +import { createCompatCopy, latestPiDependencies, prepareCompatCopy, resolveLatestPi, writeCompatArtifacts, writeCompatDiagnostics } from "./compat-fixture.mjs"; +import { repoRootFromScript, runChecked, runNpmWithRetry } from "./compat-process.mjs"; import { assertSynchronizedPackageVersions } from "./dependency-graph-assertions.mjs"; -const PI_PACKAGES = [ - "@earendil-works/pi-agent-core", - "@earendil-works/pi-ai", - "@earendil-works/pi-coding-agent", - "@earendil-works/pi-tui", -]; +const PI_SYNC_PACKAGES = ["@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"]; -const root = repoRootFromScript(import.meta.url); -const temp = mkdtempSync(join(tmpdir(), "pi-agenticoding-current-")); -const copy = join(temp, "source"); -try { - cpSync(root, copy, { - recursive: true, - filter: (source) => ![".git", "node_modules", "openspec"].includes(source.split(/[\\/]/).at(-1)), - }); - rmSync(join(copy, "package-lock.json"), { force: true }); - const packagePath = join(copy, "package.json"); - const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); - packageJson.scripts.prepare = ""; - writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); +const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; - const typeboxResult = runNpm(copy, [ - "view", "@earendil-works/pi-coding-agent@latest", "dependencies.typebox", "--json", - ], { capture: true }); - const currentPiTypebox = JSON.parse(typeboxResult.stdout); - if (typeof currentPiTypebox !== "string" || currentPiTypebox.length === 0) { - throw new Error("Current Pi coding-agent did not declare a TypeBox dependency"); - } +async function main() { + const root = repoRootFromScript(import.meta.url); + const artifactDir = process.env.COMPAT_ARTIFACT_DIR; + const { temp, copy } = createCompatCopy(root, "pi-agenticoding-current-"); + + try { + prepareCompatCopy(copy); - runNpm(copy, ["install", "--ignore-scripts", "--save-dev", "--save-exact", - "@earendil-works/pi-ai@latest", - "@earendil-works/pi-coding-agent@latest", - "@earendil-works/pi-tui@latest", - `typebox@${currentPiTypebox}`, - ]); - const graphResult = runNpm(copy, ["ls", "--all", "--json"], { capture: true }); - const graph = JSON.parse(graphResult.stdout); - const piVersion = assertSynchronizedPackageVersions(graph, PI_PACKAGES); - assertSynchronizedPackageVersions(graph, ["typebox"]); + const latestPi = await resolveLatestPi(copy); + await runNpmWithRetry(copy, ["install", "--ignore-scripts", "--save-dev", "--save-exact", + ...Object.entries(latestPiDependencies(latestPi)).map(([name, version]) => `${name}@${version}`), + ]); + const graphResult = await runNpmWithRetry(copy, ["ls", "--all", "--json"], { capture: true }); + const graph = JSON.parse(graphResult.stdout); + if (artifactDir) { + mkdirSync(artifactDir, { recursive: true }); + writeFileSync(join(artifactDir, "dependency-graph.json"), JSON.stringify(graph, null, 2)); + } + const piVersion = assertSynchronizedPackageVersions(graph, PI_SYNC_PACKAGES); + assertSynchronizedPackageVersions(graph, ["typebox"]); - runNpm(copy, ["run", "typecheck"]); - runChecked(process.execPath, ["./scripts/run-node-test.mjs", - "tests/unit/spawn-runtime-compatibility.test.ts", - "tests/unit/spawn-lifecycle.test.ts", - "tests/unit/spawn-event.test.ts", - "tests/unit/spawn-render.test.ts", - "tests/unit/dependency-graph-assertions.test.ts", - "tests/unit/spawn.test.ts", - "tests/unit/readonly-spawn.test.ts", - "tests/unit/compat-process.test.ts", - ], { cwd: copy }); - runNpm(copy, ["run", "test:e2e"]); - process.stdout.write(`Current synchronized Pi compatibility passed at ${piVersion}.\n`); -} finally { - rmSync(temp, { recursive: true, force: true }); + await runNpmWithRetry(copy, ["run", "typecheck"]); + await runNpmWithRetry(copy, ["exec", "audit-ci", "--", "--config", "audit-ci.jsonc"]); + runChecked(process.execPath, ["./scripts/run-node-test.mjs", + "tests/unit/spawn-runtime-compatibility.test.ts", + "tests/unit/spawn-lifecycle.test.ts", + "tests/unit/spawn-event.test.ts", + "tests/unit/spawn-render.test.ts", + "tests/unit/dependency-graph-assertions.test.ts", + "tests/unit/spawn.test.ts", + "tests/unit/readonly-spawn.test.ts", + "tests/unit/compat-process.test.ts", + ], { cwd: copy }); + runChecked(process.execPath, ["./scripts/run-node-test.mjs", "tests/e2e/basic.test.ts"], { cwd: copy }); + writeCompatArtifacts(artifactDir, copy, { piVersion, typeboxVersion: latestPi.typeboxVersion }); + process.stdout.write(`Current synchronized Pi compatibility passed at ${piVersion}.\n`); + } catch (error) { + writeCompatDiagnostics(artifactDir, copy, error); + throw error; + } finally { + rmSync(temp, { recursive: true, force: true }); + } } + +if (isMain) await main(); diff --git a/scripts/test-compat-floor.mjs b/scripts/test-compat-floor.mjs deleted file mode 100644 index 6f351fb..0000000 --- a/scripts/test-compat-floor.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { repoRootFromScript, runChecked, runNpm } from "./compat-process.mjs"; -import { assertExactPackageVersions } from "./dependency-graph-assertions.mjs"; - -const expected = { - "@earendil-works/pi-agent-core": "0.82.0", - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", - typebox: "1.1.38", -}; - -const root = repoRootFromScript(import.meta.url); -const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); -if (packageJson.engines?.node !== ">=22.19.0") throw new Error("Node floor must be >=22.19.0"); -const graphResult = runNpm(root, ["ls", "--all", "--json"], { capture: true }); -assertExactPackageVersions(JSON.parse(graphResult.stdout), expected); - -runNpm(root, ["run", "typecheck"]); -runChecked(process.execPath, [join(root, "scripts", "run-node-test.mjs"), - "tests/unit/spawn-runtime-compatibility.test.ts", - "tests/unit/spawn-lifecycle.test.ts", - "tests/unit/spawn-event.test.ts", - "tests/unit/dependency-graph-assertions.test.ts", - "tests/unit/spawn-render.test.ts", - "tests/unit/spawn.test.ts", - "tests/unit/readonly-spawn.test.ts", - "tests/unit/config-invariants.test.ts", - "tests/unit/compat-process.test.ts", -], { cwd: root }); -runNpm(root, ["run", "test:e2e"]); -process.stdout.write("Exact Pi 0.82.0 compatibility floor passed.\n"); diff --git a/scripts/test-package-host.mjs b/scripts/test-package-host.mjs index 5e04af5..146ea92 100644 --- a/scripts/test-package-host.mjs +++ b/scripts/test-package-host.mjs @@ -1,39 +1,45 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { repoRootFromScript, runChecked, runNpm } from "./compat-process.mjs"; +import { pathToFileURL } from "node:url"; +import { createCompatCopy, latestPiDependencies, prepareCompatCopy, resolveLatestPi, writeCompatArtifacts, writeCompatDiagnostics } from "./compat-fixture.mjs"; +import { repoRootFromScript, runChecked, runNpmWithRetry } from "./compat-process.mjs"; -const root = repoRootFromScript(import.meta.url); -const temp = mkdtempSync(join(tmpdir(), "pi-agenticoding-host-")); -let tarball; -try { - const packJson = JSON.parse(runNpm(root, ["pack", "--json", "--ignore-scripts"], { capture: true }).stdout); - tarball = join(root, packJson[0].filename); +const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; + +async function main() { + const root = repoRootFromScript(import.meta.url); + const artifactDir = process.env.COMPAT_ARTIFACT_DIR; + const { temp, copy } = createCompatCopy(root, "pi-agenticoding-host-"); const host = join(temp, "host"); - mkdirSync(host, { recursive: true }); - writeFileSync(join(host, "package.json"), `${JSON.stringify({ - name: "pi-agenticoding-package-host", - private: true, - type: "module", - dependencies: { - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", - typebox: "1.1.38", - "pi-agenticoding": `file:${tarball}`, - }, - }, null, 2)}\n`); - runNpm(host, ["install", "--ignore-scripts"]); - const graph = JSON.parse(runNpm(host, ["ls", "--json", "pi-agenticoding", - "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"], { capture: true }).stdout); - const extension = graph.dependencies?.["pi-agenticoding"]; - if (!extension) throw new Error("Packed extension is missing from host graph"); - for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"]) { - const nested = join(host, "node_modules", "pi-agenticoding", "node_modules", ...name.split("/"), "package.json"); - if (existsSync(nested)) throw new Error(`Packed extension owns nested peer ${name}`); - } + let tarball; + + try { + prepareCompatCopy(copy); - writeFileSync(join(host, "smoke.mjs"), ` + const packJson = JSON.parse((await runNpmWithRetry(copy, ["pack", "--json", "--ignore-scripts"], { capture: true })).stdout); + tarball = join(copy, packJson[0].filename); + const latestPi = await resolveLatestPi(copy); + mkdirSync(host, { recursive: true }); + writeFileSync(join(host, "package.json"), `${JSON.stringify({ + name: "pi-agenticoding-package-host", + private: true, + type: "module", + dependencies: { + ...latestPiDependencies(latestPi), + "pi-agenticoding": `file:${tarball}`, + }, + }, null, 2)}\n`); + await runNpmWithRetry(host, ["install", "--ignore-scripts"]); + const graph = JSON.parse((await runNpmWithRetry(host, ["ls", "--json", "pi-agenticoding", + "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"], { capture: true })).stdout); + const extension = graph.dependencies?.["pi-agenticoding"]; + if (!extension) throw new Error("Packed extension is missing from host graph"); + for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"]) { + const nested = join(host, "node_modules", "pi-agenticoding", "node_modules", ...name.split("/"), "package.json"); + if (existsSync(nested)) throw new Error(`Packed extension owns nested peer ${name}`); + } + + writeFileSync(join(host, "smoke.mjs"), ` import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; const extensionPath = join(process.cwd(), "node_modules", "pi-agenticoding", "index.ts"); @@ -47,9 +53,19 @@ const loaded = loader.getExtensions(); if (loaded.errors.length > 0) throw new Error(JSON.stringify(loaded.errors)); if (loaded.extensions.length !== 1) throw new Error("packed extension did not load"); `); - runChecked(process.execPath, ["smoke.mjs"], { cwd: host }); - process.stdout.write("Packed Pi 0.82.0 host smoke passed with host-provided peers.\n"); -} finally { - if (tarball) rmSync(tarball, { force: true }); - rmSync(temp, { recursive: true, force: true }); + runChecked(process.execPath, ["smoke.mjs"], { cwd: host }); + writeCompatArtifacts(artifactDir, host, { + piVersion: latestPi.piVersion, + typeboxVersion: latestPi.typeboxVersion, + }); + process.stdout.write(`Packed latest Pi ${latestPi.piVersion} host smoke passed with host-provided peers.\n`); + } catch (error) { + writeCompatDiagnostics(artifactDir, host, error); + throw error; + } finally { + if (tarball) rmSync(tarball, { force: true }); + rmSync(temp, { recursive: true, force: true }); + } } + +if (isMain) await main(); diff --git a/spawn/index.ts b/spawn/index.ts index 4dc48f3..053c848 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -11,6 +11,7 @@ */ import type { + AgentSession, ExtensionAPI, ExtensionContext, ToolDefinition, @@ -28,7 +29,7 @@ import { SessionManager, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -import type { AgenticodingState } from "../state.js"; +import { abortChildSession, type AgenticodingState } from "../state.js"; import { formatPageList } from "../notebook/store.js"; import { createNotebookToolDefinitions } from "../notebook/tools.js"; import { resolveSpawnModelRoute } from "../model-groups/router.js"; @@ -141,6 +142,83 @@ function truncateResult(text: string): { text: string; truncated: boolean } { * * handoff and spawn never carry into children. */ +function shouldSwallowPromptError(error: unknown, isStale: () => boolean, wasAborted: boolean): boolean { + if (isStale()) return false; + if (!wasAborted) return false; + // Pi contracts AbortError via AbortSignal standard + return (error as Error)?.name === "AbortError"; +} + +function toSpawnStats(sessionStats: any): Record { + return { + inputTokens: sessionStats.tokens?.input ?? 0, + outputTokens: sessionStats.tokens?.output ?? 0, + cacheReadTokens: sessionStats.tokens?.cacheRead ?? 0, + cacheWriteTokens: sessionStats.tokens?.cacheWrite ?? 0, + totalTokens: sessionStats.tokens?.total ?? 0, + cost: sessionStats.cost ?? 0, + turns: sessionStats.assistantMessages ?? 0, + }; +} + +function collectSpawnStats(session: { getSessionStats?: () => unknown }): { stats?: Record; statsUnavailable: boolean } { + try { + const sessionStats = (session.getSessionStats as (() => any) | undefined)?.(); + return sessionStats + ? { stats: toSpawnStats(sessionStats), statsUnavailable: false } + : { statsUnavailable: false }; + } catch { + return { statsUnavailable: true }; + } +} + +function clearSpawnSession(state: AgenticodingState, toolCallId: string, session: AgentSession): void { + if (state.childSessions.get(toolCallId) === session) state.childSessions.delete(toolCallId); + if (state.liveChildSessions.get(toolCallId) === session) state.liveChildSessions.delete(toolCallId); +} + +function createSpawnAbortContext(state: AgenticodingState, session: AgentSession, ctx: ExtensionContext, toolCallId: string) { + const invalidatedError = new Error("Spawn invalidated by reset."); + let wasAborted = false; + let reported: Promise | undefined; + const clearChildSession = () => clearSpawnSession(state, toolCallId, session); + const abortChild = () => { + wasAborted = true; + const p = abortChildSession(state, session); + if (p !== reported) { + reported = p; + // Report abort failures via UI. Guard against notify itself throwing + // so the detached promise never emits unhandledRejection. + // Detached path — the finally(dispose) branch aggregates notify failures + // differently; here we intentionally swallow to avoid unhandledRejection. + void p.catch((e) => { + try { notifyCleanupFailure(ctx, e); } catch { /* reporting must never reject */ } + }); + } + return p; + }; + const abortAndInvalidate = async (cause?: unknown): Promise => { + clearChildSession(); + let abortFailure: unknown; + try { + await abortChild(); + } catch (e) { + abortFailure = e; + } + if (abortFailure !== undefined) { + const aggregate = new AggregateError( + [invalidatedError, abortFailure], + "Spawn invalidated by reset; child abort failed.", + ); + if (cause !== undefined) (aggregate as unknown as { cause: unknown }).cause = cause; + throw aggregate; + } + if (cause !== undefined) (invalidatedError as unknown as { cause: unknown }).cause = cause; + throw invalidatedError; + }; + return { get wasAborted() { return wasAborted; }, invalidatedError, abortChild, clearChildSession, abortAndInvalidate }; +} + function getInheritableParentToolNames(parentToolNames: string[], availableTools: Pick[]): string[] { const activeToolNames = new Set(parentToolNames); return availableTools @@ -371,29 +449,8 @@ export function executeSpawn( // fallback keeps intentionally partial session test doubles compatible. const effectiveChildThinking = () => session.thinkingLevel ?? requestedChildThinking; - const invalidatedError = new Error("Spawn invalidated by reset."); - let wasAborted = false; - let abortPromise: Promise | undefined; - const abortChild = () => { - wasAborted = true; - if (!abortPromise) { - abortPromise = session.abort(); - abortPromise.catch(() => {}); - } - }; - const clearChildSession = () => { - if (state.childSessions.get(toolCallId) === session) { - state.childSessions.delete(toolCallId); - } - if (state.liveChildSessions.get(toolCallId) === session) { - state.liveChildSessions.delete(toolCallId); - } - }; - const abortAndInvalidate = async () => { - clearChildSession(); - await session.abort().catch(() => {}); - throw invalidatedError; - }; + const abortContext = createSpawnAbortContext(state, session, ctx, toolCallId); + const { invalidatedError, abortChild, clearChildSession, abortAndInvalidate } = abortContext; let hasPrimaryError = false; let primaryError: unknown; @@ -409,8 +466,7 @@ export function executeSpawn( try { if (signal?.aborted) { - abortChild(); - await abortPromise; + await abortChild(); throw signal.reason instanceof Error ? signal.reason : new Error("Spawn aborted before child session started."); @@ -435,8 +491,7 @@ export function executeSpawn( }); if (signal?.aborted) { - abortChild(); - await abortPromise; + await abortChild(); throw signal.reason instanceof Error ? signal.reason : new Error("Spawn aborted before child session started."); @@ -445,11 +500,15 @@ export function executeSpawn( await abortAndInvalidate(); } - await session.prompt(fullPrompt); + try { + await session.prompt(fullPrompt); + } catch (error) { + if (!shouldSwallowPromptError(error, isStale, abortContext.wasAborted)) throw error; + } } catch (error) { clearChildSession(); if (isStale()) { - throw invalidatedError; + await abortAndInvalidate(error); } throw error; } finally { @@ -457,17 +516,17 @@ export function executeSpawn( } if (isStale()) { - clearChildSession(); - throw invalidatedError; + await abortAndInvalidate(); } const resultText = getLastAssistantText(session.messages as AssistantMessageLike[]); - if (!resultText) { + // Aborted children legitimately have no text — empty result is allowed (outcome "aborted"), not "no output" error. + if (!resultText && !abortContext.wasAborted) { clearChildSession(); throw new Error("Child agent produced no output."); } - const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages as AssistantMessageLike[]); - const { text: finalText, truncated } = truncateResult(resultText); + const outcome = abortContext.wasAborted ? "aborted" : getLastAssistantOutcome(session.messages as AssistantMessageLike[]); + const { text: finalText, truncated } = truncateResult(resultText ?? ""); // Execution should not retain live children after completion. If the TUI // already rendered the child, it still owns the session object itself. @@ -475,26 +534,12 @@ export function executeSpawn( // liveChildSessions — the child already completed so there's nothing to abort. clearChildSession(); - let stats: Record | undefined; - let statsUnavailable = false; - try { - const sessionStats = session.getSessionStats(); - if (sessionStats) { - stats = { - inputTokens: sessionStats.tokens?.input ?? 0, - outputTokens: sessionStats.tokens?.output ?? 0, - cacheReadTokens: sessionStats.tokens?.cacheRead ?? 0, - cacheWriteTokens: sessionStats.tokens?.cacheWrite ?? 0, - totalTokens: sessionStats.tokens?.total ?? 0, - cost: sessionStats.cost ?? 0, - turns: sessionStats.assistantMessages ?? 0, - }; - } - } catch (error: unknown) { - statsUnavailable = true; - } + const { stats, statsUnavailable } = collectSpawnStats(session as { getSessionStats?: () => unknown }); if (isStale()) { + // INVARIANT: live ownership was synchronously cleared before stats collection, + // so this reset could not have initiated a child abort to aggregate — plain + // invalidatedError is correct. Early stale paths above aggregate via abortAndInvalidate(cause). throw invalidatedError; } diff --git a/spawn/renderer.ts b/spawn/renderer.ts index a183b73..8999250 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -33,7 +33,7 @@ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent"; import { Container, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { TUI } from "@earendil-works/pi-tui"; import { escapeDisplayLabel } from "../model-groups/display.js"; -import type { AgenticodingState } from "../state.js"; +import { abortChildSession, type AgenticodingState } from "../state.js"; import { __setSingletons, getSingletons, @@ -717,11 +717,11 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.unsubscribe = undefined; getSingletons().frameScheduler.cancelDirty(this); this.clearPendingState(); - // Snapshot fields before clearing: if session.abort() triggers re-entrant - // dispose, the nulled-out fields prevent double-abort. + // Snapshot fields before clearing: if abortChildSession() triggers + // re-entrant dispose, the nulled-out fields prevent double-abort. + const state = this.state; const session = this.session; const ownedToolCallId = this.ownedToolCallId; - const liveChildSessions = this.state?.liveChildSessions; this.resetRenderBatching(); this.requestRender = () => {}; this.clearRenderCache(); @@ -734,9 +734,16 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.ownedToolCallId = undefined; this.state = undefined; this.attachedChildSessionEpoch = undefined; - if (session && ownedToolCallId && liveChildSessions?.get(ownedToolCallId) === session) { - session.abort().catch(() => {}); - liveChildSessions.delete(ownedToolCallId); + if (session && ownedToolCallId && state?.liveChildSessions.get(ownedToolCallId) === session) { + // Share the deduplicated abort promise with the other abort paths (spawn + // invalidation, /reset) so one session never receives a second abort. + // Renderer has no ExtensionContext, so abort failures are intentionally + // not notified here — spawn path notifies via ctx.ui.notify. Silent swallow is correct. + try { + void abortChildSession(state, session).catch(() => {}); + } finally { + state.liveChildSessions.delete(ownedToolCallId); + } } } diff --git a/state.ts b/state.ts index f0478b6..e797705 100644 --- a/state.ts +++ b/state.ts @@ -96,6 +96,9 @@ export interface AgenticodingState { */ childSessionEpoch: number; + /** One abort promise per child session, shared by signal and reset cleanup. WeakMap entries are GC'd with the session; no manual clear needed. */ + childAbortPromises: WeakMap>; + /** Whether readonly mode is active — write/edit blocked; handoff needs explicit /handoff or a human topic boundary; bash writes limited to temp. */ readonlyEnabled: boolean; @@ -160,6 +163,7 @@ export function createState(): AgenticodingState { childSessions, liveChildSessions, childSessionEpoch: 0, + childAbortPromises: new WeakMap(), readonlyEnabled: false, readonlyNudgePending: false, frontmatterSkillCache, @@ -236,15 +240,32 @@ export function invalidateHandoffState(state: AgenticodingState): void { // from work that no longer belongs to the active session tree. } +/** Return the session's single shared abort operation, starting it if necessary. */ +export function abortChildSession(state: AgenticodingState, session: AgentSession): Promise { + const existing = state.childAbortPromises.get(session); + if (existing) return existing; + + let resolve!: () => void; + let reject!: (error: unknown) => void; + const abortPromise = new Promise((ok, fail) => { + resolve = ok; + reject = fail; + }); + state.childAbortPromises.set(session, abortPromise); + try { + Promise.resolve(session.abort()).then(resolve, reject); + } catch (error) { + reject(error); + } + return abortPromise; +} + /** Abort all active child sessions and clear both registries. Called on /new (session reset). */ export function abortAndClearChildSessions(state: AgenticodingState): void { - const seen = new Map(); // session → first id (for logging) - for (const [id, session] of [...state.childSessions.entries(), ...state.liveChildSessions.entries()]) { - if (!seen.has(session)) seen.set(session, id); - } + const sessions = new Set([...state.childSessions.values(), ...state.liveChildSessions.values()]); state.childSessions.clear(); state.liveChildSessions.clear(); - for (const [session, id] of seen) { - session.abort().catch(() => {}); + for (const session of sessions) { + void abortChildSession(state, session).catch(() => {}); } } diff --git a/tests/unit/abort-child-session.test.ts b/tests/unit/abort-child-session.test.ts new file mode 100644 index 0000000..a396c30 --- /dev/null +++ b/tests/unit/abort-child-session.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createState, abortChildSession } from "../../state.js"; + +function createMockSession(abortImpl: () => Promise | void) { + return { abort: abortImpl } as any; +} + +test("abortChildSession returns the same promise for the same session", async () => { + const state = createState(); + const session = createMockSession(async () => {}); + + const promise1 = abortChildSession(state, session); + const promise2 = abortChildSession(state, session); + + assert.strictEqual(promise1, promise2, "same session returns same promise"); + + await promise1; + await promise2; +}); + +test("abortChildSession handles synchronous abort() throw", async () => { + const state = createState(); + const syncError = new Error("sync abort failure"); + const session = createMockSession(() => { throw syncError; }); + + const promise = abortChildSession(state, session); + + await assert.rejects(promise, (error: unknown) => { + assert.equal(error, syncError); + return true; + }); +}); + +test("abortChildSession returns different promises for different sessions", async () => { + const state = createState(); + const session1 = createMockSession(async () => {}); + const session2 = createMockSession(async () => {}); + + const promise1 = abortChildSession(state, session1); + const promise2 = abortChildSession(state, session2); + + assert.notStrictEqual(promise1, promise2, "different sessions return different promises"); + + await promise1; + await promise2; +}); + +test("abortChildSession promise resolves when abort completes", async () => { + const state = createState(); + let abortCalled = false; + const session = createMockSession(async () => { abortCalled = true; }); + + const promise = abortChildSession(state, session); + await promise; + + assert.equal(abortCalled, true, "abort was called"); +}); + +test("abortChildSession promise rejects when abort rejects", async () => { + const state = createState(); + const abortError = new Error("abort rejected"); + const session = createMockSession(async () => { throw abortError; }); + + const promise = abortChildSession(state, session); + + await assert.rejects(promise, (error: unknown) => { + assert.equal(error, abortError); + return true; + }); +}); diff --git a/tests/unit/compat-fixture.test.ts b/tests/unit/compat-fixture.test.ts new file mode 100644 index 0000000..f5318e3 --- /dev/null +++ b/tests/unit/compat-fixture.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const { writeCompatArtifacts } = await import(new URL("../../scripts/compat-fixture.mjs", import.meta.url).href); + +function withTempDir(prefix: string, run: (temp: string) => void): void { + const temp = mkdtempSync(join(tmpdir(), prefix)); + try { + run(temp); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +test("writeCompatArtifacts does nothing when artifacts are disabled", () => { + withTempDir("compat-artifact-disabled-", (temp) => { + const artifactDir = join(temp, "artifacts"); + writeCompatArtifacts(undefined, join(temp, "source"), { piVersion: "1.2.3", typeboxVersion: "4.5.6" }); + assert.equal(existsSync(artifactDir), false); + }); +}); + +test("writeCompatArtifacts writes reproducible successful compatibility artifacts", () => { + withTempDir("compat-artifact-success-", (temp) => { + const sourceDir = join(temp, "source"); + const artifactDir = join(temp, "artifacts"); + const packageLock = '{"lockfileVersion":3}\n'; + mkdirSync(sourceDir); + writeFileSync(join(sourceDir, "package-lock.json"), packageLock); + + writeCompatArtifacts(artifactDir, sourceDir, { piVersion: "1.2.3", typeboxVersion: "4.5.6" }); + + assert.equal(readFileSync(join(artifactDir, "package-lock.json"), "utf8"), packageLock); + assert.equal(readFileSync(join(artifactDir, "versions.txt"), "utf8"), `pi=1.2.3\ntypebox=4.5.6\nnode=${process.version}\n`); + }); +}); + +test("writeCompatArtifacts rejects a successful artifact without its package lock", () => { + withTempDir("compat-artifact-missing-lock-", (temp) => { + const sourceDir = join(temp, "source"); + mkdirSync(sourceDir); + assert.throws( + () => writeCompatArtifacts(join(temp, "artifacts"), sourceDir, { piVersion: "1.2.3", typeboxVersion: "4.5.6" }), + /Expected compatibility package lock/, + ); + }); +}); diff --git a/tests/unit/compat-process.test.ts b/tests/unit/compat-process.test.ts index 8c34459..328e394 100644 --- a/tests/unit/compat-process.test.ts +++ b/tests/unit/compat-process.test.ts @@ -10,6 +10,7 @@ const { repoRootFromScript, runChecked, runNpm, + runNpmWithRetry, isValidNpmExecpath, } = await import(new URL("../../scripts/compat-process.mjs", import.meta.url).href); @@ -19,6 +20,38 @@ function readScript(name: string): string { return readFileSync(join(REPO_ROOT, "scripts", name), "utf8"); } +function writeFlakyNpmCli(tmpDir: string, failures: number): { cliPath: string; attemptsPath: string } { + const attemptsPath = join(tmpDir, "attempts"); + const cliPath = join(tmpDir, "npm-cli.js"); + writeFileSync(cliPath, ` +const { existsSync, readFileSync, writeFileSync } = require("node:fs"); +const attemptsPath = ${JSON.stringify(attemptsPath)}; +const attempts = (existsSync(attemptsPath) ? Number(readFileSync(attemptsPath, "utf8")) : 0) + 1; +writeFileSync(attemptsPath, String(attempts)); +if (attempts <= ${failures}) { process.stderr.write("transient failure"); process.exit(17); } +process.stdout.write("fixture npm success"); +`); + return { cliPath, attemptsPath }; +} + +async function withFlakyNpm(failures: number, test: (tmpDir: string, cliPath: string, attemptsPath: string) => Promise): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), "npm-retry-test-")); + const { cliPath, attemptsPath } = writeFlakyNpmCli(tmpDir, failures); + try { + return await test(tmpDir, cliPath, attemptsPath); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function retryFixtureNpm(tmpDir: string, cliPath: string) { + return runNpmWithRetry(tmpDir, ["--version"], { + capture: true, + env: { npm_execpath: cliPath }, + execPath: process.execPath, + }, { retries: 2, baseMs: 0 }); +} + test("repoRootFromScript decodes native paths containing spaces", () => { const expectedRoot = resolve(tmpdir(), "compat repo with spaces"); const scriptUrl = pathToFileURL(join(expectedRoot, "scripts", "check.mjs")).href; @@ -86,6 +119,32 @@ test("runNpm launches npm portably", () => { assert.match(result.stdout, /^\d+\.\d+\.\d+/); }); +test("runNpmWithRetry returns on its first successful attempt", async () => { + await withFlakyNpm(0, async (tmpDir, cliPath, attemptsPath) => { + const result = await retryFixtureNpm(tmpDir, cliPath); + assert.equal(result.stdout, "fixture npm success"); + assert.equal(readFileSync(attemptsPath, "utf8"), "1"); + }); +}); + +test("runNpmWithRetry retries a transient npm failure", async () => { + await withFlakyNpm(1, async (tmpDir, cliPath, attemptsPath) => { + const result = await retryFixtureNpm(tmpDir, cliPath); + assert.equal(result.stdout, "fixture npm success"); + assert.equal(readFileSync(attemptsPath, "utf8"), "2"); + }); +}); + +test("runNpmWithRetry rethrows after exhausting its retry budget", async () => { + await withFlakyNpm(3, async (tmpDir, cliPath, attemptsPath) => { + await assert.rejects( + retryFixtureNpm(tmpDir, cliPath), + /status: 17/, + ); + assert.equal(readFileSync(attemptsPath, "utf8"), "3"); + }); +}); + test("runChecked reports process launch failures", () => { assert.throws( () => runChecked("pi-agenticoding-command-that-does-not-exist", [], { cwd: REPO_ROOT, capture: true }), @@ -112,12 +171,28 @@ test("runChecked reports nonzero status and captured output", () => { ); }); +test("runChecked truncates oversized diagnostic output", () => { + const payload = "x".repeat(9000); + assert.throws( + () => runChecked(process.execPath, ["-e", "process.stderr.write(process.env.PAYLOAD); process.exit(7)"], { + cwd: REPO_ROOT, + capture: true, + env: { ...process.env, PAYLOAD: payload }, + }), + (error: unknown) => { + assert.match(String(error), /\[truncated 808 chars\]/); + assert.equal(String(error).includes(payload), false); + return true; + }, + ); +}); + test("compatibility scripts use native roots and the shared npm runner", () => { - for (const name of ["test-compat-current.mjs", "test-compat-floor.mjs", "test-package-host.mjs"]) { + for (const name of ["test-compat-current.mjs", "test-package-host.mjs"]) { const source = readScript(name); assert.doesNotMatch(source, /\.pathname\b/); assert.doesNotMatch(source, /spawnSync\(["']npm["']/); - assert.match(source, /runNpm\(/); + assert.match(source, /runNpm/); assert.match(source, /repoRootFromScript\(import\.meta\.url\)/); } }); diff --git a/tests/unit/config-invariants.test.ts b/tests/unit/config-invariants.test.ts index 3ac3b36..90afa10 100644 --- a/tests/unit/config-invariants.test.ts +++ b/tests/unit/config-invariants.test.ts @@ -1,9 +1,8 @@ /** * Invariant tests for the audit-ci security audit configuration. * - * Validates that allowlist entries have unexpired expiry dates, that the - * CI workflow ordering (audit → unit → e2e) is preserved, and that the - * allowlist matches the current lockfile's actual vulnerability state. + * Validates the audit policy, that any allowlist entries have unexpired expiry + * dates, and that CI preserves the audit → unit → e2e order. */ import assert from "node:assert/strict"; @@ -39,21 +38,20 @@ const WORKFLOW_PATH = new URL(".github/workflows/test.yml", REPO_ROOT_URL); const LOCK_PATH = new URL("package-lock.json", REPO_ROOT_URL); const SPAWN_SOURCE_PATH = new URL("spawn/index.ts", REPO_ROOT_URL); const RENDERER_SOURCE_PATH = new URL("spawn/renderer.ts", REPO_ROOT_URL); +// Pinned versions verified against package.json + lockfile. +// Update when Pi devDependencies are bumped. +const EXPECTED_PI_VERSION = "0.84.1"; +const EXPECTED_TYPEBOX_VERSION = "1.3.7"; +// Approved peer floors — must track the pinned devDependency versions above. +const EXPECTED_PI_PEER = `>=${EXPECTED_PI_VERSION}`; +const EXPECTED_TYPEBOX_PEER = `>=${EXPECTED_TYPEBOX_VERSION}`; const EXPECTED_MATRIX = new Set([ "ubuntu-latest@22.19.0", "ubuntu-latest@24", "macos-latest@24", "windows-latest@24", ]); -const EXPECTED_ALLOWLIST_KEYS = new Set([ - "GHSA-mh99-v99m-4gvg", - "GHSA-rgw5-rvv9-x895", - "GHSA-4cwx-7wf7-3272|@earendil-works/pi-coding-agent>undici", - "GHSA-8xcm-r25x-g524|@earendil-works/pi-coding-agent>undici", - "GHSA-jr45-8vmc-qm54|@earendil-works/pi-coding-agent>undici", - "GHSA-m8rv-5g2x-5cg5|@earendil-works/pi-coding-agent>undici", - "GHSA-v3r7-h72x-cjcm|@earendil-works/pi-coding-agent>undici", -]); +const EXPECTED_ALLOWLIST_KEYS = new Set(); function readText(url: URL): string { return readFileSync(url, "utf8"); @@ -81,6 +79,14 @@ function stepIndex(workflow: string, step: string): number { return index; } +function stepBlock(workflow: string, step: string): string { + const start = stepIndex(workflow, step); + const afterName = workflow.indexOf("\n", start) + 1; + const nextStep = workflow.indexOf("\n - name:", afterName); + const end = nextStep === -1 ? workflow.length : nextStep; + return workflow.slice(afterName, end); +} + function allowlistEntries(config: AuditConfig): Array<[string, AuditRecord]> { return config.allowlist.map((entry) => { const [key, value] = Object.entries(entry)[0] ?? []; @@ -119,49 +125,20 @@ function runAuditCi(): void { assert.equal(result.status, 0, diagnostics); } -function compareVersions(a: string, b: string): number { - const parse = (v: string): [number, number, number] => { - const match = /^(\d+)\.(\d+)\.(\d+)/.exec(v); - assert.ok(match, `unexpected version format: ${v}`); - return [Number(match[1]), Number(match[2]), Number(match[3])]; - }; - const [aMajor, aMinor, aPatch] = parse(a); - const [bMajor, bMinor, bPatch] = parse(b); - if (aMajor !== bMajor) return aMajor - bMajor; - if (aMinor !== bMinor) return aMinor - bMinor; - return aPatch - bPatch; -} - -function parseLockfileVulnerablePaths(lockfilePath: string, packageName: string, maxVersion: string): string[] { - const lock = JSON.parse(readFileSync(lockfilePath, "utf8")) as { - packages?: Record; - }; - const prefix = `node_modules/${packageName}`; - const paths: string[] = []; - for (const [path, entry] of Object.entries(lock.packages ?? {})) { - if (!path.endsWith(prefix)) continue; - const version = entry?.version; - assert.ok(typeof version === "string", `missing version for lockfile entry: ${path}`); - if (compareVersions(version, maxVersion) <= 0) { - paths.push(path); - } - } - return paths; -} - -test("Pi 0.82.0 compatibility metadata and source boundaries stay exact", () => { +test("pinned Pi compatibility metadata and source boundaries stay exact", () => { const packageJson = parsePackageJson(); const lock = JSON.parse(readText(LOCK_PATH)) as { packages: Record }; assert.equal(packageJson.engines.node, ">=22.19.0"); - for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"]) { - assert.equal(packageJson.peerDependencies[name], "*", `${name} peer must remain host-provided`); + for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"]) { + assert.equal(packageJson.peerDependencies[name], EXPECTED_PI_PEER, `${name} peer must require ${EXPECTED_PI_PEER}`); } + assert.equal(packageJson.peerDependencies.typebox, EXPECTED_TYPEBOX_PEER, `typebox peer must require ${EXPECTED_TYPEBOX_PEER}`); for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"]) { - assert.equal(packageJson.devDependencies[name], "0.82.0"); - assert.equal(lock.packages[`node_modules/${name}`]?.version, "0.82.0"); + assert.equal(packageJson.devDependencies[name], EXPECTED_PI_VERSION); + assert.equal(lock.packages[`node_modules/${name}`]?.version, EXPECTED_PI_VERSION); } - assert.equal(packageJson.devDependencies.typebox, "1.1.38"); - assert.equal(lock.packages["node_modules/typebox"]?.version, "1.1.38"); + assert.equal(packageJson.devDependencies.typebox, EXPECTED_TYPEBOX_VERSION); + assert.equal(lock.packages["node_modules/typebox"]?.version, EXPECTED_TYPEBOX_VERSION); const spawnSource = readText(SPAWN_SOURCE_PATH); assert.doesNotMatch(spawnSource, /\bAuthStorage\b|\bModelRegistry\b/); @@ -175,7 +152,7 @@ test("Pi 0.82.0 compatibility metadata and source boundaries stay exact", () => assert.doesNotMatch(rendererSource, /process\.(?:stdout|stderr)\.write\s*\(/); }); -test("audit-ci config keeps only the active expiry-tracked scoped exceptions", () => { +test("audit-ci config enforces the empty allowlist policy", () => { const config = parseAuditConfig(); assert.equal(config.$schema, AUDIT_SCHEMA); assert.equal(config.moderate, true); @@ -192,23 +169,6 @@ test("audit-ci config keeps only the active expiry-tracked scoped exceptions", ( } }); -test("the lockfile contains the sole allowlisted vulnerable brace-expansion path", () => { - const vulnerablePaths = parseLockfileVulnerablePaths(fileURLToPath(LOCK_PATH), "brace-expansion", "5.0.7"); - assert.deepEqual(vulnerablePaths, [ - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion", - ]); -}); - -test("the lockfile contains the allowlisted undici path at a vulnerable version", () => { - // undici <8.9.0 is allowlisted only while pi-coding-agent pins 8.5.0 exactly; - // once a pi-coding-agent release ships undici ≥8.9.0 this fails and forces - // the allowlist entries to be removed. - const vulnerablePaths = parseLockfileVulnerablePaths(fileURLToPath(LOCK_PATH), "undici", "8.8.0"); - assert.deepEqual(vulnerablePaths, [ - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici", - ]); -}); - test("workflow keeps the expected matrix and audit/test order", () => { const workflow = readText(WORKFLOW_PATH); const packageJson = parsePackageJson(); @@ -221,6 +181,12 @@ test("workflow keeps the expected matrix and audit/test order", () => { }); +test("Windows current-Pi step runs after packed-host failure but not on cancellation", () => { + const workflow = readText(WORKFLOW_PATH); + const block = stepBlock(workflow, "Synchronized current Pi compatibility on Windows"); + assert.match(block, /if:\s*matrix\.os == 'windows-latest' && !cancelled\(\)/); +}); + test("audit-ci config matches the CI audit command", () => { runAuditCi(); }); diff --git a/tests/unit/dependency-graph-assertions.test.ts b/tests/unit/dependency-graph-assertions.test.ts index afb8ecd..8318577 100644 --- a/tests/unit/dependency-graph-assertions.test.ts +++ b/tests/unit/dependency-graph-assertions.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const { - assertExactPackageVersions, - assertSynchronizedPackageVersions, -} = await import(new URL("../../scripts/dependency-graph-assertions.mjs", import.meta.url).href); + +const { assertSynchronizedPackageVersions } = await import( + new URL("../../scripts/dependency-graph-assertions.mjs", import.meta.url).href, +); const PI_PACKAGES = [ "@earendil-works/pi-agent-core", @@ -13,30 +13,24 @@ const PI_PACKAGES = [ "@earendil-works/pi-tui", ]; -test("floor and current compatibility lanes include installed pi-agent-core", () => { - for (const script of ["test-compat-floor.mjs", "test-compat-current.mjs"]) { - const source = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), "utf8"); - assert.match(source, /["']@earendil-works\/pi-agent-core["']/i, `${script} must guard pi-agent-core`); - } +test("latest compatibility lane guards every installed Pi package", () => { + const source = readFileSync(new URL("../../scripts/test-compat-current.mjs", import.meta.url), "utf8"); + assert.match(source, /["']@earendil-works\/pi-agent-core["']/i); }); function dependency(version: string, dependencies = {}): object { return { version, dependencies }; } -function graph(piVersion = "0.82.0", typeboxVersion = "1.1.38"): object { +function graph(piVersion = "0.99.0", typeboxVersion = "2.0.0"): object { return { name: "synthetic-install", version: "1.0.0", dependencies: { - "@earendil-works/pi-ai": dependency(piVersion, { - typebox: dependency(typeboxVersion), - }), + "@earendil-works/pi-ai": dependency(piVersion, { typebox: dependency(typeboxVersion) }), "@earendil-works/pi-coding-agent": dependency(piVersion, { "@earendil-works/pi-agent-core": dependency(piVersion), - "@earendil-works/pi-ai": dependency(piVersion, { - typebox: dependency(typeboxVersion), - }), + "@earendil-works/pi-ai": dependency(piVersion, { typebox: dependency(typeboxVersion) }), "@earendil-works/pi-tui": dependency(piVersion), }), "@earendil-works/pi-tui": dependency(piVersion), @@ -45,64 +39,18 @@ function graph(piVersion = "0.82.0", typeboxVersion = "1.1.38"): object { }; } -test("exact floor accepts a coherent recursive dependency graph", () => { - assert.doesNotThrow(() => assertExactPackageVersions(graph(), { - "@earendil-works/pi-agent-core": "0.82.0", - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", - typebox: "1.1.38", - })); -}); - -test("exact floor rejects mixed nested Pi and TypeBox versions", () => { - const mixedCore = graph() as any; - mixedCore.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-agent-core"].version = "0.83.0"; - assert.throws( - () => assertExactPackageVersions(mixedCore, { "@earendil-works/pi-agent-core": "0.82.0" }), - /@earendil-works\/pi-agent-core@0\.82\.0.*0\.83\.0/, - ); - - const mixedPi = graph() as any; - mixedPi.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-ai"].version = "0.83.0"; - assert.throws( - () => assertExactPackageVersions(mixedPi, { "@earendil-works/pi-ai": "0.82.0" }), - /@earendil-works\/pi-ai@0\.82\.0.*0\.83\.0/, - ); - - const mixedTypebox = graph() as any; - mixedTypebox.dependencies["@earendil-works/pi-ai"].dependencies.typebox.version = "1.2.0"; - assert.throws( - () => assertExactPackageVersions(mixedTypebox, { typebox: "1.1.38" }), - /typebox@1\.1\.38.*1\.2\.0/, - ); -}); - -test("current assertions accept recursively synchronized Pi and TypeBox versions", () => { - const current = graph("0.99.0", "2.0.0"); +test("latest assertions accept recursively synchronized Pi and TypeBox versions", () => { + const current = graph(); assert.equal(assertSynchronizedPackageVersions(current, PI_PACKAGES), "0.99.0"); assert.equal(assertSynchronizedPackageVersions(current, ["typebox"]), "2.0.0"); }); -test("current assertions reject mixed nested Pi and TypeBox versions", () => { - const mixedCore = graph("0.99.0", "2.0.0") as any; +test("latest assertions reject mixed nested Pi and TypeBox versions", () => { + const mixedCore = graph() as any; mixedCore.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-agent-core"].version = "0.98.0"; - assert.throws( - () => assertSynchronizedPackageVersions(mixedCore, PI_PACKAGES), - /synchronized.*0\.98\.0.*0\.99\.0/i, - ); - - const mixedPi = graph("0.99.0", "2.0.0") as any; - mixedPi.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-tui"].version = "0.98.0"; - assert.throws( - () => assertSynchronizedPackageVersions(mixedPi, PI_PACKAGES), - /synchronized.*0\.98\.0.*0\.99\.0/i, - ); + assert.throws(() => assertSynchronizedPackageVersions(mixedCore, PI_PACKAGES), /synchronized.*0\.98\.0.*0\.99\.0/i); - const mixedTypebox = graph("0.99.0", "2.0.0") as any; + const mixedTypebox = graph() as any; mixedTypebox.dependencies["@earendil-works/pi-ai"].dependencies.typebox.version = "1.9.0"; - assert.throws( - () => assertSynchronizedPackageVersions(mixedTypebox, ["typebox"]), - /synchronized.*1\.9\.0.*2\.0\.0/i, - ); + assert.throws(() => assertSynchronizedPackageVersions(mixedTypebox, ["typebox"]), /synchronized.*1\.9\.0.*2\.0\.0/i); }); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 18068b7..be2d363 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -181,6 +181,7 @@ export function createTestPI() { }, getFlag: (name: string) => _flags.get(name), registerMessageRenderer: () => {}, + registerMarkdownTransformer: () => {}, // Pi 0.84.1 ExtensionAPI compat stub registerEntryRenderer: () => {}, setLabel: () => {}, unregisterProvider: () => {}, diff --git a/tests/unit/module-evaluation-order.test.ts b/tests/unit/module-evaluation-order.test.ts index 87a911c..9c5967e 100644 --- a/tests/unit/module-evaluation-order.test.ts +++ b/tests/unit/module-evaluation-order.test.ts @@ -7,6 +7,9 @@ import { spawnSync } from "node:child_process"; import { strict as assert } from "node:assert"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, it } from "node:test"; @@ -14,11 +17,17 @@ const root = fileURLToPath(new URL("../../", import.meta.url)); const loader = new URL("../../register-loader.mjs", import.meta.url).href; function evaluate(code: string): void { - const result = spawnSync(process.execPath, ["--import", loader, "--input-type=module", "--eval", code], { - cwd: root, - encoding: "utf8", - }); - assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); + const agentDir = mkdtempSync(join(tmpdir(), "pi-agenticoding-module-order-")); + try { + const result = spawnSync(process.execPath, ["--import", loader, "--input-type=module", "--eval", code], { + cwd: root, + encoding: "utf8", + env: { ...process.env, PI_CODING_AGENT_DIR: agentDir }, + }); + assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } } describe("module evaluation order", () => { @@ -92,7 +101,7 @@ describe("module evaluation order", () => { const model = modelRuntime.getModel('module-order-test', 'deterministic'); if (!model) throw new Error('deterministic model missing'); const { session } = await sdk.createAgentSession({ - model, modelRuntime, cwd: process.cwd(), + model, modelRuntime, cwd: process.cwd(), agentDir: process.env.PI_CODING_AGENT_DIR, sessionManager: sdk.SessionManager.inMemory(), settingsManager: sdk.SettingsManager.inMemory(), }); diff --git a/tests/unit/spawn-lifecycle.test.ts b/tests/unit/spawn-lifecycle.test.ts index 7ca2b1a..c5026563 100644 --- a/tests/unit/spawn-lifecycle.test.ts +++ b/tests/unit/spawn-lifecycle.test.ts @@ -1,6 +1,6 @@ import test, { afterEach, beforeEach } from "node:test"; import assert from "node:assert/strict"; -import { createState, resetState } from "../../state.js"; +import { createState, resetState, abortChildSession } from "../../state.js"; import { createSession, createSubscribableSession, createTestPI, createRenderContext, theme } from "./helpers.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; import { registerSpawnTool } from "../../spawn/index.js"; @@ -70,6 +70,128 @@ test("resetState aborts a claimed child session after render ownership transfer" assert.equal(state.liveChildSessions.size, 0); }); +test("nested spawn dispose shares the deduplicated abort promise with other abort paths", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + const session = { + ...createSession([]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Another abort path (spawn invalidation, /reset) already started the shared abort. + const sharedAbort = abortChildSession(state, session); + + component.dispose(); + + assert.equal(abortCalls, 1, "dispose reuses the shared abort instead of aborting again"); + assert.equal(state.liveChildSessions.has("tool-call-1"), false, "dispose clears the live registry entry"); + const lateAbort = abortChildSession(state, session); + assert.strictEqual(lateAbort, sharedAbort, "dispose and later abort share dedup promise identity"); + + await sharedAbort; +}); + +test("nested spawn dispose registers its abort in the shared dedup map", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + const session = { + ...createSession([]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + component.dispose(); + assert.equal(abortCalls, 1); + + // A later abort path must observe the promise dispose already created. + const lateAbort = abortChildSession(state, session); + assert.strictEqual(lateAbort, abortChildSession(state, session), "dedup promise is stable across repeated calls"); + await lateAbort; + assert.equal(abortCalls, 1, "a later abortChildSession call does not re-abort"); +}); + +test("nested spawn dispose is identity-guarded against a replaced live session", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let oldAbortCalls = 0; + const session = { + ...createSession([]), + abort: async () => { + oldAbortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // A newer child claimed the toolCallId slot before disposal. + const replacement = createSubscribableSession([]).session; + state.liveChildSessions.set("tool-call-1", replacement); + + component.dispose(); + + assert.equal(oldAbortCalls, 0, "a superseded session is not aborted by dispose"); + assert.equal(state.liveChildSessions.get("tool-call-1"), replacement, "dispose must not delete the replacement's registry entry"); +}); + +test("nested spawn dispose survives re-entrant dispose from abort", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + let component: any; + const session = { + ...createSession([]), + abort: async () => { + abortCalls++; + // Synchronous re-entrant dispose from within abort must be a no-op. + component.dispose(); + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + component.dispose(); + + assert.equal(abortCalls, 1, "re-entrant dispose from abort does not double-abort"); + assert.equal(state.liveChildSessions.has("tool-call-1"), false, "outer dispose still clears the live registry entry"); +}); + // ── nested spawn lifecycle tests ────────────────────────────────── test("nested spawn drops events after resetState bumps child epoch", () => { diff --git a/tests/unit/spawn-runtime-compatibility.test.ts b/tests/unit/spawn-runtime-compatibility.test.ts index 5dac784..c42d45a 100644 --- a/tests/unit/spawn-runtime-compatibility.test.ts +++ b/tests/unit/spawn-runtime-compatibility.test.ts @@ -8,7 +8,7 @@ import { createState } from "../../state.js"; import { executeSpawn, registerSpawnTool } from "../../spawn/index.js"; import { createTestPI, runRealChildInvocation } from "./helpers.js"; -test("exact Pi floor real child completes through inherited/default thinking", async () => { +test("real child completes through inherited/default thinking", async () => { const proof = await runRealChildInvocation({ prompt: "Use the agentic_e2e_probe tool and return AGENTIC_E2E_PROBE_OK." }); assert.equal(proof.result.content[0].text, proof.expectedText); assert.equal(proof.result.details.model, proof.modelId); @@ -20,7 +20,7 @@ test("exact Pi floor real child completes through inherited/default thinking", a assert.deepEqual(proof.outboundFetches, [], "offline real-child fixture attempted an outbound fetch"); }); -test("exact Pi floor real child preserves selected identity and reports effective thinking", async () => { +test("real child preserves selected identity and reports effective thinking", async () => { const proof = await runRealChildInvocation({ prompt: "Use the agentic_e2e_probe tool and return AGENTIC_E2E_PROBE_OK.", thinking: "max", diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 1086ee6..400247f 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -12,11 +12,41 @@ import { } from "../../spawn/index.js"; import { renderSpawnResult } from "../../spawn/renderer.js"; import { SpawnRouteError } from "../../model-groups/router.js"; -import { createTestPI, createRenderContext, createSession, theme } from "./helpers.js"; +import { createTestPI, createRenderContext, createSession, theme, createDeferred } from "./helpers.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; let h: TestHarness; +// ── Test helpers ───────────────────────────────────────────────────── +// Hoisted so all tests can reference them (used from line ~79 onward). +function mockSessionFactory(opts: { + prompt?: (prompt?: string) => Promise; + abort?: () => Promise; + dispose?: () => void; + result?: any[]; + thinkingLevel?: string; + getSessionStats?: () => any; +} = {}) { + const defaultResult = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + const session: any = { + messages: [] as any[], + get thinkingLevel() { return opts.thinkingLevel; }, + prompt: async (p?: string) => { + if (opts.prompt) await opts.prompt(p); + session.messages = opts.result ?? defaultResult; + }, + abort: opts.abort ?? (async () => {}), + dispose: opts.dispose ?? (() => {}), + getSessionStats: opts.getSessionStats ?? (() => undefined), + }; + return session; +} + +/** Create a session factory for spawn tests — wraps mockSessionFactory with the expected return shape. */ +function mockFactoryWith(opts: Parameters[0] = {}) { + return async () => ({ session: mockSessionFactory(opts), extensionsResult: undefined as any }); +} + function makeChildSpawnTool(state: any) { const pi = createTestPI(); registerSpawnTool(pi as any, state); @@ -57,6 +87,28 @@ function executeWithFailingCleanup(primaryFailure: unknown, cleanupFailure: unkn }; } +function executeWithDisposeFailure(toolCallId: string, context: Record = {}) { + const cleanupError = new Error("dispose failed"); + const pi = createTestPI(); + const state = createState(); + pi.setActiveTools(["read", "bash", "spawn"]); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => {}, + dispose: () => { throw cleanupError; }, + })); + return { + execution: pi.tools.get("spawn").execute( + toolCallId, + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp", ...context } as any, + ), + state, + cleanupError, + }; +} + function assertChildRegistriesCleared(state: ReturnType): void { assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); @@ -79,20 +131,10 @@ test("spawn execute passes broad active registered tool formula to child session const requestedCwd = "/tmp"; let seenConfig: any; - const mockFactory = async (config: any) => { + registerSpawnTool(pi as any, state, async (config: any) => { seenConfig = config; - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + return { session: mockSessionFactory({ prompt: async () => {} }), extensionsResult: undefined as any }; + }); await pi.tools.get("spawn").execute( "spawn-1", @@ -126,21 +168,10 @@ test("spawn forwards requested thinking and reports the session effective thinki const state = createState(); const updates: any[] = []; let seenConfig: any; - const session = { - messages: [] as any[], - get thinkingLevel() { return "off" as const; }, - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - dispose: () => {}, - getSessionStats: () => undefined, - }; - - registerSpawnTool(pi as any, state, (async (config: any) => { + registerSpawnTool(pi as any, state, async (config: any) => { seenConfig = config; - return { session: session as any }; - }) as any); + return { session: mockSessionFactory({ thinkingLevel: "off", prompt: async () => {} }), extensionsResult: undefined as any }; + }); const result = await pi.tools.get("spawn").execute( "spawn-effective-thinking", @@ -176,21 +207,12 @@ test("spawn execute composes Model Group routing with readonly child guards", as }; let seenConfig: any; let seenPrompt = ""; - const mockFactory = async (config: any) => { + registerSpawnTool(pi as any, state, async (config: any) => { seenConfig = config; - const session = { - messages: [] as any[], - prompt: async (prompt: string) => { - seenPrompt = prompt; - session.messages = [{ role: "assistant", content: [{ type: "text", text: "routed result" }] }]; - }, - abort: async () => {}, - dispose: () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - registerSpawnTool(pi as any, state, mockFactory as any); + return { session: mockSessionFactory({ + prompt: async (p?: string) => { seenPrompt = p ?? ""; }, + }), extensionsResult: undefined as any }; + }); const result = await pi.tools.get("spawn").execute( "spawn-routed", @@ -225,20 +247,9 @@ test("spawn execute builds prompt with notebook pages and task", async () => { state.notebookPages.set("entry-a", "preview line\nfull body"); let seenPrompt = ""; - const mockFactory = async (config: any) => { - const session = { - messages: [] as any[], - prompt: async (prompt: string) => { - seenPrompt = prompt; - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async (p?: string) => { seenPrompt = p ?? ""; }, + })); await pi.tools.get("spawn").execute( "spawn-1", @@ -450,23 +461,14 @@ test("spawn execute returns result and stats", async () => { const state = createState(); const updates: any[] = []; - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => ({ - tokens: { input: 11, output: 22, cacheRead: 3, cacheWrite: 4, total: 40 }, - cost: 0.5, - assistantMessages: 2, - }), - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => {}, + getSessionStats: () => ({ + tokens: { input: 11, output: 22, cacheRead: 3, cacheWrite: 4, total: 40 }, + cost: 0.5, + assistantMessages: 2, + }), + })); const result = await pi.tools.get("spawn").execute( "spawn-1", @@ -498,21 +500,10 @@ test("spawn execute marks stats unavailable when stats collection throws", async pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => { - throw new Error("stats failed"); - }, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => {}, + getSessionStats: () => { throw new Error("stats failed"); }, + })); const result = await pi.tools.get("spawn").execute( "spawn-1", { prompt: "Do the task" }, @@ -530,17 +521,7 @@ test("spawn execute throws when child produces no output", async () => { pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => {}, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ result: [] })); await assert.rejects( () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), @@ -553,19 +534,9 @@ test("spawn execute clears childSessions when prompt throws", async () => { pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - throw new Error("prompt failed"); - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => { throw new Error("prompt failed"); }, + })); await assert.rejects( () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), @@ -665,19 +636,9 @@ test("spawn execute clears childSessions after successful completion when unrend pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => {}, + })); const result = await pi.tools.get("spawn").execute( "spawn-1", { prompt: "Do the task" }, @@ -872,6 +833,314 @@ test("spawn execute aborts child session when signal fires during execution", as assert.equal(disposeCalls, 1, "mid-prompt abort disposes exactly once"); }); +test("spawn execute swallows prompt rejection when signal aborts mid-flight", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const controller = new AbortController(); + let abortCalled = false; + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (err: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const mockFactory = async () => { + return { session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { + abortCalled = true; + rejectPrompt(Object.assign(new Error("aborted"), { name: "AbortError" })); + }, + dispose: () => { disposeCalls++; }, + }) }; + }; + registerSpawnTool(pi as any, state, mockFactory as any); + + const executePromise = pi.tools.get("spawn").execute( + "spawn-aborted-throw", + { prompt: "Do the task" }, + controller.signal, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + controller.abort(); + const result = await executePromise; + assert.equal(abortCalled, true); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); + assert.equal(result.details.outcome, "aborted"); + // Outcome and cleanup are the external contracts; text format is secondary. + assert.equal(result.content[0]?.text ?? "", ""); + assert.equal(disposeCalls, 1, "mid-prompt abort disposes exactly once"); +}); + +test("spawn execute preserves a real prompt failure that races with abort", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const controller = new AbortController(); + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const promptError = new Error("prompt failed despite abort"); + registerSpawnTool(pi as any, state, async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + dispose: () => { disposeCalls++; }, + }), extensionsResult: undefined as any })); + + const execution = pi.tools.get("spawn").execute( + "spawn-abort-real-error", { prompt: "Do the task" }, controller.signal, + undefined, { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + controller.abort(); + rejectPrompt(promptError); + + await assert.rejects(execution, (error: unknown) => error === promptError); + assert.equal(disposeCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("spawn invalidation wins the abort and prompt-rejection race", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const controller = new AbortController(); + let abortCalls = 0; + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const mockFactory = async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { abortCalls++; }, + dispose: () => { disposeCalls++; }, + }) }); + registerSpawnTool(pi as any, state, mockFactory as any); + + const execution = pi.tools.get("spawn").execute( + "spawn-abort-reset-race", { prompt: "Do the task" }, controller.signal, + undefined, { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + controller.abort(); + resetState(state); + rejectPrompt(new Error("prompt rejected after abort and reset")); + + await assert.rejects(() => execution, /invalidated by reset/i); + assert.equal(abortCalls, 1, "signal cancellation and reset share one abort"); + assert.equal(disposeCalls, 1, "the raced child disposes exactly once"); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("executeSpawn surfaces AggregateError when abort rejects during active-prompt reset", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const abortError = new Error("abort failed"); + const invalidatedError = "Spawn invalidated by reset."; + const mockFactory = async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { throw abortError; }, + dispose: () => { disposeCalls++; }, + }), extensionsResult: undefined as any }); + registerSpawnTool(pi as any, state, mockFactory as any); + + const execution = pi.tools.get("spawn").execute( + "spawn-abort-rejects", { prompt: "Do the task" }, undefined, + undefined, { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + resetState(state); + rejectPrompt(new Error("prompt rejected")); + + await assert.rejects( + execution, + (error: unknown) => { + assert.ok(error instanceof AggregateError, `expected AggregateError, got: ${String(error)}`); + assert.ok(error.message.includes("Spawn invalidated by reset"), `message should mention invalidation: ${error.message}`); + // Errors[0] is the invalidatedError, errors[1] is the abortFailure + assert.ok(error.errors[0] instanceof Error, "first error should be the invalidatedError"); + assert.equal((error.errors[0] as Error).message, invalidatedError); + assert.equal(error.errors[1], abortError, "second error should be the abort failure"); + return true; + }, + ); + assert.equal(disposeCalls, 1, "the aborted child disposes exactly once"); + assertChildRegistriesCleared(state); +}); + +test("reset before registration reports AggregateError when shared abort rejects", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let abortCalls = 0; + let disposeCalls = 0; + let factoryCalled = false; + const abortError = new Error("abort failed"); + const factoryReady = createDeferred(); + const mockFactory = async () => { + factoryCalled = true; + await factoryReady.promise; + return { + extensionsResult: undefined as any, + session: { + messages: [] as any[], + prompt: async () => {}, + abort: async () => { abortCalls++; throw abortError; }, + dispose: () => { disposeCalls++; }, + getSessionStats: () => undefined, + } as any, + }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const execution = pi.tools.get("spawn").execute( + "spawn-pre-reg-abort-reject", { prompt: "Do the task" }, undefined, + undefined, { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + // Reset while the factory is still producing the session + resetState(state); + // Allow the factory to resolve — session is immediately stale + factoryReady.resolve(); + + await assert.rejects( + execution, + (error: unknown) => { + assert.ok(error instanceof AggregateError, `expected AggregateError, got: ${String(error)}`); + assert.ok(error.errors[0] instanceof Error, "first error should be the invalidatedError"); + assert.equal((error.errors[0] as Error).message, "Spawn invalidated by reset."); + assert.equal(error.errors[1], abortError, "second error should be the abort failure"); + return true; + }, + ); + assert.equal(factoryCalled, true, "factory was called"); + assert.equal(abortCalls, 1, "abort was called exactly once"); + assert.equal(disposeCalls, 1, "child disposed exactly once"); + assertChildRegistriesCleared(state); +}); + +test("reset during prompt reports AggregateError when shared abort rejects (UI reports once)", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let abortCalls = 0; + let disposeCalls = 0; + const abortError = new Error("abort failed"); + const notifications: Array<[string, string]> = []; + const mockFactory = async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { abortCalls++; throw abortError; }, + dispose: () => { disposeCalls++; }, + }), extensionsResult: undefined as any }); + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + registerSpawnTool(pi as any, state, mockFactory as any); + + const execution = pi.tools.get("spawn").execute( + "spawn-reset-prompt-abort-reject-ui", + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp", hasUI: true, ui: { notify: (m: string, l: string) => { notifications.push([m, l]); } } } as any, + ); + await started; + resetState(state); + rejectPrompt(new Error("prompt rejected")); + + await assert.rejects( + execution, + (error: unknown) => { + assert.ok(error instanceof AggregateError, `expected AggregateError, got: ${String(error)}`); + assert.equal(error.errors[0] instanceof Error ? (error.errors[0] as Error).message : null, "Spawn invalidated by reset."); + assert.equal(error.errors[1], abortError); + return true; + }, + ); + assert.equal(notifications.length, 1, "abort failure reported exactly once via UI"); + assert.deepEqual(notifications, [["Spawn cleanup failed: abort failed", "error"]]); + assert.equal(abortCalls, 1); + assert.equal(disposeCalls, 1); + assertChildRegistriesCleared(state); +}); + +test("throwing notify during abort-fail reporting does not emit unhandledRejection", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const abortError = new Error("abort failed"); + const notifyError = new Error("notify exploded"); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const mockFactory = async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { throw abortError; }, + dispose: () => {}, + }), extensionsResult: undefined as any }); + registerSpawnTool(pi as any, state, mockFactory as any); + + const execution = pi.tools.get("spawn").execute( + "spawn-throwing-notify", + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp", hasUI: true, ui: { notify: () => { throw notifyError; } } } as any, + ); + await started; + resetState(state); + rejectPrompt(new Error("prompt rejected")); + + await assert.rejects( + execution, + (error: unknown) => { + assert.ok(error instanceof AggregateError, `expected AggregateError, got: ${String(error)}`); + assert.equal(error.errors[1], abortError); + return true; + }, + ); + // Give any detached microtasks a chance to flush + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(unhandled.length, 0, `no unhandled rejections from notify throwing: ${unhandled.map((e) => String(e)).join(", ")}`); + assertChildRegistriesCleared(state); + } finally { + process.removeListener("unhandledRejection", onUnhandled); + } +}); + test("spawn renderCall shows prompt preview and optional routing controls", () => { const state = createState(); @@ -1207,15 +1476,10 @@ test("executeSpawn does not prompt when onUpdate synchronously resets the child undefined, () => { resetState(state); }, "medium", - async () => ({ - extensionsResult: undefined as any, - session: { - messages: [] as any[], - prompt: async () => { promptCalls++; }, - abort: async () => { abortCalls++; }, - dispose: () => { disposeCalls++; }, - getSessionStats: () => undefined, - } as any, + mockFactoryWith({ + prompt: async () => { promptCalls++; }, + abort: async () => { abortCalls++; }, + dispose: () => { disposeCalls++; }, }), ); @@ -1246,15 +1510,10 @@ test("executeSpawn does not prompt when onUpdate synchronously aborts the signal controller.signal, () => { controller.abort(reason); }, "medium", - async () => ({ - extensionsResult: undefined as any, - session: { - messages: [] as any[], - prompt: async () => { promptCalls++; }, - abort: async () => { abortCalls++; }, - dispose: () => { disposeCalls++; }, - getSessionStats: () => undefined, - } as any, + mockFactoryWith({ + prompt: async () => { promptCalls++; }, + abort: async () => { abortCalls++; }, + dispose: () => { disposeCalls++; }, }), ); @@ -1308,16 +1567,14 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn // Wait for session to be created and prompt to start await promptStartedPromise; - // Reset state triggers abortAndClearChildSessions which calls session.abort() - // abort() rejects the pending prompt, which causes the stale check to fire + // Reset cleanup starts the shared abort, then prompt rejection reaches invalidation. resetState(state); await assert.rejects( () => executePromise, /invalidated by reset/i, ); - // abort is called once by clearChildSession (identity match via liveChildSessions) - assert.equal(abortCalls >= 1, true); + assert.equal(abortCalls, 1, "reset and invalidation share one abort"); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); assert.equal(disposeCalls, 1, "prompt-reset invalidation disposes exactly once"); @@ -1527,3 +1784,80 @@ test("spawn docs document active registered inheritance", async () => { assert.match(v040, /spawn and handoff/); assert.match(v040, /notebook tools/); }); + +// ── PR #23 follow-up: abort cleanup + dispose AggregateError coverage ────── + +test("spawn abort cleanup notifies UI when abort rejects", async () => { + const pi = createTestPI(); + const state = createState(); + const notifications: Array<[string, string]> = []; + const ctx = { + model: { id: "mock-model" }, + cwd: "/tmp", + hasUI: true, + ui: { notify: (m: string, l: string) => { notifications.push([m, l]); } }, + } as any; + const abortError = new Error("abort failed"); + const session = { + messages: [], + prompt: async () => {}, + abort: async () => { throw abortError; }, + dispose: () => {}, + getSessionStats: () => undefined, + }; + const controller = new AbortController(); + controller.abort(new Error("parent aborted")); + + await assert.rejects( + () => executeSpawn( + "spawn-abort-cleanup", + pi as any, + ctx, + state, + { prompt: "Do the task" }, + controller.signal, + undefined, + "medium", + async () => ({ session: session as any, extensionsResult: undefined as any }), + ), + (error: unknown) => error === abortError, + ); + assert.deepEqual(notifications, [["Spawn cleanup failed: abort failed", "error"]]); +}); + +test("spawn dispose failure without primary error propagates cleanup error", async () => { + const headless = executeWithDisposeFailure("spawn-dispose-only", { hasUI: false }); + await assert.rejects( + () => headless.execution, + (err: unknown) => { assert.equal(err, headless.cleanupError); return true; }, + ); + assertChildRegistriesCleared(headless.state); + + const notifications: Array<[string, string]> = []; + const ui = executeWithDisposeFailure("spawn-dispose-only-ui", { + hasUI: true, + ui: { notify: (m: string, l: string) => { notifications.push([m, l]); } }, + }); + await assert.rejects( + () => ui.execution, + (err: unknown) => { assert.equal(err, ui.cleanupError); return true; }, + ); + assert.equal(notifications.length, 0, "dispose-only failure does not notify"); + assertChildRegistriesCleared(ui.state); +}); + +test("spawn headless dispose AggregateError preserves both failures", async () => { + const primaryFailure = new Error("prompt failed"); + const cleanupFailure = new Error("dispose failed"); + const { execution, state } = executeWithFailingCleanup(primaryFailure, cleanupFailure, { hasUI: false }); + await assert.rejects( + () => execution, + (err: unknown) => { + assert.ok(err instanceof AggregateError); + assert.equal((err as AggregateError).message, "Spawn failed and cleanup failed."); + assert.deepEqual((err as AggregateError).errors, [primaryFailure, cleanupFailure]); + return true; + }, + ); + assertChildRegistriesCleared(state); +});