diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 17861a9..86d8bc3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: matrix: include: - os: ubuntu-latest - node-version: "22" # minimum version on primary platform + node-version: "22.19.0" # exact supported floor on primary platform - os: ubuntu-latest node-version: "24" # latest on primary platform - os: macos-latest @@ -70,6 +70,21 @@ jobs: run: npm run test:e2e # Upload test results for debugging — artifacts available for 30 days. + - 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.80.8 host 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-package-host.mjs" + + - name: Synchronized current Pi compatibility on Windows + if: matrix.os == 'windows-latest' + working-directory: ${{ runner.temp }} + run: node "${{ github.workspace }}/scripts/test-compat-current.mjs" + - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -78,3 +93,16 @@ jobs: path: | tests/snapshots/ retention-days: 30 + + current-pi: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - name: Synchronized current Pi compatibility + working-directory: ${{ runner.temp }} + run: node "${{ github.workspace }}/scripts/test-compat-current.mjs" diff --git a/CHANGELOG.md b/CHANGELOG.md index 761e65f..5628604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Migrated child spawning to Pi 0.80.8's public selected-model and child-owned runtime APIs, added `max` thinking support, and dispose every created child session exactly once across completion, failure, abort, and reset races. Pi 0.80.8 and Node 22.19.0 are now the documented minimums; parent-only transient provider/auth state fails explicitly without model fallback. - Spawned child agents now inherit active registered parent tools executable in the child session, including MCP/extension tools such as ChunkHound when active and registered, while still excluding spawn and handoff and preserving child-local notebook tools. ## [0.3.0] - 2026-05-23 diff --git a/README.md b/README.md index bc91c1c..e0e55ef 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ Then disable pi's built-in compaction so handoff stays in control: That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `notebook_index`, and `handoff`. The status bar shows context usage and notebook count. +### Compatibility + +pi-agenticoding requires **Pi 0.80.8 or later** and **Node.js 22.19.0 or later**. Spawn passes Pi's already-selected public model into a child-owned runtime. Persisted, environment-based, and extension-rediscoverable provider/auth configuration is available to children; parent-only transient credentials, inline provider factories, or in-memory catalog changes are not guaranteed to be rediscoverable. In that unsupported case, spawn reports the child resolution/auth failure and does not silently select another model. + --- ## What You Get @@ -104,7 +108,7 @@ The agent decided to spawn research children, save reusable findings to the note ### Spawn — Isolate Noise -Delegate messy work to an isolated child agent with clean context. The child inherits the parent's model, thinking level, cwd, and active registered tools executable in the child session, including MCP/extension tools such as ChunkHound when they are active and registered. Child-local notebook tools remain available, but children cannot spawn grandchildren or handoff. Siblings run in parallel; the parent stays focused on orchestration. +Delegate messy work to an isolated child agent with clean context. The child inherits the parent's already-selected public model, thinking level (including `max`), cwd, and active registered tools executable in the child session, including MCP/extension tools such as ChunkHound when they are active and registered. Child-local notebook tools remain available, but children cannot spawn grandchildren or handoff. Each child owns an isolated in-memory session that spawn disposes when execution ends. Siblings run in parallel; the parent stays focused on orchestration. ### Notebook — Continuity Across Cuts diff --git a/audit-ci.jsonc b/audit-ci.jsonc index fdb63dd..0478d77 100644 --- a/audit-ci.jsonc +++ b/audit-ci.jsonc @@ -2,18 +2,9 @@ "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json", "moderate": true, "allowlist": [ - // Upstream: pi-coding-agent ships a shrinkwrap that pins vulnerable transitive - // deps (ws, protobufjs, undici). The fix must happen in pi-coding-agent itself - // — nothing to do from this extension. - { "GHSA-96hv-2xvq-fx4p": { "active": true, "expiry": "2026-09-01", "notes": "ws <8.21.0 via pi-coding-agent shrinkwrap" } }, - { "GHSA-f38q-mgvj-vph7": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.2 via pi-coding-agent shrinkwrap" } }, - { "GHSA-wcpc-wj8m-hjx6": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.0 via pi-coding-agent shrinkwrap" } }, - { "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, - { "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } } - // Low-severity undici advisories (GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m) are - // not allowlisted — the config gates moderate+ only, so they don't block CI. + // npm's audit report exposes this hoisted advisory as the module path + // `protobufjs`; a config invariant separately rejects any vulnerable + // protobufjs occurrence outside the exact Pi 0.80.8 development path. + { "GHSA-f38q-mgvj-vph7|protobufjs": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs 7.6.1 only via @earendil-works/pi-ai > @google/genai in the exact Pi 0.80.8 development graph" } } ] } diff --git a/package-lock.json b/package-lock.json index 62348ad..ad864f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,24 +9,24 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-ai": "0.80.8", + "@earendil-works/pi-coding-agent": "0.80.8", + "@earendil-works/pi-tui": "0.80.8", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", "husky": "^9.1.7", - "typebox": "^1.2.2", + "typebox": "1.1.38", "typescript": "^6.0.3" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", - "typebox": "^1.2.2" + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" } }, "node_modules/@anthropic-ai/sdk": { @@ -538,16 +538,17 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", - "integrity": "sha512-KGepEdgEeWDs7Imwlp96tBsO8TjSIpcDBvazsCDtHRa81+uwJI/YGetTegI52pMlKhVpJFLIGajRi4PCGC5MUg==", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.8.tgz", + "integrity": "sha512-GkiMUP3PB0hwBhj7qNppCa6z1U+CnwBf4gcxC+fg2PWdNrliaJQQNp4DUERiZqS7MJBLEu56kwpdrG7Tjymonw==", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -562,24 +563,17 @@ "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-ai/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==", - "dev": true, - "license": "MIT" - }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.6.tgz", - "integrity": "sha512-xPQDoA3+4Q8UsInST8OGFHPHyi7JQIbx51ku5kf2VuhXrrTv/npjVTXYD3A3D5xs6QRebV0B2mQqHfXaxQAplw==", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.8.tgz", + "integrity": "sha512-oal0jK9E221Imhrj4Q4wXOhWmhzZEzbt9gmbVHs3UR4+KaClg+Ki1vH2FTVY7hntga89koPFSq4kQ6XV/HoSng==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.6", - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-agent-core": "^0.80.8", + "@earendil-works/pi-ai": "^0.80.8", + "@earendil-works/pi-tui": "^0.80.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -593,7 +587,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -1069,12 +1063,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.6.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.8.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-ai": "^0.80.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1084,15 +1078,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.8.tgz", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -1101,15 +1096,15 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.8.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1351,15 +1346,24 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { @@ -1375,6 +1379,26 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1397,9 +1421,9 @@ "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "dev": true, "license": "BSD-3-Clause" }, @@ -1420,13 +1444,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2271,9 +2288,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -2281,15 +2298,14 @@ "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -2404,9 +2420,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "dev": true, "license": "MIT", "engines": { @@ -2447,9 +2463,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -2521,9 +2537,9 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", - "integrity": "sha512-6JCq780X0UuqvJsUDSJmi4V54ObB8qSwFQiBOI1jhPpC+Ydusd8SEXn2HtyIqve/utMgwcZT9aOyZM72m26A0w==", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.8.tgz", + "integrity": "sha512-EPKtojwGyjGruZEUYgyZpIYEdiwVAXFu6I+cx2LcZeOTpg+RHFUHboOd0uNEA/beihIdq2yc2lYmFnWFNoN0bA==", "dev": true, "license": "MIT", "dependencies": { @@ -2560,15 +2576,24 @@ } }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@nodable/entities": { @@ -2584,6 +2609,26 @@ ], "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3799,9 +3844,9 @@ "license": "0BSD" }, "node_modules/typebox": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.2.tgz", - "integrity": "sha512-0nqIJFL+baWoAEtwa0l/vfbfXg0+3gEhiWGnHuoIiivXjlk/TpxDddG0WER34CojKNpHi4ZXku8XGEz9H55b5Q==", + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 298dc53..04dda46 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,13 @@ "url": "git+https://github.com/agenticoding/pi-agenticoding.git" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", - "typebox": "^1.2.2" + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" }, "scripts": { "prepare": "husky", @@ -27,17 +27,20 @@ "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.79.6", - "@earendil-works/pi-coding-agent": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-ai": "0.80.8", + "@earendil-works/pi-coding-agent": "0.80.8", + "@earendil-works/pi-tui": "0.80.8", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", "husky": "^9.1.7", - "typebox": "^1.2.2", + "typebox": "1.1.38", "typescript": "^6.0.3" }, "pi": { diff --git a/scripts/compat-process.mjs b/scripts/compat-process.mjs new file mode 100644 index 0000000..f84f05f --- /dev/null +++ b/scripts/compat-process.mjs @@ -0,0 +1,65 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Resolve the repository root from a script under ./scripts. */ +export function repoRootFromScript(importMetaUrl) { + return resolve(dirname(fileURLToPath(importMetaUrl)), ".."); +} + +function formatInvocation(command, args) { + return [command, ...args].map((value) => JSON.stringify(value)).join(" "); +} + +/** 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; + const result = spawnSync(command, args, { + cwd, + env, + encoding: "utf8", + stdio: capture ? "pipe" : "inherit", + }); + if (result.error || result.signal || result.status !== 0) { + const diagnostics = [ + `invocation: ${formatInvocation(command, args)}`, + `cwd: ${cwd ?? process.cwd()}`, + `error.stack: ${result.error?.stack ?? "none"}`, + `status: ${String(result.status)}`, + `signal: ${String(result.signal)}`, + `stdout:\n${result.stdout ?? ""}`, + `stderr:\n${result.stderr ?? ""}`, + ].join("\n"); + throw new Error(diagnostics); + } + return result; +} + +/** Build a shell-free npm invocation, including Windows' npm.cmd installations. */ +export function npmInvocation(args, options = {}) { + const { + env = process.env, + platform = process.platform, + execPath = process.execPath, + } = options; + if (env.npm_execpath) { + return { command: execPath, args: [env.npm_execpath, ...args] }; + } + if (platform !== "win32") { + return { command: "npm", args }; + } + + const npmCli = resolve(dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js"); + if (existsSync(npmCli)) { + return { command: execPath, args: [npmCli, ...args] }; + } + throw new Error( + `Unable to locate npm CLI beside ${execPath}; invoke this compatibility check through its npm script.`, + ); +} + +export function runNpm(cwd, args, options = {}) { + const invocation = npmInvocation(args, options); + return runChecked(invocation.command, invocation.args, { cwd, ...options }); +} diff --git a/scripts/dependency-graph-assertions.mjs b/scripts/dependency-graph-assertions.mjs new file mode 100644 index 0000000..334aa7a --- /dev/null +++ b/scripts/dependency-graph-assertions.mjs @@ -0,0 +1,60 @@ +function collectPackageOccurrences(graph, packageNames) { + const targets = new Set(packageNames); + const occurrences = new Map(packageNames.map((name) => [name, []])); + + function visit(node, path) { + if (!node || typeof node !== "object" || !node.dependencies || typeof node.dependencies !== "object") return; + for (const [name, dependency] of Object.entries(node.dependencies)) { + const dependencyPath = `${path} > ${name}`; + if (targets.has(name)) { + occurrences.get(name).push({ + path: dependencyPath, + version: typeof dependency?.version === "string" ? dependency.version : undefined, + }); + } + visit(dependency, dependencyPath); + } + } + + visit(graph, graph?.name ?? "root"); + return occurrences; +} + +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); + const packages = []; + + for (const name of packageNames) { + const namedPackages = occurrences.get(name); + if (namedPackages.length === 0) throw new Error(`Expected ${name} in the dependency graph, found no occurrences`); + packages.push(...namedPackages.map((occurrence) => ({ name, ...occurrence }))); + } + + const versions = [...new Set(packages.map(({ version }) => formatVersion(version)))].sort(); + if (versions.length !== 1) { + const found = packages.map(({ name, path, version }) => `${name}@${formatVersion(version)} at ${path}`).join(", "); + throw new Error(`Expected synchronized package versions; found ${versions.join(", ")}: ${found}`); + } + if (packages[0].version === undefined) throw new Error(`Expected synchronized package versions; found missing version`); + return packages[0].version; +} diff --git a/scripts/test-compat-current.mjs b/scripts/test-compat-current.mjs new file mode 100644 index 0000000..a60e1b9 --- /dev/null +++ b/scripts/test-compat-current.mjs @@ -0,0 +1,61 @@ +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRootFromScript, runChecked, runNpm } 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 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 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"); + } + + 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"]); + + 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/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 }); +} diff --git a/scripts/test-compat-floor.mjs b/scripts/test-compat-floor.mjs new file mode 100644 index 0000000..5e259e4 --- /dev/null +++ b/scripts/test-compat-floor.mjs @@ -0,0 +1,33 @@ +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.80.8", + "@earendil-works/pi-ai": "0.80.8", + "@earendil-works/pi-coding-agent": "0.80.8", + "@earendil-works/pi-tui": "0.80.8", + 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.80.8 compatibility floor passed.\n"); diff --git a/scripts/test-package-host.mjs b/scripts/test-package-host.mjs new file mode 100644 index 0000000..556be97 --- /dev/null +++ b/scripts/test-package-host.mjs @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRootFromScript, runChecked, runNpm } 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 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.80.8", + "@earendil-works/pi-coding-agent": "0.80.8", + "@earendil-works/pi-tui": "0.80.8", + 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}`); + } + + 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"); +const loader = new DefaultResourceLoader({ + cwd: process.cwd(), + agentDir: join(process.cwd(), "agent"), + additionalExtensionPaths: [extensionPath], +}); +await loader.reload(); +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.80.8 host smoke passed with host-provided peers.\n"); +} finally { + if (tarball) rmSync(tarball, { force: true }); + rmSync(temp, { recursive: true, force: true }); +} diff --git a/spawn/index.ts b/spawn/index.ts index 8d30ec9..4b5f491 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -22,11 +22,9 @@ import { READONLY_WRITE_EDIT_SUMMARY, } from "../readonly-copy.js"; import { - AuthStorage, createAgentSession, createBashToolDefinition, defineTool, - ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; @@ -51,6 +49,14 @@ import { const CHILD_MAX_LINES = 2000; const CHILD_MAX_BYTES = 50 * 1024; +const spawnCleanupErrors = new WeakMap, { primary: unknown; cleanup: unknown }>(); + +/** Return a disposal failure retained alongside a primary spawn failure. */ +export function getSpawnCleanupError(error: unknown, execution: Promise): unknown { + const retained = spawnCleanupErrors.get(execution); + return retained && Object.is(retained.primary, error) ? retained.cleanup : undefined; +} + // ── Helpers ─────────────────────────────────────────────────────────── // Widen to accept AgentMessage variants from session messages. @@ -214,13 +220,13 @@ const SPAWN_PARAMETERS = Type.Object({ "Self-contained task description. Reference notebook pages by name — " + "child will notebook_read them on demand.", }), - thinking: StringEnum( - ["off", "minimal", "low", "medium", "high", "xhigh"] as const, + thinking: Type.Optional(StringEnum( + ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, { description: "Override child thinking level. Inherits parent by default.", }, - ), + )), }); @@ -257,7 +263,7 @@ export function createChildTools( * * @param sessionFactory - Test seam for mocking createAgentSession. */ -export async function executeSpawn( +export function executeSpawn( toolCallId: string, pi: ExtensionAPI, ctx: ExtensionContext, @@ -272,14 +278,15 @@ export async function executeSpawn( | undefined, defaultThinking: ThinkingValue, sessionFactory: typeof createAgentSession = createAgentSession, -) { - +): Promise<{ content: TextContent[]; details: SpawnResultDetails }> { + let execution!: Promise<{ content: TextContent[]; details: SpawnResultDetails }>; + execution = (async () => { const childModel = ctx.model; if (!childModel) { throw new Error("No model configured. Cannot spawn child agent."); } - const childThinking: ThinkingValue = params.thinking ?? defaultThinking; + const requestedChildThinking: ThinkingValue = params.thinking ?? defaultThinking; const listing = formatPageList(state); const notebookListing = listing @@ -303,8 +310,6 @@ export async function executeSpawn( `When complete, provide a concise summary of findings. ` + `Keep the result under ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB.`; - const authStorage = AuthStorage.create(); - const modelRegistry = ModelRegistry.create(authStorage); const childSessionEpoch = state.childSessionEpoch; const isStale = () => state.childSessionEpoch !== childSessionEpoch; const childTools = createChildTools(pi, state, { isStale }); @@ -329,21 +334,27 @@ export async function executeSpawn( const effectiveToolNames = filterReadonlyToolNames(childToolNames, state.readonlyEnabled); const { session } = await sessionFactory({ - sessionManager: SessionManager.inMemory(), + sessionManager: SessionManager.inMemory(ctx.cwd), model: childModel, - thinkingLevel: childThinking, + thinkingLevel: requestedChildThinking, cwd: ctx.cwd, tools: effectiveToolNames, customTools: effectiveChildTools, - authStorage, - modelRegistry, }); + // Pi clamps unsupported requested levels during session creation. Report the + // public session value so child details describe what actually runs. The + // 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; - session.abort().catch(() => {}); + if (!abortPromise) { + abortPromise = session.abort(); + abortPromise.catch(() => {}); + } }; const clearChildSession = () => { if (state.childSessions.get(toolCallId) === session) { @@ -359,9 +370,12 @@ export async function executeSpawn( throw invalidatedError; }; - if (isStale()) { - await abortAndInvalidate(); - } + let primaryError: unknown; + let hasPrimaryError = false; + try { + if (isStale()) { + await abortAndInvalidate(); + } // liveChildSessions must be set before childSessions so the renderer can // attach with a fully-published live ownership record. @@ -370,8 +384,8 @@ export async function executeSpawn( try { if (signal?.aborted) { - wasAborted = true; - await session.abort(); + abortChild(); + await abortPromise; throw signal.reason instanceof Error ? signal.reason : new Error("Spawn aborted before child session started."); @@ -381,17 +395,30 @@ export async function executeSpawn( await abortAndInvalidate(); } + // Register before publishing the running update: onUpdate is component code + // and may synchronously abort or reset this child. + signal?.addEventListener("abort", abortChild, { once: true }); onUpdate?.({ content: [], details: { model: childModel.id, - thinking: childThinking, + thinking: effectiveChildThinking(), truncated: false, outcome: "running", } satisfies SpawnResultDetails, }); - signal?.addEventListener("abort", abortChild, { once: true }); + if (signal?.aborted) { + abortChild(); + await abortPromise; + throw signal.reason instanceof Error + ? signal.reason + : new Error("Spawn aborted before child session started."); + } + if (isStale()) { + await abortAndInvalidate(); + } + await session.prompt(fullPrompt); } catch (error) { clearChildSession(); @@ -447,7 +474,7 @@ export async function executeSpawn( const details: SpawnResultDetails = { model: childModel.id, - thinking: childThinking, + thinking: effectiveChildThinking(), truncated, outcome, }; @@ -457,10 +484,33 @@ export async function executeSpawn( details.statsUnavailable = true; } - return { - content: [{ type: "text" as const, text: finalText }] as TextContent[], - details, - }; + return { + content: [{ type: "text" as const, text: finalText }] as TextContent[], + details, + }; + } catch (error) { + primaryError = error; + hasPrimaryError = true; + throw error; + } finally { + clearChildSession(); + try { + // AgentSession always provides dispose(); the guard keeps intentionally + // partial test doubles compatible with the public session boundary. + if (typeof session.dispose === "function") session.dispose(); + } catch (cleanupError) { + if (hasPrimaryError) { + spawnCleanupErrors.set(execution, { + primary: primaryError, + cleanup: cleanupError, + }); + } else { + throw cleanupError; + } + } + } + })(); + return execution; } /** @@ -488,7 +538,7 @@ export function registerSpawnTool( parameters: SPAWN_PARAMETERS, renderShell: "self", - async execute( + execute( _toolCallId: string, params: { prompt: string; thinking?: ThinkingValue }, signal: AbortSignal | undefined, diff --git a/spawn/renderer.ts b/spawn/renderer.ts index 0577893..c109883 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -237,10 +237,16 @@ function formatCollapsedStats(details: SpawnResultDetails): { text: string; colo * Minimal interface the frame scheduler needs from a component. * Lets the scheduler batch without importing the full class. */ +interface ScheduledSpawnRender { + requestRender: () => void; + /** Restore the consumed request only when it is still current. */ + restoreAfterFailure: () => boolean; +} + interface SpawnFrameTarget { flushPendingUpdates(): void; clearRenderCache(): void; - flushScheduledRender(): (() => void) | undefined; + flushScheduledRender(): ScheduledSpawnRender | undefined; } // ── Frame-based render scheduler ──────────────────────────────────── @@ -299,8 +305,8 @@ export class SpawnFrameScheduler { const batch = [...this.dirtyComponents]; this.dirtyComponents.clear(); - const requestRenders = new Set<() => void>(); - const failed: SpawnFrameTarget[] = []; + const requestRenders = new Map<() => void, Map boolean>>(); + const failed = new Set(); for (const component of batch) { try { @@ -308,29 +314,44 @@ export class SpawnFrameScheduler { component.flushPendingUpdates(); // 2. Invalidate render cache so render() recomputes on next TUI paint component.clearRenderCache(); - // 3. Collect TUI invalidate - const r = component.flushScheduledRender(); - if (r) requestRenders.add(r); - } catch (e) { - // Component failed during flush — re-queue for next frame. - // The error is logged but we continue processing remaining components. - console.error("[spawn] flush error on component:", e); - failed.push(component); + // 3. Collect TUI invalidate and its component-owned recovery operation + const scheduled = component.flushScheduledRender(); + if (scheduled) { + const targets = requestRenders.get(scheduled.requestRender) + ?? new Map boolean>(); + targets.set(component, scheduled.restoreAfterFailure); + requestRenders.set(scheduled.requestRender, targets); + } + } catch { + // Component failed during flush — re-queue for the next frame while + // remaining components continue. Do not write diagnostics: stdout/stderr + // corrupts Pi's TUI and this scheduler has no ExtensionContext UI seam. + failed.add(component); } } - // Re-queue failed components for recovery on next frame - for (const component of failed) { - getSingletons().frameScheduler.markDirty(component); + // One invalidate per distinct callback per frame tick. If it fails, let each + // component restore only the render request that was consumed for this frame. + for (const [requestRender, targets] of requestRenders) { + try { + requestRender(); + } catch { + for (const [component, restoreAfterFailure] of targets) { + if (restoreAfterFailure()) failed.add(component); + } + } } - // One invalidate per distinct callback per frame tick. - for (const requestRender of requestRenders) { - requestRender(); + // Re-queue failed components directly, then schedule exactly one recovery + // frame. Calling markDirty() here would create a timer that this method could + // overwrite below, leaving an untracked callback behind. + for (const component of failed) { + this.dirtyComponents.add(component); } - // If more components were dirtied during flush, schedule another frame - if (this.dirtyComponents.size > 0) { + // If more components were dirtied during flush, schedule another frame. + // A callback may already have called markDirty(), so retain that timer. + if (this.dirtyComponents.size > 0 && !this.frameTimer) { this.frameTimer = setTimeout(() => this.flush(), this.frameMs); } } @@ -449,10 +470,11 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget * Returns the TUI invalidate function if this component has work pending. * Also detects stale sessions and triggers a full rebuild. */ - flushScheduledRender(): (() => void) | undefined { + flushScheduledRender(): ScheduledSpawnRender | undefined { if (!this.renderQueued || this.queuedRenderToken !== this.renderScheduleToken) { return undefined; } + const consumedToken = this.queuedRenderToken; this.renderQueued = false; this.queuedRenderToken = undefined; if (this.isStaleSession()) { @@ -461,7 +483,17 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.clearRenderCache(); return undefined; } - return this.requestRender; + return { + requestRender: this.requestRender, + restoreAfterFailure: () => { + // A reset/dispose increments the token; a reentrant event queues a newer + // token. Neither may be overwritten by recovery of this consumed frame. + if (this.renderQueued || this.renderScheduleToken !== consumedToken) return false; + this.renderQueued = true; + this.queuedRenderToken = consumedToken; + return true; + }, + }; } setRequestRender(requestRender: () => void): void { diff --git a/spawn/shared.ts b/spawn/shared.ts index a38fec0..b376aa5 100644 --- a/spawn/shared.ts +++ b/spawn/shared.ts @@ -1,8 +1,9 @@ -export type ThinkingValue = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ThinkingValue = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; export type SpawnOutcome = "running" | "success" | "aborted" | "error"; export type SpawnResultDetails = { model: string; + /** Effective child-session level after Pi applies model capability clamping. */ thinking: ThinkingValue; truncated: boolean; outcome: SpawnOutcome; diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 9c3d1e8..26f7210 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -34,7 +34,7 @@ import { SpawnFrameScheduler } from "../spawn/renderer.js"; // ── Types ───────────────────────────────────────────────────────────── export interface TestHarness { - /** Captured console.warn and console.error calls. */ + /** Captured console.debug/log/warn/error calls. */ warnings: Array<{ level: string; args: unknown[] }>; /** Restore console, clear scheduler, reset write lock. */ teardown: () => void; @@ -66,6 +66,8 @@ export function createTestHarness(): TestHarness { frameScheduler: new SpawnFrameScheduler(), }; const warnings: Array<{ level: string; args: unknown[] }> = []; + const originalDebug = console.debug; + const originalLog = console.log; const originalWarn = console.warn; const originalError = console.error; @@ -84,7 +86,13 @@ export function createTestHarness(): TestHarness { // context, frame scheduler) in one call. __setSingletons(singletons); - // Capture console output for assertions without noisy passing-test output. + // Capture every forbidden TUI-corrupting console level for assertions. + console.debug = (...args: unknown[]) => { + warnings.push({ level: "debug", args }); + }; + console.log = (...args: unknown[]) => { + warnings.push({ level: "log", args }); + }; console.warn = (...args: unknown[]) => { warnings.push({ level: "warn", args }); }; @@ -99,6 +107,8 @@ export function createTestHarness(): TestHarness { // Then clear it to release any dirty components before disposal. __setSingletons(previousSingletons); singletons.frameScheduler.clear(); + console.debug = originalDebug; + console.log = originalLog; console.warn = originalWarn; console.error = originalError; }, diff --git a/tests/unit/compat-process.test.ts b/tests/unit/compat-process.test.ts new file mode 100644 index 0000000..d89aa72 --- /dev/null +++ b/tests/unit/compat-process.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const { + npmInvocation, + repoRootFromScript, + runChecked, + runNpm, +} = await import(new URL("../../scripts/compat-process.mjs", import.meta.url).href); + +const REPO_ROOT = fileURLToPath(new URL("../../", import.meta.url)); + +function readScript(name: string): string { + return readFileSync(join(REPO_ROOT, "scripts", name), "utf8"); +} + +test("repoRootFromScript decodes native paths containing spaces", () => { + const expectedRoot = resolve(tmpdir(), "compat repo with spaces"); + const scriptUrl = pathToFileURL(join(expectedRoot, "scripts", "check.mjs")).href; + assert.equal(repoRootFromScript(scriptUrl), expectedRoot); +}); + +test("npmInvocation uses npm_execpath through the active Node executable", () => { + assert.deepEqual( + npmInvocation(["ls", "--json"], { + env: { npm_execpath: "/npm install/npm-cli.js" }, + platform: "win32", + execPath: "/node install/node.exe", + }), + { + command: "/node install/node.exe", + args: ["/npm install/npm-cli.js", "ls", "--json"], + }, + ); +}); + +test("runNpm launches npm portably", () => { + const result = runNpm(REPO_ROOT, ["--version"], { capture: true }); + assert.match(result.stdout, /^\d+\.\d+\.\d+/); +}); + +test("runChecked reports process launch failures", () => { + assert.throws( + () => runChecked("pi-agenticoding-command-that-does-not-exist", [], { cwd: REPO_ROOT, capture: true }), + (error: unknown) => { + assert.match(String(error), /error\.stack:/); + assert.match(String(error), /status: null/); + return true; + }, + ); +}); + +test("runChecked reports nonzero status and captured output", () => { + assert.throws( + () => runChecked(process.execPath, [ + "-e", + "process.stdout.write('stdout sentinel'); process.stderr.write('stderr sentinel'); process.exit(7)", + ], { cwd: REPO_ROOT, capture: true }), + (error: unknown) => { + assert.match(String(error), /status: 7/); + assert.match(String(error), /stdout sentinel/); + assert.match(String(error), /stderr sentinel/); + 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"]) { + const source = readScript(name); + assert.doesNotMatch(source, /\.pathname\b/); + assert.doesNotMatch(source, /spawnSync\(["']npm["']/); + 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 1eeac4d..2dbeb51 100644 --- a/tests/unit/config-invariants.test.ts +++ b/tests/unit/config-invariants.test.ts @@ -25,9 +25,9 @@ type AuditConfig = { }; type PackageJson = { - engines: { - node: string; - }; + engines: { node: string }; + peerDependencies: Record; + devDependencies: Record; }; const AUDIT_SCHEMA = "https://github.com/IBM/audit-ci/raw/main/docs/schema.json"; @@ -39,21 +39,17 @@ const AUDIT_CLI_PATH = fileURLToPath( ); const PACKAGE_JSON_PATH = new URL("package.json", REPO_ROOT_URL); 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); const EXPECTED_MATRIX = new Set([ - "ubuntu-latest@22", + "ubuntu-latest@22.19.0", "ubuntu-latest@24", "macos-latest@24", "windows-latest@24", ]); const EXPECTED_ALLOWLIST_KEYS = new Set([ - "GHSA-96hv-2xvq-fx4p", - "GHSA-f38q-mgvj-vph7", - "GHSA-wcpc-wj8m-hjx6", - "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici", - "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici", - "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici", - "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici", - "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici", + "GHSA-f38q-mgvj-vph7|protobufjs", ]); function readText(url: URL): string { @@ -97,10 +93,10 @@ function parseIsoDate(value: string): number { return timestamp; } -function minimumNodeVersion(value: string): number { - const match = value.match(/^>=(?\d+)$/); - assert.ok(match?.groups?.major, `unsupported engines.node format: ${value}`); - return Number.parseInt(match.groups.major, 10); +function minimumNodeVersion(value: string): string { + const match = value.match(/^>=(?\d+\.\d+\.\d+)$/); + assert.ok(match?.groups?.version, `unsupported engines.node format: ${value}`); + return match.groups.version; } function runAuditCi(): void { @@ -111,7 +107,53 @@ function runAuditCi(): void { assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); } -test("audit-ci config keeps an expiry-tracked, path-scoped allowlist", () => { +function collectPackagePaths(graph: any, packageName: string): Array<{ path: string; version: string }> { + const found: Array<{ path: string; version: string }> = []; + const visit = (node: any, path: string) => { + for (const [name, dependency] of Object.entries(node?.dependencies ?? {}) as Array<[string, any]>) { + const dependencyPath = `${path} > ${name}`; + if (name === packageName && typeof dependency.version === "string") { + found.push({ path: dependencyPath, version: dependency.version }); + } + visit(dependency, dependencyPath); + } + }; + visit(graph, graph?.name ?? "root"); + return found; +} + +function isVulnerableProtobufVersion(version: string): boolean { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + assert.ok(match, `unexpected protobufjs version: ${version}`); + const [, major, minor, patch] = match.map(Number); + return major < 7 || (major === 7 && (minor < 6 || (minor === 6 && patch <= 2))); +} + +test("Pi 0.80.8 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.devDependencies[name], "0.80.8"); + assert.equal(lock.packages[`node_modules/${name}`]?.version, "0.80.8"); + } + assert.equal(packageJson.devDependencies.typebox, "1.1.38"); + assert.equal(lock.packages["node_modules/typebox"]?.version, "1.1.38"); + + const spawnSource = readText(SPAWN_SOURCE_PATH); + assert.doesNotMatch(spawnSource, /\bAuthStorage\b|\bModelRegistry\b/); + assert.doesNotMatch(spawnSource, /\bauthStorage\s*:|\bmodelRegistry\s*:/); + assert.match(spawnSource, /model:\s*childModel/); + assert.match(spawnSource, /session\.dispose\(\)/); + const rendererSource = readText(RENDERER_SOURCE_PATH); + assert.doesNotMatch(rendererSource, /console\.(?:debug|warn|error|log)\s*\(/); + assert.doesNotMatch(rendererSource, /process\.(?:stdout|stderr)\.write\s*\(/); +}); + +test("audit-ci config keeps an expiry-tracked advisory-module path allowlist", () => { const config = parseAuditConfig(); assert.equal(config.$schema, AUDIT_SCHEMA); assert.equal(config.moderate, true); @@ -120,7 +162,7 @@ test("audit-ci config keeps an expiry-tracked, path-scoped allowlist", () => { assert.deepEqual(new Set(entries.map(([key]) => key)), EXPECTED_ALLOWLIST_KEYS); const today = Date.parse(new Date().toISOString().slice(0, 10) + "T00:00:00Z"); for (const [key, value] of entries) { - assert.match(key, /^GHSA-[a-z0-9-]+(\|.*)?$/); + assert.match(key, /^GHSA-[a-z0-9-]+\|[^|>]+(?:>[^|>]+)*$/); assert.equal(value.active, true); assert.match(value.expiry, /^\d{4}-\d{2}-\d{2}$/); assert.ok(parseIsoDate(value.expiry) >= today, `expired allowlist entry: ${key}`); @@ -128,6 +170,42 @@ test("audit-ci config keeps an expiry-tracked, path-scoped allowlist", () => { } }); +test("the allowlisted vulnerable protobufjs path is reachable only through the exact Pi floor graph", () => { + const npmArgs = ["ls", "protobufjs", "--all", "--json"]; + const npmExecPath = process.env.npm_execpath; + const invocation = npmExecPath + ? [process.execPath, npmExecPath, ...npmArgs].join(" ") + : "npm ls protobufjs --all --json"; + const result = npmExecPath + ? spawnSync(process.execPath, [npmExecPath, ...npmArgs], { + cwd: REPO_ROOT, + encoding: "utf8", + }) + : spawnSync("npm ls protobufjs --all --json", { + cwd: REPO_ROOT, + encoding: "utf8", + shell: true, + }); + const diagnostics = [ + `invocation: ${invocation}`, + `error.stack: ${result.error?.stack ?? "none"}`, + `status: ${String(result.status)}`, + `signal: ${String(result.signal)}`, + `stdout:\n${result.stdout}`, + `stderr:\n${result.stderr}`, + ].join("\n"); + + assert.equal(result.error, undefined, diagnostics); + assert.equal(result.signal, null, diagnostics); + assert.equal(result.status, 0, diagnostics); + const vulnerablePaths = collectPackagePaths(JSON.parse(result.stdout), "protobufjs") + .filter(({ version }) => isVulnerableProtobufVersion(version)); + assert.deepEqual(vulnerablePaths, [{ + path: "pi-agenticoding > @earendil-works/pi-ai > @google/genai > protobufjs", + version: "7.6.1", + }]); +}); + test("workflow keeps the expected matrix and audit/test order", () => { const workflow = readText(WORKFLOW_PATH); const packageJson = parsePackageJson(); diff --git a/tests/unit/dependency-graph-assertions.test.ts b/tests/unit/dependency-graph-assertions.test.ts new file mode 100644 index 0000000..186aa70 --- /dev/null +++ b/tests/unit/dependency-graph-assertions.test.ts @@ -0,0 +1,108 @@ +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 PI_PACKAGES = [ + "@earendil-works/pi-agent-core", + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", + "@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`); + } +}); + +function dependency(version: string, dependencies = {}): object { + return { version, dependencies }; +} + +function graph(piVersion = "0.80.8", typeboxVersion = "1.1.38"): object { + return { + name: "synthetic-install", + version: "1.0.0", + dependencies: { + "@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-tui": dependency(piVersion), + }), + "@earendil-works/pi-tui": dependency(piVersion), + typebox: dependency(typeboxVersion), + }, + }; +} + +test("exact floor accepts a coherent recursive dependency graph", () => { + assert.doesNotThrow(() => assertExactPackageVersions(graph(), { + "@earendil-works/pi-agent-core": "0.80.8", + "@earendil-works/pi-ai": "0.80.8", + "@earendil-works/pi-coding-agent": "0.80.8", + "@earendil-works/pi-tui": "0.80.8", + 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.81.0"; + assert.throws( + () => assertExactPackageVersions(mixedCore, { "@earendil-works/pi-agent-core": "0.80.8" }), + /@earendil-works\/pi-agent-core@0\.80\.8.*0\.81\.0/, + ); + + const mixedPi = graph() as any; + mixedPi.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-ai"].version = "0.81.0"; + assert.throws( + () => assertExactPackageVersions(mixedPi, { "@earendil-works/pi-ai": "0.80.8" }), + /@earendil-works\/pi-ai@0\.80\.8.*0\.81\.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"); + 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; + 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, + ); + + const mixedTypebox = graph("0.99.0", "2.0.0") 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, + ); +}); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index a831cbe..7d00f9b 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -85,8 +85,6 @@ export function createDeferred() { return { promise, resolve }; } -type Handler = (args: any, ctx: any) => any; - export function createTestPI() { const _handlers = new Map(); const _tools = new Map(); @@ -163,6 +161,7 @@ export function createTestPI() { }, getFlag: (name: string) => _flags.get(name), registerMessageRenderer: () => {}, + registerEntryRenderer: () => {}, setLabel: () => {}, unregisterProvider: () => {}, events: { on: () => () => {}, emit: () => {} } as import("@earendil-works/pi-coding-agent").EventBus, @@ -194,8 +193,6 @@ const _testPIVerified: _TestPICoversExtensionAPI = true; // ── Readonly test helpers ──────────────────────────────────────────── -import type registerAgenticodingType from "../../index.js"; // type-only re-export for clients - export type ToolCall = (event: { toolName: string; input?: Record }, ctx: { cwd?: string }) => Promise; /** @@ -254,43 +251,6 @@ export async function withTempHome(run: (homeDir: string) => Promise): Pro } } -export const EMPTY_USAGE = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - -export function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") { - return { - role: "assistant", - content, - api: model.api, - provider: model.provider, - model: model.id, - usage: EMPTY_USAGE, - stopReason, - timestamp: Date.now(), - }; -} - -export function createTestAssistantStream(message: any): any { - return { - async *[Symbol.asyncIterator]() { - yield { type: "done", reason: message.stopReason, message }; - }, - result: async () => message, - }; -} - -export function messageText(message: any): string { - return (message.content ?? []) - .map((block: any) => block.type === "text" ? block.text : JSON.stringify(block)) - .join("\n"); -} - // ── TUI context factory ─────────────────────────────────────────────── export function makeTUICtx( diff --git a/tests/unit/readonly-spawn.test.ts b/tests/unit/readonly-spawn.test.ts index fe767fa..69f4e24 100644 --- a/tests/unit/readonly-spawn.test.ts +++ b/tests/unit/readonly-spawn.test.ts @@ -42,13 +42,17 @@ async function spawnWithCapture( ); } -test("readonly spawn child prompt tells the child it inherits readonly authority", async () => { +test("readonly spawn excludes mutating tools and tells the child it inherits readonly authority", async () => { let prompt = ""; + let childToolNames: string[] = []; - await spawnWithCapture(true, (_config, childPrompt) => { + await spawnWithCapture(true, (config, childPrompt) => { prompt = childPrompt; + childToolNames = config.tools; }); + assert.equal(childToolNames.includes("write"), false); + assert.equal(childToolNames.includes("edit"), false); assert.match(prompt, /inherit readonly authority/i); assert.match(prompt, /\[readonly\] write\/edit blocked/i); assert.match(prompt, /bash writes\/deletions outside temp blocked/i); diff --git a/tests/unit/spawn-event.test.ts b/tests/unit/spawn-event.test.ts index 81808eb..1523ba1 100644 --- a/tests/unit/spawn-event.test.ts +++ b/tests/unit/spawn-event.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { createState } from "../../state.js"; import { createSession, createSubscribableSession, createTestPI, createRenderContext, theme } from "./helpers.js"; import { flushSpawnFrameScheduler } from "../../spawn/renderer.js"; +import { getSingletons } from "../../runtime-singletons.js"; import { registerSpawnTool } from "../../spawn/index.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; @@ -22,6 +23,113 @@ afterEach(() => { h.teardown(); }); +test("test harness captures every forbidden console output level", () => { + for (const level of ["debug", "log", "warn", "error"] as const) { + globalThis.console[level](`sentinel-${level}`); + } + assert.deepEqual(h.warnings.map(({ level }) => level), ["debug", "log", "warn", "error"]); +}); + +test("frame scheduler requeues a throwing target without diagnostics and recovers", () => { + const scheduler = getSingletons().frameScheduler; + let failingFlushes = 0; + let failingRenders = 0; + let healthyFlushes = 0; + let healthyRenders = 0; + const failing = { + flushPendingUpdates() { + failingFlushes++; + if (failingFlushes === 1) throw new Error("throw once"); + }, + clearRenderCache() {}, + flushScheduledRender() { + return { requestRender: () => { failingRenders++; }, restoreAfterFailure: () => true }; + }, + }; + const healthy = { + flushPendingUpdates() { healthyFlushes++; }, + clearRenderCache() {}, + flushScheduledRender() { + return { requestRender: () => { healthyRenders++; }, restoreAfterFailure: () => true }; + }, + }; + + scheduler.markDirty(failing); + scheduler.markDirty(healthy); + flushSpawnFrameScheduler(); + assert.equal(healthyFlushes, 1); + assert.equal(healthyRenders, 1); + assert.equal(failingRenders, 0); + assert.deepEqual(h.warnings, []); + + flushSpawnFrameScheduler(); + assert.equal(failingFlushes, 2); + assert.equal(failingRenders, 1); + assert.deepEqual(h.warnings, []); + scheduler.clear(); +}); + +test("frame scheduler contains invalidate callback failures and continues healthy callbacks", () => { + const scheduler = getSingletons().frameScheduler; + let failingFlushes = 0; + let failingInvalidates = 0; + let healthyInvalidates = 0; + const failing = { + flushPendingUpdates() { failingFlushes++; }, + clearRenderCache() {}, + flushScheduledRender() { + return { + requestRender: () => { + failingInvalidates++; + if (failingInvalidates === 1) throw new Error("invalidate once"); + }, + restoreAfterFailure: () => true, + }; + }, + }; + const healthy = { + flushPendingUpdates() {}, + clearRenderCache() {}, + flushScheduledRender() { + return { requestRender: () => { healthyInvalidates++; }, restoreAfterFailure: () => true }; + }, + }; + + scheduler.markDirty(failing); + scheduler.markDirty(healthy); + assert.doesNotThrow(() => flushSpawnFrameScheduler()); + assert.equal(failingInvalidates, 1); + assert.equal(healthyInvalidates, 1); + assert.deepEqual(h.warnings, []); + + flushSpawnFrameScheduler(); + assert.equal(failingFlushes, 2, "callback failure requeues its component"); + assert.equal(failingInvalidates, 2); + assert.equal(healthyInvalidates, 1); + assert.deepEqual(h.warnings, []); + scheduler.clear(); +}); + +test("frame scheduler creates only one timer for a failed-target recovery frame", (t) => { + const scheduler = getSingletons().frameScheduler; + let scheduledTimers = 0; + t.mock.method(globalThis, "setTimeout", ((callback: () => void) => { + scheduledTimers++; + return { callback, id: scheduledTimers } as any; + }) as typeof setTimeout); + t.mock.method(globalThis, "clearTimeout", (() => {}) as typeof clearTimeout); + const failing = { + flushPendingUpdates() { throw new Error("retry later"); }, + clearRenderCache() {}, + flushScheduledRender() { return undefined; }, + }; + + scheduler.markDirty(failing); + flushSpawnFrameScheduler(); + assert.equal(scheduledTimers, 2, "one initial frame and one recovery frame"); + scheduler.clear(); +}); + test("nested spawn live action tracks tool execution events", () => { const state = createState(); const childSpawnTool = makeChildSpawnTool(state); @@ -126,15 +234,19 @@ test("nested spawn dispose stops event processing", () => { assert.ok(after.every((line: string) => !line.includes("thinking")), `unexpected post-dispose update: ${after.join("\n")}`); }); -test("nested spawn dispose aborts a claimed live child session", () => { +test("nested spawn dispose aborts but does not dispose a claimed live child session", () => { const state = createState(); const childSpawnTool = makeChildSpawnTool(state); let abortCalls = 0; + let disposeCalls = 0; const session = { ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), abort: async () => { abortCalls++; }, + dispose: () => { + disposeCalls++; + }, } as any; state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -152,6 +264,7 @@ test("nested spawn dispose aborts a claimed live child session", () => { component.dispose(); assert.equal(abortCalls, 1); + assert.equal(disposeCalls, 0); assert.equal(state.liveChildSessions.has("tool-call-1"), false); }); @@ -253,6 +366,67 @@ test("nested spawn coalesces same-turn child events into one parent invalidate", assert.ok(lines.some((l: string) => l.includes("file2"))); }); +test("nested spawn retries a throw-once parent invalidate without a new child event", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ + invalidate: () => { + invalidateCalls++; + if (invalidateCalls === 1) throw new Error("invalidate once"); + }, + }), + ); + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + assert.doesNotThrow(() => flushSpawnFrameScheduler()); + assert.equal(invalidateCalls, 1); + + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 2, "failed invalidate retries on the recovery frame"); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 2, "successful retry consumes the queued render"); + assert.deepEqual(h.warnings, []); +}); + +test("nested spawn does not restore a failed invalidate after disposal", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + let component: any; + + component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ + invalidate: () => { + invalidateCalls++; + component.dispose(); + throw new Error("invalidate after disposal"); + }, + }), + ) as any; + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + assert.doesNotThrow(() => flushSpawnFrameScheduler()); + assert.equal(invalidateCalls, 1); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "dispose prevents stale render restoration"); + assert.deepEqual(h.warnings, []); +}); + test("nested spawn ignores child renderer invalidations during parent rebuild", async () => { const state = createState(); const childSpawnTool = makeChildSpawnTool(state); diff --git a/tests/unit/spawn-lifecycle.test.ts b/tests/unit/spawn-lifecycle.test.ts index 611e3d5..b8319f1 100644 --- a/tests/unit/spawn-lifecycle.test.ts +++ b/tests/unit/spawn-lifecycle.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createState, resetState } 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"; +import { executeSpawn, getSpawnCleanupError, registerSpawnTool } from "../../spawn/index.js"; import { flushSpawnFrameScheduler } from "../../spawn/renderer.js"; let h: TestHarness; @@ -22,6 +22,241 @@ afterEach(() => { h.teardown(); }); +test("executeSpawn disposes a normally completed child exactly once", async () => { + const state = createState(); + const pi = createTestPI(); + let disposeCalls = 0; + const session = { + ...createSession([]), + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; + }, + getSessionStats: () => undefined, + dispose: () => { disposeCalls++; }, + }; + + const result = await executeSpawn( + "spawn-1", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work" }, undefined, undefined, "medium", + async () => ({ session: session as any, extensionsResult: undefined as any }), + ); + + assert.equal(result.content[0]?.text, "done"); + assert.equal(disposeCalls, 1); +}); + +test("executeSpawn preserves a primary prompt failure when disposal also fails", async () => { + const state = createState(); + const pi = createTestPI(); + const primary = new Error("prompt failed"); + const cleanup = new Error("dispose failed"); + let disposeCalls = 0; + const session = { + ...createSession([]), + prompt: async () => { throw primary; }, + getSessionStats: () => undefined, + dispose: () => { + disposeCalls++; + throw cleanup; + }, + }; + + const execution = executeSpawn( + "spawn-1", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work" }, undefined, undefined, "medium", + async () => ({ session: session as any, extensionsResult: undefined as any }), + ); + await assert.rejects( + execution, + (error: unknown) => error === primary && getSpawnCleanupError(error, execution) === cleanup, + ); + assert.equal(disposeCalls, 1); +}); + +test("executeSpawn preserves a primitive primary failure and retains its cleanup failure", async () => { + const state = createState(); + const pi = createTestPI(); + const primary = "primitive prompt failure"; + const cleanup = new Error("dispose failed"); + const session = { + ...createSession([]), + prompt: async () => { throw primary; }, + getSessionStats: () => undefined, + dispose: () => { throw cleanup; }, + }; + + const execution = executeSpawn( + "spawn-primitive", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work" }, undefined, undefined, "medium", + async () => ({ session: session as any, extensionsResult: undefined as any }), + ); + let caught: unknown; + try { + await execution; + } catch (error) { + caught = error; + } + + assert.equal(caught, primary, "the primitive primary failure remains authoritative"); + assert.equal(getSpawnCleanupError(caught, execution), cleanup, "cleanup failure remains observable"); +}); + +test("executeSpawn correlates concurrent identical primitive failures with their own cleanup", async () => { + const state = createState(); + const pi = createTestPI(); + const primary = "shared primitive failure"; + const cleanupA = new Error("dispose A failed"); + const cleanupB = new Error("dispose B failed"); + let releaseA!: () => void; + let releaseB!: () => void; + const gateA = new Promise((resolve) => { releaseA = resolve; }); + const gateB = new Promise((resolve) => { releaseB = resolve; }); + const makeSession = (gate: Promise, cleanup: Error) => ({ + ...createSession([]), + prompt: async () => { + await gate; + throw primary; + }, + getSessionStats: () => undefined, + dispose: () => { throw cleanup; }, + }); + + const executionA = executeSpawn( + "spawn-primitive-a", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work A" }, undefined, undefined, "medium", + async () => ({ session: makeSession(gateA, cleanupA) as any, extensionsResult: undefined as any }), + ); + const executionB = executeSpawn( + "spawn-primitive-b", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work B" }, undefined, undefined, "medium", + async () => ({ session: makeSession(gateB, cleanupB) as any, extensionsResult: undefined as any }), + ); + const rejectionA = executionA.catch((error: unknown) => error); + const rejectionB = executionB.catch((error: unknown) => error); + + releaseA(); + assert.equal(await rejectionA, primary); + releaseB(); + assert.equal(await rejectionB, primary); + + assert.equal(getSpawnCleanupError(primary, executionB), cleanupB); + assert.equal(getSpawnCleanupError(primary, executionA), cleanupA); +}); + +test("executeSpawn correlates concurrent identical object failures with their own cleanup", async () => { + const state = createState(); + const pi = createTestPI(); + const primary = new Error("shared object failure"); + const cleanupA = new Error("dispose A failed"); + const cleanupB = new Error("dispose B failed"); + let releaseA!: () => void; + let releaseB!: () => void; + const gateA = new Promise((resolve) => { releaseA = resolve; }); + const gateB = new Promise((resolve) => { releaseB = resolve; }); + const makeSession = (gate: Promise, cleanup: Error) => ({ + ...createSession([]), + prompt: async () => { + await gate; + throw primary; + }, + getSessionStats: () => undefined, + dispose: () => { throw cleanup; }, + }); + + const executionA = executeSpawn( + "spawn-object-a", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work A" }, undefined, undefined, "medium", + async () => ({ session: makeSession(gateA, cleanupA) as any, extensionsResult: undefined as any }), + ); + const executionB = executeSpawn( + "spawn-object-b", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work B" }, undefined, undefined, "medium", + async () => ({ session: makeSession(gateB, cleanupB) as any, extensionsResult: undefined as any }), + ); + const rejectionA = executionA.catch((error: unknown) => error); + const rejectionB = executionB.catch((error: unknown) => error); + + releaseA(); + assert.equal(await rejectionA, primary); + releaseB(); + assert.equal(await rejectionB, primary); + + assert.equal(getSpawnCleanupError(primary, executionB), cleanupB); + assert.equal(getSpawnCleanupError(primary, executionA), cleanupA); +}); + +test("executeSpawn surfaces a disposal-only failure", async () => { + const state = createState(); + const pi = createTestPI(); + const cleanup = new Error("dispose failed"); + const session = { + ...createSession([]), + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; + }, + getSessionStats: () => undefined, + dispose: () => { throw cleanup; }, + }; + + await assert.rejects( + () => executeSpawn( + "spawn-1", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work" }, undefined, undefined, "medium", + async () => ({ session: session as any, extensionsResult: undefined as any }), + ), + (error: unknown) => error === cleanup, + ); +}); + +test("executeSpawn disposes no-output and post-create aborted children", async () => { + for (const mode of ["no-output", "aborted"] as const) { + const state = createState(); + const pi = createTestPI(); + let disposeCalls = 0; + const controller = new AbortController(); + if (mode === "aborted") controller.abort(new Error("stop")); + const session = { + ...createSession([]), + messages: [] as any[], + prompt: async () => {}, + getSessionStats: () => undefined, + dispose: () => { disposeCalls++; }, + }; + await assert.rejects( + () => executeSpawn( + `spawn-${mode}`, pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work" }, mode === "aborted" ? controller.signal : undefined, undefined, "medium", + async () => ({ session: session as any, extensionsResult: undefined as any }), + ), + mode === "aborted" ? /stop/ : /produced no output/, + ); + assert.equal(disposeCalls, 1, mode); + } +}); + +test("executeSpawn disposes a session that resolves after reset invalidates creation", async () => { + const state = createState(); + const pi = createTestPI(); + let resolveFactory!: (value: any) => void; + const factory = new Promise((resolve) => { resolveFactory = resolve; }); + let disposeCalls = 0; + const session = { + ...createSession([]), + getSessionStats: () => undefined, + dispose: () => { disposeCalls++; }, + }; + const execution = executeSpawn( + "spawn-reset", pi as any, { model: { id: "model", provider: "provider" }, cwd: "/tmp" } as any, + state, { prompt: "work" }, undefined, undefined, "medium", async () => factory, + ); + resetState(state); + resolveFactory({ session, extensionsResult: undefined as any }); + await assert.rejects(() => execution, /invalidated by reset/i); + assert.equal(disposeCalls, 1); +}); + test("resetState aborts and clears child session registries", () => { const state = createState(); let abortCalls = 0; diff --git a/tests/unit/spawn-runtime-compatibility.test.ts b/tests/unit/spawn-runtime-compatibility.test.ts new file mode 100644 index 0000000..009aa0e --- /dev/null +++ b/tests/unit/spawn-runtime-compatibility.test.ts @@ -0,0 +1,246 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Value } from "typebox/value"; +import { createState } from "../../state.js"; +import { executeSpawn, registerSpawnTool } from "../../spawn/index.js"; +import { createTestPI } from "./helpers.js"; + +async function runRealChildInvocation(params: { prompt: string; thinking?: "max" }) { + const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-runtime-")); + const cwd = join(tempRoot, "project"); + const agentDir = join(tempRoot, "agent"); + const extensionDir = join(cwd, ".pi", "extensions"); + const sentinel = "AGENTIC_E2E_PROBE_OK"; + const provider = "agentic-e2e"; + const modelId = "agentic-e2e-model"; + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousOpenAiApiKey = process.env.OPENAI_API_KEY; + const previousPiOffline = process.env.PI_OFFLINE; + const previousFetch = globalThis.fetch; + const outboundFetches: string[] = []; + + try { + await mkdir(extensionDir, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(cwd, "package.json"), JSON.stringify({ type: "module" })); + await writeFile( + join(extensionDir, "agentic-e2e-probe.js"), + ` +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +const usage = { + input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; +export default function(pi) { + pi.registerProvider("${provider}", { + api: "agentic-e2e-api", apiKey: "test-key", baseUrl: "http://localhost.invalid", + models: [{ + id: "${modelId}", name: "Agentic E2E Model", reasoning: false, + input: ["text"], cost: usage.cost, contextWindow: 128000, maxTokens: 1024, + }], + streamSimple(model, context) { + globalThis.__agenticE2eStreamCalls = (globalThis.__agenticE2eStreamCalls ?? 0) + 1; + const toolResult = context.messages.find((message) => + message.role === "toolResult" && message.toolName === "agentic_e2e_probe" + ); + const content = toolResult + ? [{ type: "text", text: model.provider + "/" + model.id + ":${sentinel}" }] + : [{ type: "toolCall", id: "probe-call-1", name: "agentic_e2e_probe", arguments: {} }]; + const message = { + role: "assistant", content, api: model.api, provider: model.provider, model: model.id, + usage, stopReason: toolResult ? "stop" : "toolUse", timestamp: Date.now(), + }; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "done", reason: message.stopReason, message }); + stream.end(); + }); + return stream; + }, + }); + pi.registerTool({ + name: "agentic_e2e_probe", label: "Agentic E2E Probe", + description: "Return the deterministic compatibility sentinel.", + parameters: { type: "object", properties: {}, additionalProperties: false }, + async execute() { + globalThis.__agenticE2eProbeCalls = (globalThis.__agenticE2eProbeCalls ?? 0) + 1; + return { content: [{ type: "text", text: "${sentinel}" }], details: {} }; + }, + }); +} +`, + ); + + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.OPENAI_API_KEY = "test-openai-key"; + process.env.PI_OFFLINE = "1"; + globalThis.fetch = (async (input: Parameters[0]) => { + outboundFetches.push(input instanceof Request ? input.url : String(input)); + throw new Error(`offline fixture blocked outbound fetch: ${outboundFetches.at(-1)}`); + }) as typeof fetch; + (globalThis as any).__agenticE2eProbeCalls = 0; + (globalThis as any).__agenticE2eStreamCalls = 0; + const model = { + id: modelId, name: "Agentic E2E Model", api: "agentic-e2e-api", provider, + reasoning: false, input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, maxTokens: 1024, + }; + const pi = createTestPI(); + pi.setToolSource("agentic_e2e_probe", "project"); + pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); + pi.setAllTools(["read", "agentic_e2e_probe", "spawn"]); + registerSpawnTool(pi as any, createState()); + const result = await pi.tools.get("spawn").execute( + `spawn-${params.thinking ?? "inherited"}`, + params, + undefined, + undefined, + { model, cwd }, + ); + return { + result, + expectedText: `${provider}/${modelId}:${sentinel}`, + modelId, + probeCalls: (globalThis as any).__agenticE2eProbeCalls, + streamCalls: (globalThis as any).__agenticE2eStreamCalls, + outboundFetches, + }; + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + if (previousOpenAiApiKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = previousOpenAiApiKey; + if (previousPiOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = previousPiOffline; + globalThis.fetch = previousFetch; + delete (globalThis as any).__agenticE2eProbeCalls; + delete (globalThis as any).__agenticE2eStreamCalls; + await rm(tempRoot, { recursive: true, force: true }); + } +} + +test("exact Pi floor 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); + assert.equal(proof.probeCalls, 1); + assert.equal(proof.streamCalls, 2); + 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 () => { + const proof = await runRealChildInvocation({ + prompt: "Use the agentic_e2e_probe tool and return AGENTIC_E2E_PROBE_OK.", + thinking: "max", + }); + assert.equal(proof.result.content[0].text, proof.expectedText); + assert.equal(proof.result.details.model, proof.modelId); + assert.equal(proof.result.details.thinking, "off", "non-reasoning model clamps requested max to off"); + assert.equal(proof.probeCalls, 1); + assert.equal(proof.streamCalls, 2); + assert.deepEqual(proof.outboundFetches, [], "offline real-child fixture attempted an outbound fetch"); +}); + +test("spawn source uses only the public selected-model child session boundary", async () => { + const source = await readFile(new URL("../../spawn/index.ts", import.meta.url), "utf8"); + assert.doesNotMatch(source, /\bAuthStorage\b|\bModelRegistry\b/); + assert.doesNotMatch(source, /\bauthStorage\s*:|\bmodelRegistry\s*:/); + assert.doesNotMatch(source, /modelRuntime\s*[:.]|as\s+any[^\n]*(?:auth|runtime)/i); + assert.match(source, /model:\s*childModel/); +}); + +test("spawn accepts max and forwards the public ctx.model unchanged", async () => { + const pi = createTestPI(); + const state = createState(); + const model = { id: "selected-model", provider: "selected-provider" }; + const requestedCwd = "/tmp"; + let options: any; + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; + }, + abort: async () => {}, + dispose: () => {}, + getSessionStats: () => undefined, + }; + registerSpawnTool(pi as any, state, async (value: any) => { + options = value; + return { session: session as any, extensionsResult: undefined as any }; + }); + const tool = pi.tools.get("spawn"); + const schemaText = JSON.stringify(tool.parameters); + assert.match(schemaText, /max/); + assert.equal(Value.Check(tool.parameters, { prompt: "work" }), true, "registered schema accepts inherited/default thinking"); + await tool.execute("spawn-max", { prompt: "work", thinking: "max" }, undefined, undefined, { + model, + cwd: requestedCwd, + }); + assert.equal(options.model, model); + assert.equal(options.thinkingLevel, "max"); + assert.equal(options.cwd, requestedCwd); + assert.equal(options.sessionManager.getCwd(), resolve(requestedCwd)); +}); + +test("selected-model creation failure remains authoritative and never attempts a fallback model", async () => { + const pi = createTestPI(); + const state = createState(); + const selectedModel = { id: "transient-model", provider: "transient-provider" }; + const fallbackModel = { id: "fallback-model", provider: "fallback-provider" }; + const sentinel = new Error("selected model unavailable sentinel"); + const attemptedModels: unknown[] = []; + + await assert.rejects( + () => executeSpawn( + "spawn-no-fallback", pi as any, { model: selectedModel, cwd: "/tmp" } as any, state, + { prompt: "work" }, undefined, undefined, "medium", + async (options: any) => { + attemptedModels.push(options.model); + if (options.model === fallbackModel) throw new Error("fallback sentinel reached"); + throw sentinel; + }, + ), + (error: unknown) => error === sentinel, + ); + assert.deepEqual(attemptedModels, [selectedModel]); +}); + +test("a parent-transient selected model fails explicitly in the real child runtime without fallback", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-agenticoding-transient-")); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + try { + await mkdir(cwd, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + process.env.PI_CODING_AGENT_DIR = agentDir; + const pi = createTestPI(); + const state = createState(); + const model = { + id: "transient-model", + name: "Transient Parent Model", + provider: "transient-parent", + api: "transient-parent-api", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 1024, + }; + await assert.rejects( + () => executeSpawn( + "spawn-transient", pi as any, { model, cwd } as any, state, + { prompt: "work" }, undefined, undefined, "medium", + ), + /error|provider|auth|transient|model/i, + ); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 8ad0475..607a1d7 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -1,9 +1,7 @@ import test, { afterEach, beforeEach } from "node:test"; import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { createState, resetState } from "../../state.js"; import { buildChildToolNames, @@ -13,7 +11,7 @@ import { truncateText, } from "../../spawn/index.js"; import { renderSpawnResult } from "../../spawn/renderer.js"; -import { createTestPI, createRenderContext, createSession, createSubscribableSession, messageText, makeTUICtx, theme, createTestAssistantMessage, createTestAssistantStream } from "./helpers.js"; +import { createTestPI, createRenderContext, createSession, theme } from "./helpers.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; let h: TestHarness; @@ -32,120 +30,6 @@ afterEach(() => { h.teardown(); }); -test("agentic e2e spawn child can use active registered non-builtin tool", async () => { - const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-a10-")); - const tempCwd = join(tempRoot, "project"); - const tempAgentDir = join(tempRoot, "agent"); - const extensionDir = join(tempCwd, ".pi", "extensions"); - const sentinel = "AGENTIC_E2E_PROBE_OK"; - const oldAgentDir = process.env.PI_CODING_AGENT_DIR; - const oldOpenAiApiKey = process.env.OPENAI_API_KEY; - const parentRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); - let streamCallCount = 0; - - try { - await mkdir(extensionDir, { recursive: true }); - await mkdir(tempAgentDir, { recursive: true }); - await writeFile(join(tempCwd, "package.json"), JSON.stringify({ type: "module" })); - await writeFile( - join(extensionDir, "agentic-e2e-probe.js"), - ` -export default function(pi) { - pi.registerTool({ - name: "agentic_e2e_probe", - label: "Agentic E2E Probe", - description: "Return the deterministic Story 04 A10 sentinel.", - promptSnippet: "Call agentic_e2e_probe to return the Story 04 A10 sentinel.", - parameters: { type: "object", properties: {}, additionalProperties: false }, - async execute() { - globalThis.__agenticE2eProbeCalls = (globalThis.__agenticE2eProbeCalls ?? 0) + 1; - return { - content: [{ type: "text", text: "${sentinel}" }], - details: { sentinel: "${sentinel}" }, - }; - }, - }); -} -`, - ); - - process.env.PI_CODING_AGENT_DIR = tempAgentDir; - process.env.OPENAI_API_KEY = "test-openai-key"; - (globalThis as any).__agenticE2eProbeCalls = 0; - - parentRegistry.registerProvider("openai", { - name: "Agentic E2E OpenAI-compatible provider", - api: "agentic-e2e-api", - apiKey: "test-openai-key", - baseUrl: "http://localhost:0", - streamSimple: (model: any, context: any) => { - streamCallCount += 1; - if (streamCallCount === 1) { - const promptText = context.messages.map(messageText).join("\n"); - assert.match(promptText, /agentic_e2e_probe/); - assert.match(promptText, new RegExp(sentinel)); - return createTestAssistantStream(createTestAssistantMessage(model, [ - { type: "toolCall", id: "probe-call-1", name: "agentic_e2e_probe", arguments: {} }, - ], "tool_calls")); - } - - const probeResult = context.messages.find((message: any) => - message.role === "toolResult" && - message.toolName === "agentic_e2e_probe" && - messageText(message).includes(sentinel) - ); - const text = probeResult ? sentinel : "AGENTIC_E2E_PROBE_MISSING"; - return createTestAssistantStream(createTestAssistantMessage(model, [{ type: "text", text }])); - }, - models: [{ - id: "agentic-e2e-model", - name: "Agentic E2E Model", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 1024, - }], - }); - const model = parentRegistry.find("openai", "agentic-e2e-model"); - assert.ok(model); - - const pi = createTestPI(); - pi.setToolSource("agentic_e2e_probe", "project"); - pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); - pi.setAllTools(["read", "agentic_e2e_probe", "spawn"]); - const state = createState(); - const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`; - - registerSpawnTool(pi as any, state); - const result = await pi.tools.get("spawn").execute( - "spawn-e2e", - { prompt: childPrompt, thinking: "medium" }, - undefined, - undefined, - { model, cwd: tempCwd }, - ); - - assert.equal(result.content[0].text, sentinel); - assert.equal((globalThis as any).__agenticE2eProbeCalls, 1); - assert.equal(streamCallCount, 2); - } finally { - parentRegistry.unregisterProvider("openai"); - if (oldAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = oldAgentDir; - } - if (oldOpenAiApiKey === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = oldOpenAiApiKey; - } - delete (globalThis as any).__agenticE2eProbeCalls; - await rm(tempRoot, { recursive: true, force: true }); - } -}); - test("spawn execute passes broad active registered tool formula to child session", async () => { const pi = createTestPI(); pi.setToolSource("project_search", "project"); @@ -153,6 +37,7 @@ test("spawn execute passes broad active registered tool formula to child session pi.setActiveTools(["read", "bash", "spawn", "handoff", "project_search", "phantom_tool"]); pi.setAllTools(["read", "bash", "spawn", "handoff", "project_search", "inactive_registered"]); const state = createState(); + const requestedCwd = "/tmp"; let seenConfig: any; const mockFactory = async (config: any) => { @@ -175,12 +60,20 @@ test("spawn execute passes broad active registered tool formula to child session { prompt: "Do the task", thinking: "high" }, undefined, undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, + { model: { id: "mock-model" }, cwd: requestedCwd }, ); assert.equal(seenConfig.model.id, "mock-model"); assert.equal(seenConfig.thinkingLevel, "high"); - assert.equal(seenConfig.cwd, "/tmp"); + assert.equal(seenConfig.cwd, requestedCwd); + assert.equal( + seenConfig.sessionManager.getCwd(), + resolve(requestedCwd), + "child session manager resolves ctx.cwd through Pi's native path semantics", + ); + assert.equal(seenConfig.sessionManager.isPersisted(), false, "child transcript remains in-memory and isolated"); + assert.equal(seenConfig.sessionManager.getSessionFile(), undefined, "child transcript has no parent session file"); + assert.deepEqual(seenConfig.sessionManager.getEntries(), [], "child transcript starts independently empty"); assert.deepEqual( new Set(seenConfig.tools), new Set(["read", "bash", "project_search", "notebook_write", "notebook_read", "notebook_index"]), @@ -188,6 +81,41 @@ test("spawn execute passes broad active registered tool formula to child session assert.deepEqual(seenConfig.customTools.map((tool: any) => tool.name), ["notebook_write", "notebook_read", "notebook_index"]); }); +test("spawn forwards requested thinking and reports the session effective thinking", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "spawn"]); + 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) => { + seenConfig = config; + return { session: session as any }; + }) as any); + + const result = await pi.tools.get("spawn").execute( + "spawn-effective-thinking", + { prompt: "Do the task", thinking: "max" }, + undefined, + (update: any) => updates.push(update), + { model: { id: "non-reasoning-model", reasoning: false }, cwd: "/tmp" }, + ); + + assert.equal(seenConfig.thinkingLevel, "max", "requested thinking is forwarded unchanged"); + assert.equal(updates[0].details.thinking, "off", "running details report Pi's effective thinking"); + assert.equal(result.details.thinking, "off", "final details report Pi's effective thinking"); +}); + test("spawn execute builds prompt with notebook pages and task", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -868,6 +796,7 @@ test("executeSpawn → onUpdate → renderResult chains session ownership", asyn let onUpdateCalled = false; let renderComponent: any = null; + let disposeCalls = 0; const mockFactory = async () => { const session = { messages: [] as any[], @@ -875,6 +804,7 @@ test("executeSpawn → onUpdate → renderResult chains session ownership", asyn session.messages = [{ role: "assistant", content: [{ type: "text", text: "result" }] }]; }, abort: async () => {}, + dispose: () => { disposeCalls++; }, getSessionStats: () => undefined, }; return { session: session as any }; @@ -918,8 +848,11 @@ test("executeSpawn → onUpdate → renderResult chains session ownership", asyn const text = lines.join(" "); assert.ok(text.includes("result"), `expected result in render, got: ${text}`); - // Final execute result is also correct + // Final execute result is also correct, and rendering never co-owns disposal. assert.equal(result.content[0].text, "result"); + assert.equal(disposeCalls, 1); + renderComponent.dispose(); + assert.equal(disposeCalls, 1); }); test("spawn render shows success state when stats are unavailable", () => { @@ -952,6 +885,7 @@ test("spawn execute aborts child session when signal fires during execution", as const state = createState(); let abortCalled = false; + let disposeCalls = 0; let resolvePrompt!: () => void; let promptStarted!: () => void; const started = new Promise((resolve) => { promptStarted = resolve; }); @@ -967,6 +901,7 @@ test("spawn execute aborts child session when signal fires during execution", as abortCalled = true; resolvePrompt(); }, + dispose: () => { disposeCalls++; }, getSessionStats: () => undefined, }; return { session: session as any }; @@ -992,6 +927,7 @@ test("spawn execute aborts child session when signal fires during execution", as assert.equal(state.liveChildSessions.size, 0); assert.equal(result.content[0].text, "aborted mid-flight"); assert.equal(result.details.outcome, "aborted"); + assert.equal(disposeCalls, 1, "mid-prompt abort disposes exactly once"); }); test("spawn renderCall shows prompt preview and thinking level", () => { @@ -1308,6 +1244,82 @@ test("executeSpawn detects stale session before session creation", async () => { assert.equal(state.liveChildSessions.size, 0); }); +test("executeSpawn does not prompt when onUpdate synchronously resets the child epoch", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let promptCalls = 0; + let abortCalls = 0; + let disposeCalls = 0; + + const execution = executeSpawn( + "spawn-1", + pi as any, + { model: { id: "mock-model" }, cwd: "/tmp" } as any, + state, + { prompt: "Do the task" }, + 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, + }), + ); + + await assert.rejects(() => execution, /invalidated by reset/i); + assert.equal(promptCalls, 0); + assert.equal(abortCalls >= 1, true); + assert.equal(disposeCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("executeSpawn does not prompt when onUpdate synchronously aborts the signal", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const controller = new AbortController(); + const reason = new Error("cancelled during update"); + let promptCalls = 0; + let abortCalls = 0; + let disposeCalls = 0; + + const execution = executeSpawn( + "spawn-1", + pi as any, + { model: { id: "mock-model" }, cwd: "/tmp" } as any, + state, + { prompt: "Do the task" }, + 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, + }), + ); + + await assert.rejects(async () => execution, (error) => error === reason); + assert.equal(promptCalls, 0); + assert.equal(abortCalls, 1); + assert.equal(disposeCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + test("executeSpawn aborts stale child when resetState fires during prompt", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -1317,6 +1329,7 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn let resolvePromptStarted!: () => void; const promptStartedPromise = new Promise((r) => { resolvePromptStarted = r; }); let abortCalls = 0; + let disposeCalls = 0; const executePromise = executeSpawn( "spawn-1", @@ -1341,6 +1354,7 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn abortCalls++; rejectPrompt?.(new Error("aborted")); }, + dispose: () => { disposeCalls++; }, getSessionStats: () => undefined, } as any, }), @@ -1360,6 +1374,54 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn assert.equal(abortCalls >= 1, true); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); + assert.equal(disposeCalls, 1, "prompt-reset invalidation disposes exactly once"); +}); + +test("executeSpawn suppresses a successful stale prompt that ignores reset abort", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let resolvePrompt!: () => void; + let promptStarted!: () => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + let abortCalls = 0; + let disposeCalls = 0; + const updates: unknown[] = []; + const session = { + messages: [] as any[], + prompt: async () => { + promptStarted(); + await new Promise((resolve) => { resolvePrompt = resolve; }); + session.messages = [{ role: "assistant", content: [{ type: "text", text: "stale success" }] }]; + }, + abort: async () => { abortCalls++; }, + dispose: () => { disposeCalls++; }, + getSessionStats: () => undefined, + }; + + const execution = executeSpawn( + "spawn-1", + pi as any, + { model: { id: "mock-model" }, cwd: "/tmp" } as any, + state, + { prompt: "Do the task" }, + undefined, + (update) => { updates.push(update); }, + "medium", + async () => ({ session: session as any, extensionsResult: undefined as any }), + ); + + await started; + resetState(state); + resolvePrompt(); + + await assert.rejects(() => execution, /invalidated by reset/i); + assert.equal(abortCalls >= 1, true); + assert.equal(disposeCalls, 1); + assert.equal(updates.length, 1, "no stale completion update is published"); + assert.deepEqual((updates[0] as any).content, []); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); }); test("truncateText respects line limit before byte limit", async () => {