diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 297f30fb422..37dc4fee835 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -339,3 +339,47 @@ skkdevcraft pr PierrunoYT pr zhichli pr + +wesleyzhangwq pr + +dgokeeffe pr + +vipentti pr + +midastruth pr + +Maximo-Guk pr + +bigoldcat123 pr + +johnatbasicas pr + +powerfooI pr + +yearth pr + +pablasso pr + +bilby91 pr + +giannisCKS pr + +Panoplos pr + +haoyongchun1125-maker pr + +gwokhou pr + +gaoyk19 pr + +cad0p pr + +Jaaneek pr + +CaiJichang212 pr + +Mallikarjun-0 pr + +wutongyuonce pr + +Terminator666666 pr diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 7927820663a..37facbe2f88 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -128,9 +128,82 @@ jobs: if-no-files-found: error retention-days: 14 + smoke-test-binaries: + runs-on: ${{ matrix.runner }} + needs: build + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-latest + - macos-latest + - windows-latest + permissions: + actions: read + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Download binary archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-assets-${{ env.RELEASE_TAG }} + path: release-assets + + - name: Extract current-platform binary + id: binary + shell: bash + run: | + set -euo pipefail + + runtime_platform="$(node -p '`${process.platform}-${process.arch}`')" + case "${runtime_platform}" in + darwin-arm64|darwin-x64|linux-arm64|linux-x64) + platform="${runtime_platform}" + archive="release-assets/pi-${platform}.tar.gz" + root="extracted/pi" + binary="${root}/pi" + ;; + win32-arm64) + platform="windows-arm64" + archive="release-assets/pi-${platform}.zip" + root="extracted" + binary="${root}/pi.exe" + ;; + win32-x64) + platform="windows-x64" + archive="release-assets/pi-${platform}.zip" + root="extracted" + binary="${root}/pi.exe" + ;; + *) + echo "::error::Unsupported runner platform ${runtime_platform}" + exit 1 + ;; + esac + + mkdir -p extracted + if [[ "${archive}" == *.zip ]]; then + export ARCHIVE_PATH="$(cygpath -w "${archive}")" + export DESTINATION_PATH="$(cygpath -w extracted)" + powershell.exe -NoProfile -NonInteractive -Command \ + 'Expand-Archive -LiteralPath $env:ARCHIVE_PATH -DestinationPath $env:DESTINATION_PATH -Force' + else + tar -xf "${archive}" -C extracted + fi + echo "root=${root}" >> "${GITHUB_OUTPUT}" + echo "binary=${binary}" >> "${GITHUB_OUTPUT}" + + - name: Smoke-test binary + shell: bash + run: | + "${{ steps.binary.outputs.binary }}" --help + "${{ steps.binary.outputs.binary }}" --version + stage-github-release: runs-on: ubuntu-latest - needs: build + needs: + - build + - smoke-test-binaries permissions: actions: read contents: write @@ -270,11 +343,53 @@ jobs: - name: Publish npm packages run: node scripts/publish.mjs + announce-pi-dev-release: + runs-on: ubuntu-latest + needs: publish-npm + environment: pi-model-upload + concurrency: + group: announce-pi-dev-release + cancel-in-progress: false + permissions: + contents: read + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} + AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: 'true' + R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Verify AWS CLI + run: aws --version + + - name: Announce verified release on pi.dev + run: | + node scripts/publish-release-announcement.mjs \ + --version "${RELEASE_TAG#v}" \ + --bucket pi-artifacts \ + --endpoint "$R2_ENDPOINT" \ + --installer-package-json packages/coding-agent/install-lock/package.json \ + --installer-package-lock packages/coding-agent/install-lock/package-lock.json + publish-github-release: runs-on: ubuntu-latest needs: - stage-github-release - publish-npm + - announce-pi-dev-release permissions: contents: write env: @@ -305,8 +420,9 @@ jobs: - build - stage-github-release - publish-npm + - announce-pi-dev-release - publish-github-release - if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.publish-github-release.result != 'success') }} + if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.announce-pi-dev-release.result != 'success' || needs.publish-github-release.result != 'success') }} permissions: contents: write env: diff --git a/.pi/prompts/wr.md b/.pi/prompts/wr.md index 8f05bd8fba8..5d9b11bbc33 100644 --- a/.pi/prompts/wr.md +++ b/.pi/prompts/wr.md @@ -38,3 +38,4 @@ Constraints: - Do not open a PR unless I explicitly ask. - If this is not GitHub issue or PR work, do not post a GitHub comment. - If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask. +- When working against a branch other than `main`, skip the changelog. diff --git a/AGENTS.md b/AGENTS.md index 4c3a12d99e1..765f376b87a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,9 @@ - No emojis in commits, issues, PR comments, or code - No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") - Technical prose only, be direct +- Use concise, clear, simple language. Define unavoidable jargon before using it. +- Explain non-trivial designs and problems as: problem, concrete example or short trace, then solution. State why the solution is necessary and distinguish it from optional complexity. +- Prefer concrete behavior and small illustrations over abstract summaries, dense terminology, or unexplained lists of changes. - When the user asks a question, answer it first before making edits or running implementation commands. - When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed. @@ -27,7 +30,9 @@ - After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests. - Never run `npm run build` or `npm test` unless requested by the user. -- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`. +- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: + - Vitest: `node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/specific.test.ts` + - `packages/tui` (`node:test`): `node --test test/specific.test.ts` - If you create or modify a test file, run it and iterate on test or implementation until it passes. - For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens. - Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `-.test.ts`. @@ -112,6 +117,7 @@ Rules: - All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them. - Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them. +- Do not create changelog entries when working on a branch other than `main` or pull request Attribution: @@ -154,9 +160,9 @@ Attribution: The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. -4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. +4. **CI verifies and announces the npm release**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. After publishing, `announce-pi-dev-release` verifies every public workspace package resolves at the exact release version and that its npm tarball is available, then writes the verified release marker to R2. `pi.dev/api/latest-version` reads that marker; it must never announce a release from npm before this job succeeds. -5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version. +5. **If CI publish or announcement fails**: inspect the failed job. The publish helper is idempotent and skips package versions already present on npm; the announcement job rechecks availability before updating the R2 marker. Rerun the failed job or workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version. ## User Override diff --git a/SECURITY.md b/SECURITY.md index ddd75e14d1f..e54ba8be6b0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ This document should guide you about understanding the security concept behind Pi and also where the boundaries are. In general Pi is a coding agent that runs locally within the security boundary -of the user that is running it. It's the responsibiltiy of the user to monitor +of the user that is running it. It's the responsibility of the user to monitor its operations or to contain it within a container, virtual machine or other Sandbox solution. @@ -42,7 +42,7 @@ reports and coordinate disclosure as appropriate. ## Scope Security issues in the distributed packages, command-line tools, APIs, and -repository code are in scope as well as earendil operated infrastricture +repository code are in scope as well as earendil operated infrastructure on `pi.dev`. ## Out Of Scope diff --git a/package-lock.json b/package-lock.json index 5583b562462..37842d14c45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "@typescript/native-preview": "7.0.0-dev.20260120.1", "esbuild": "0.28.1", "husky": "9.1.7", - "jiti": "2.7.0", "shx": "0.4.0", "tsx": "4.22.1", "typescript": "5.9.3" @@ -1495,26 +1494,6 @@ "node": ">= 10" } }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "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/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -1584,24 +1563,6 @@ "node": ">= 8" } }, - "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==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "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==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -2111,13 +2072,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", - "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3226,23 +3180,6 @@ "dev": true, "license": "MIT" }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -4014,15 +3951,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -4044,9 +3972,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -4191,13 +4119,10 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" @@ -4277,22 +4202,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5418,29 +5327,20 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", "vitest": "4.1.9" @@ -5456,38 +5356,19 @@ "extraneous": true, "license": "MIT" }, - "packages/agent/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/agent/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", - "@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", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -5495,7 +5376,7 @@ "pi-ai": "dist/cli.js" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "canvas": "3.2.3", "vitest": "4.1.9" }, @@ -5503,29 +5384,12 @@ "node": ">=22.19.0" } }, - "packages/ai/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/ai/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/client": { "name": "@earendil-works/pi-client", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", @@ -5537,19 +5401,18 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -5563,14 +5426,12 @@ "yaml": "2.9.0" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "devDependencies": { "@types/cross-spawn": "6.0.6", - "@types/diff": "7.0.2", "@types/hosted-git-info": "3.0.5", - "@types/ms": "2.1.0", - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@types/proper-lockfile": "4.1.4", "@types/semver": "7.7.1", "shx": "0.4.0", @@ -5586,32 +5447,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.84.0", + "version": "0.84.3", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.84.0" + "version": "0.84.3" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.84.0", + "version": "0.84.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.14.0", + "version": "1.14.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.84.0", + "version": "0.84.3", "dependencies": { "ms": "2.1.3" }, @@ -5663,56 +5524,22 @@ "extraneous": true, "license": "MIT" }, - "packages/coding-agent/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/coding-agent/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/evals": { "name": "@earendil-works/pi-evals", - "version": "0.84.0", + "version": "0.84.3", "devDependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-coding-agent": "^0.84.0", - "@types/node": "24.12.4", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-coding-agent": "^0.84.3", + "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", "vitest": "4.1.9", "vitest-evals": "0.15.0" } }, - "packages/evals/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/evals/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/protocol": { "name": "@earendil-works/pi-protocol", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -5727,11 +5554,11 @@ }, "packages/server": { "name": "@earendil-works/pi-server", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", @@ -5743,11 +5570,11 @@ }, "packages/session-backends/sqlite-node": { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0" + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", @@ -5759,36 +5586,19 @@ }, "packages/telemetry": { "name": "@earendil-works/pi-telemetry", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" } }, - "packages/telemetry/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/telemetry/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/package.json b/package.json index 42b69c65ed7..e981d3ab5ac 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "@typescript/native-preview": "7.0.0-dev.20260120.1", "esbuild": "0.28.1", "husky": "9.1.7", - "jiti": "2.7.0", "shx": "0.4.0", "tsx": "4.22.1", "typescript": "5.9.3" diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4d297adb037..c057c322855 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +### Fixed + +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays ([#7835](https://github.com/earendil-works/pi/issues/7835)). +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). + +## [0.84.2] - 2026-08-14 + +### Fixed + +- Fixed `streamProxy()` dropping finalized tool-call metadata such as OpenAI Responses namespaces ([#7709](https://github.com/earendil-works/pi/issues/7709)). + +## [0.84.1] - 2026-08-07 + +### Added + +- Added `BeforeToolCallResult.terminate` so blocked tool calls can participate in the existing batch early-termination rule ([#7715](https://github.com/earendil-works/pi/pull/7715) by [@muyiyr](https://github.com/muyiyr)). + +### Fixed + +- Fixed `Agent.reset()` clearing transcript and runtime state during active runs; it now rejects until the agent is idle ([#7717](https://github.com/earendil-works/pi/pull/7717) by [@wesleyzhangwq](https://github.com/wesleyzhangwq)). + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/agent/README.md b/packages/agent/README.md index d26229efa7e..879a4742916 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -119,9 +119,9 @@ In parallel mode, tool completion events follow tool completion order, but persi The mode can be set globally via `toolExecution` in the agent config, or per-tool via `executionMode` on `AgentTool`. If any tool call in a batch targets a tool with `executionMode: "sequential"`, the entire batch executes sequentially regardless of the global setting. -The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted. +The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution and attach `terminate: true` to the blocked result. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted. -Tools can also return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally. +Tools, blocked `beforeToolCall` results, and `afterToolCall` overrides can return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally. The `Agent` class accepts `shouldStopAfterTurn` in `AgentOptions`. Low-level loop callers can set the same hook in `AgentLoopConfig`: @@ -213,7 +213,7 @@ const agent = new Agent({ // Preflight each tool call after args are validated. Can block execution. beforeToolCall: async ({ toolCall, args, context }) => { if (toolCall.name === "bash") { - return { block: true, reason: "bash is disabled" }; + return { block: true, reason: "bash is disabled", terminate: true }; } }, @@ -453,7 +453,7 @@ execute: async (toolCallId, params, signal, onUpdate) => { Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`. -Return `terminate: true` from `execute()` or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results. +Return `terminate: true` from `execute()`, a blocked `beforeToolCall`, or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results. ## Proxy Usage diff --git a/packages/agent/docs/harness-v2-test-matrix.md b/packages/agent/docs/harness-v2-test-matrix.md deleted file mode 100644 index 383d6eb1ca5..00000000000 --- a/packages/agent/docs/harness-v2-test-matrix.md +++ /dev/null @@ -1,195 +0,0 @@ -# Harness v2 promotion test matrix - -QA1 inventory for tests removed by `44289550a feat(agent): promote durable harness API`. - -This document maps each removed test case to one of the QA1 outcomes: - -- **Covered** — the behavior is already covered by v4 conformance or another current test. -- **Ported** — the case was rewritten under the v4 API or moved to the SQLite package. -- **Inapplicable** — the old API, implementation detail, or compatibility path was intentionally deleted. -- **Uncovered** — the behavior may still be required but cannot be ported until a named implementation package lands. QA revisits it afterward; implementation packages derive their own tests from the design and do not use this matrix. - -No production or test changes are part of QA1. - -## Summary - -| Area | Removed cases | Status | -|---|---:|---| -| Harness runtime and stream behavior | 37 | Mostly uncovered by design while `AgentHarness` is scaffolded; assigned to H/L/I/C/N packages. Scaffold-safe configuration is covered by F0. | -| Branch query and corruption behavior | 6 | Core query semantics are covered; bounded SQLite validation gaps were ported by QA2, and remaining JSONL corruption gaps are assigned to J3. | -| Compaction helper behavior | 2 | Covered by current compaction/context tests. | -| Memory/SQLite v4 conformance entrypoints | 3 | Ported to `packages/agent/test/harness/session/*` and `packages/session-backends/sqlite-node/test/conformance.test.ts`. | -| Repository/backend lifecycle and JSONL behavior | 38 | Most covered by v4 conformance or J0–J2; QA2 lifecycle/query audits are resolved, with remaining crash/corruption/v3 gaps assigned to J3–J5. | -| Session aggregate/context behavior | 17 | Covered by v4 conformance plus current context tests. | -| SQLite search | 1 | Ported to SQLite package search tests; old scanning backend is inapplicable. | - -## Harness runtime and stream tests - -Removed files: - -- `packages/agent/test/harness/agent-harness-stream.test.ts` -- `packages/agent/test/harness/agent-harness.test.ts` - -The promotion intentionally replaced the behavior-complete legacy harness with the v2 scaffold. Runtime operation methods must reject with `HarnessNotImplemented` until their owning packages land; see the public method ownership table in `harness-v2.md` section 20. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| snapshots stream options before provider request hooks | Uncovered | H1/H4 after I1/I4/L3: assistant request execution must snapshot stream options and run request hooks. | -| chains provider request patches and supports deletion semantics | Uncovered | I1 + I4 own hook aggregation/effect adapter; H1 covers run integration. | -| uses updated stream options for save-point snapshots without mutating the active request | Uncovered | H3/H4/H6: checkpoint/deferred configuration behavior and tool continuation snapshots. | -| chains provider payload hooks | Uncovered | I1 + I4, then H1 request integration. | -| constructs directly and exposes queue modes | Covered / Inapplicable | Direct construction is intentionally replaced by `AgentHarness.create()`. Queue-mode defensive configuration is covered by `agent-harness-scaffold.test.ts` (`keeps scaffold-safe configuration as defensive copies`). | -| rejects waiting before shutdown is requested | Inapplicable | Legacy shutdown API was deleted. `waitForIdle` belongs to H5 and currently rejects by F0 scaffold tests. | -| shuts down active work permanently and idempotently | Uncovered | H5 owns close/abort/wait settlement. | -| allows a hook to request shutdown without deadlocking its operation | Uncovered | H5 after I1/I2 owns close/abort settlement from hooks/events. | -| allows a subscriber to request shutdown without deadlocking its operation | Uncovered | H5 after I2 owns passive-listener settlement. | -| does not start a provider request when shutdown occurs during before_agent_start | Uncovered | H1/H5 after I1: before-run hook cancellation/close behavior. | -| aborts and awaits active compaction without persisting its result | Uncovered | H5 + C1: abort reconciliation for compaction. | -| aborts and awaits active tree navigation without moving the session leaf | Uncovered | H5 + N1: abort reconciliation for navigation. | -| does not treat concurrent mutations as active operations | Uncovered | I3 lane mutation line and H4 deferred writes/configuration. | -| awaits concurrent idle session mutations before shutdown resolves | Uncovered | I3/H5: mutation-line settlement before close. | -| shuts down an idle harness without modifying its durable session | Covered / Uncovered | F0 covers scaffold `close()` and record-free create. H5 must cover durable runtime close with no writes. | -| drains one queued steering message at a time and emits queue updates | Uncovered | H3 queues/checkpoints/events. | -| appends before_agent_start messages and persists them | Uncovered | H1 `before_run` initial message capture. | -| abort clears steer and follow-up queues but preserves next-turn messages | Uncovered | H5 durable abort queue draining; H3 owns queue state. | -| drains follow-up messages one at a time after the agent would otherwise stop | Uncovered | H3 checkpoint finish-boundary conditionals. | -| settles thrown hook failures with persisted assistant error messages | Uncovered | I1 hook isolation + H1/H2 terminal failure entries. | -| refreshes model, thinking level, resources, system prompt, and active tools at save points | Uncovered | H3/H4/H6 checkpoint and deferred configuration behavior. | -| orders pending listener session writes after agent-emitted messages | Uncovered | H4 deferred writes plus I2 listener delivery. | -| waitForIdle waits for external run settlement and awaited listeners | Uncovered | H5 after I2. | -| runs tool_call and tool_result hooks through the direct loop | Uncovered | L2/L3 tool phases, I1 hooks, H6 durable tool events. | -| passes a static application context to harness tools | Uncovered | I4 effect-context threading and H6 tool execution. | -| resolves async tool context providers for each turn snapshot | Uncovered | I4/H6. | -| persists generated compaction usage | Uncovered | C1 manual compaction operation. | -| persists hook-provided compaction usage | Uncovered | C1 with I1 hooks. | -| retries transient compaction errors and emits retry events | Uncovered | C1/C3 retry and event integration. | -| does not retry non-retryable compaction errors | Uncovered | C1/C3. | -| exhausts transient compaction retries after maxRetries failures | Uncovered | C1/C3. | -| retries transient branch summary errors and emits retry events | Uncovered | N1 navigation/branch-summary resume and retry behavior. | -| persists generated branch summary usage | Uncovered | N1. | -| persists hook-provided branch summary usage | Uncovered | N1 with I1 hooks. | -| preserves app tool types for getters and update events | Covered / Uncovered | Getter defensive copies are covered by F0 scaffold tests. Persisted active-tool selection and update events belong to H4/O1. | -| validates constructor tool names | Uncovered | H4 owns tool registry plus persisted active-tool validation. | -| preserves app resource types for getters and update events | Covered / Uncovered | Getter defensive copies are covered by F0 scaffold tests. Resource update events belong to O1/H0 event wiring. | - -## Branch query and corruption tests - -Removed file: `packages/agent/test/harness/branch-query.test.ts`. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| provides identical in-memory query semantics | Covered | v4 backend conformance: `supports bounded filtered and cursor-based queries`; memory conformance runner. | -| rejects corrupt parent chains in array-backed readers | Covered / Inapplicable | The old array-backed reader type was deleted. The v4 JSONL equivalents are covered by `jsonl.test.ts`: `rejects an imported entry that references a missing parent` covers missing-parent replay, and `rejects a lane-bound entry that does not chain to the lane leaf` covers lane-tail parent chaining. Cycle parity is inapplicable for v4 JSONL replay because entries cannot reference future parents during sequential replay. | -| provides identical JSONL query semantics | Covered | J1/J2 JSONL v4 storage/repository tests plus backend conformance cover normal bounded branch queries. | -| does not decode SQLite branch entries outside query bounds | Covered | Ported to `packages/session-backends/sqlite-node/test/branch-query.test.ts`: `does not decode entries outside bounded branch queries` corrupts an out-of-bounds payload and branch-cache membership, proves bounded reads decode only requested rows, and proves an unbounded read still rejects the broken chain. | -| validates SQLite entries before filtering and limiting branch results | Covered | Ported to `packages/session-backends/sqlite-node/test/branch-query.test.ts`: `validates entries before branch query filters and limits` proves corrupt in-window entries reject before `type`, `customType`, and `limit` filtering can hide them. | -| does not validate SQLite ancestors beyond newest-first stop bounds | Covered | Ported to `packages/session-backends/sqlite-node/test/branch-query.test.ts`: `does not validate ancestors beyond newest-first stop bounds` proves `stopAtId` and `stopAtType` reads can return a valid suffix while unbounded reads still reject missing-parent and cyclic ancestor corruption. | - -## Compaction helper tests - -Removed cases from `packages/agent/test/harness/compaction.test.ts` during promotion. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| falls back to firstKeptEntryId when a compaction has no retained tail | Covered | Current `session/context.test.ts` covers empty `retainedTail` context behavior; current compaction tests cover cut-point and retained-tail preparation. | -| prepares custom and branch summary entries for summarization | Covered | Current `compaction.test.ts` covers token estimation across custom, compaction, and branch-summary roles; `session/context.test.ts` covers custom projection and branch-summary context. | - -## v4 conformance entrypoint tests - -Removed/renamed files: - -- `packages/agent/test/harness/experimental/session/memory.test.ts` -- `packages/agent/test/harness/experimental/session/sqlite.test.ts` - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| experimental memory conformance dynamic cases | Ported | `packages/agent/test/harness/session/memory.test.ts` runs the current v4 backend conformance suite. | -| uses one injectable id generator across lane views | Covered | `packages/agent/test/harness/session/memory.test.ts` keeps this focused v4 memory case. | -| experimental SQLite conformance dynamic cases | Ported | `packages/session-backends/sqlite-node/test/conformance.test.ts` runs the current v4 backend conformance suite. | - -## Repository/backend lifecycle and JSONL tests - -Removed files: - -- `packages/agent/test/harness/repo.test.ts` -- `packages/agent/test/harness/session-backends.test.ts` - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| opens, deletes, and forks by metadata (memory) | Covered | v4 conformance: `creates lists and opens sessions`, `deletes sessions idempotently`, fork cases. | -| delegates full-session fork selection without opening the source | Inapplicable | Old repository optimization was deleted; v4 fork behavior is covered by conformance. | -| retains the opened aggregate instead of reloading for scoped reads | Inapplicable | Old aggregate caching detail was deleted with the legacy repository. | -| builds context from the branch storage without loading complete history | Inapplicable / Covered | Old branch-storage optimization was deleted; v4 context behavior is covered by `session/context.test.ts`. | -| rejects repository operations and session writes after disposal | Covered / Inapplicable | The v4 core `SessionRepo` contract has no disposable state, and the in-memory/JSONL repos do not implement permanent disposal. SQLite disposal is resource release rather than repo poisoning; `packages/session-backends/sqlite-node/test/repository.test.ts` covers the remaining applicable behavior in `closes active sessions when the repository is disposed`, proving active session writes reject after repository disposal. | -| supports lexical ownership with await using | Inapplicable | The old test covered permanent disposal on the deleted in-memory repository. The v4 core `SessionRepo` contract has no disposable surface, and memory/JSONL repos do not implement lexical ownership. SQLite `await using` is resource cleanup rather than repo poisoning; active-session closure is covered by `closes active sessions when the repository is disposed` in `packages/session-backends/sqlite-node/test/repository.test.ts`. | -| serializes conflicting create and fork destinations | Uncovered / J3 | The old test covered JSONL backend-wide serialization for concurrent create/create and create/fork operations targeting the same id. V4 intentionally removed global repository serialization, but the remaining format-4 lifecycle/concurrency question is whether conflicting destination creation can duplicate files or silently overwrite; assign to J3 lifecycle/concurrency edge cases. | -| encodes custom session IDs used in filenames | Covered | J2 JSONL repository lifecycle validates file-safe ids; `jsonl.test.ts` rejects invalid coding-agent filenames. | -| allows appends to different sessions to run concurrently | Covered | J2/v4 repository conformance and JSONL concurrent write tests cover accepted concurrent writes without the old keyed queue. | -| caps concurrent operations across JSONL sessions at four by default | Inapplicable | Old JSONL keyed-operation-queue implementation detail was deleted. | -| allows overriding the JSONL concurrency limit | Inapplicable | Old JSONL keyed-operation-queue implementation detail was deleted. | -| rejects invalid JSONL concurrency limits | Inapplicable | Old `maxConcurrentOperations` configuration was deleted with the JSONL keyed-operation queue. | -| releases JSONL concurrency capacity after an operation fails | Inapplicable | Old JSONL keyed-operation-queue implementation detail was deleted. | -| serializes appends to the same session | Covered | v4 single-writer/session mutation conformance and JSONL shared-sequence tests. | -| uses listing as a barrier between accepted session operations | Inapplicable | The old test covered deleted JSONL `KeyedOperationQueue.enqueueBarrier()` behavior. V4 JSONL intentionally does not retain created/opened storages in the repository and does not serialize repository operations; `harness-v2.md` says callers must await operations with ordering dependencies, so no listing barrier should be restored. The replacement serialization invariant is per opened session storage and is already covered by backend conformance `linearizes concurrent writes across two lanes` plus JSONL-specific `persists concurrent cross-lane writes in shared sequence order`. | -| waits for every accepted session operation during disposal | Inapplicable | The old test covered deleted JSONL backend-wide disposal and `KeyedOperationQueue.drain()` behavior. V4 JSONL repos are not disposable and do not retain opened storages, so there is no repo-wide set of accepted operations to drain. The replacement per-session append serialization is already covered by backend conformance `linearizes concurrent writes across two lanes` and JSONL-specific `persists concurrent cross-lane writes in shared sequence order`; harness close/recovery semantics are owned by H5/O3, not repository disposal. | -| waits for accepted appends before disposal and rejects later writes | Inapplicable | The old test covered deleted JSONL repository disposal: drain accepted appends, enter a permanent disposed state, then reject later writes through existing sessions. V4 JSONL repos are not disposable, do not retain opened storages, and have no repo-level closed state. Per-session append serialization remains covered by backend conformance `linearizes concurrent writes across two lanes` and JSONL-specific `persists concurrent cross-lane writes in shared sequence order`; close/drain/reject-after-close semantics belong to harness H5/O3, not `SessionRepo` disposal. | -| parses once when opened and retains state across appends | Inapplicable | Old JSONL in-memory aggregate implementation detail; v4 correctness is covered by reopen/shared-sequence tests. | -| collects sessions below encoded cwd directories and lists by cwd | Covered | J2 metadata lifecycle and listing tests cover v4 JSONL metadata and cwd filtering. | -| fails loudly when listing a malformed session file | Uncovered | J3 owns JSONL crash/corruption behavior for malformed files. | -| rejects a missing active leaf when opened | Uncovered | J3 owns JSONL missing-reference rejection. SQLite equivalent is covered in `repository.test.ts`. | -| opens, deletes, and forks by metadata (JSONL) | Covered | J2 JSONL repo conformance. | -| persists header metadata through create, list, and fork | Covered | J0 codec and J2 repository metadata tests. | -| repository disposal closes its owned storage | Covered / Inapplicable | Old in-memory repo disposal is inapplicable because v4 memory/JSONL repos are not disposable and do not own returned session storage lifetimes. SQLite is the only disposable repository because it owns DB/lease resources; active-session closure is covered by `closes active sessions when the repository is disposed`, and DB close behavior is covered by existing SQLite connection lifecycle tests. | -| owns leaf navigation, labels, names, stats, and branch traversal | Covered | v4 conformance covers lanes, latest facts, labels, statistics, and branch queries. | -| serializes concurrent appends into one parent chain | Covered | v4 conformance `linearizes concurrent writes across two lanes`; JSONL storage shared-sequence tests. | -| includes assistant and summary usage in statistics | Covered | v4 conformance `keeps latest-value facts and computes ledger statistics across lanes`, JSONL storage, and SQLite repository statistics tests. | -| stops branch traversal at retained-tail compaction | Covered / Inapplicable | Branch-query stop semantics are still required outside context projection and are covered explicitly by backend conformance `supports bounded filtered and cursor-based queries` via `findEntriesOnBranch({ stopAtType: "compaction" })` across memory, JSONL, and SQLite. Retained-tail materialization is covered by context test `starts at the latest compaction and materializes its retained tail`. The old implicit `getBranch()` auto-stop-at-retained-tail-compaction behavior is inapplicable because v4 uses explicit branch bounds plus context projection. | -| writes headers and entries and reopens the aggregate | Covered | J1/J2 JSONL storage/repository tests. | -| fails loudly for malformed headers and entries | Covered / J3 | J3 owns malformed physical file behavior; current JSONL tests already cover malformed tail/middle lines. | -| enforces entry uniqueness and does not recreate deleted files | Covered | v4 conformance rejects duplicate ids; J2 lifecycle covers delete/reopen behavior. | -| scopes entry uniqueness to the session path | Covered | v4 repository/session isolation conformance. | -| rejects non-object header metadata | Uncovered | J3/J4 should cover malformed JSONL header metadata for format-4 and v3 normalization. | - -## Session aggregate and context tests - -Removed file: `packages/agent/test/harness/session.test.ts`. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| appends messages and builds context in order | Covered | v4 conformance appends entries in parent/sequence order; `session/context.test.ts` covers context projection. | -| reads entries forward from the requested sequence | Covered | v4 conformance `supports bounded filtered and cursor-based queries`. | -| tracks model and thinking level changes | Covered | Current `compaction.test.ts` built-context case covers model/thinking changes; R2 reducer tests cover effective configuration. | -| supports branching by moving the leaf and appending a new branch | Covered | v4 conformance lane isolation and lane move cases. | -| supports moving the leaf to root | Covered | v4 conformance lane lifecycle/targets. | -| reconstructs compaction summaries in context | Covered | `session/context.test.ts` starts at latest compaction and materializes retained tail. | -| supports moving with branch summary entries in context | Covered | `session/context.test.ts` includes branch summary context behavior. | -| persists compaction usage | Covered | v4 conformance statistics plus JSONL/SQLite statistics tests. | -| persists branch summary usage | Covered | v4 conformance statistics plus JSONL/SQLite statistics tests. | -| supports custom message entries in context | Covered | `session/context.test.ts` custom projection coverage. | -| keeps custom entries in context entries but omits them from messages by default | Covered | `session/context.test.ts` custom projection/default omission coverage. | -| projects custom entries with configured custom-entry projectors | Covered | `session/context.test.ts` custom projector coverage. | -| applies context entry transforms after default compaction selection | Covered | `session/context.test.ts` transform-after-compaction-boundary coverage. | -| normalizes session names | Covered | v4 conformance latest-value facts; JSONL metadata tests cover name metadata. | -| supports labels and session info entries without affecting context | Covered | v4 conformance facts/labels plus `session/context.test.ts` context projection. | -| rejects labels for missing entries | Covered | v4 conformance `keeps latest-value facts and computes ledger statistics across lanes` includes missing-label rejection. | -| persists leaf changes and appended entries through the backend | Covered | v4 conformance lane moves, reopen/list/fork cases across memory/SQLite/JSONL. | - -## SQLite search test - -Removed case from `packages/agent/test/harness/sqlite-node.test.ts`. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| searches canonical session entries by scanning | Ported / Inapplicable | Search moved to `packages/session-backends/sqlite-node/test/search.test.ts` using FTS5. The old scanning-search backend is intentionally deleted. | - -## Implementation prerequisites for the final QA pass - -These packages must land before QA3 can re-evaluate the uncovered rows above. They do not use this matrix as their test plan. - -- **QA2**: completed storage/query audit and ports for bounded-query corruption/validation behavior, repository/session disposal lifecycle, listing/disposal barriers, and branch-query retained-tail semantics outside context projection. -- **J3**: JSONL malformed file, torn-tail, missing-reference, and lifecycle/concurrency edge cases. -- **J4/J5**: v3 read-only normalization and first-write conversion; include malformed v3/header metadata cases. -- **I1/I2/I3/I4/L1-L3**: hook/event/mutation/effects/loop primitive coverage required before runtime harness tests can return. -- **H1-H8**: durable run, queue, configuration, wait/abort, tool, recovery, and deferred-provider runtime behavior formerly covered by legacy `agent-harness*.test.ts`. -- **C1-C3/N1**: durable compaction and navigation runtime behavior formerly covered by legacy harness compaction/branch-summary tests. -- **O1/O2**: complete event/watch snapshots and runtime telemetry around the restored operation paths. diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md deleted file mode 100644 index f2198ae8f90..00000000000 --- a/packages/agent/docs/harness-v2.md +++ /dev/null @@ -1,3410 +0,0 @@ -# Durable AgentHarness design - -> **Compatibility policy.** Old coding-agent v3 JSONL sessions must open and restore idle. This is the only backward-compatibility requirement. All other formats and APIs in `packages/agent/src/harness` and `packages/session-backends/sqlite-node` (and their respective tests) may break. We do not write migrations, schema versioning, or conversion paths for anything else. - -```mermaid -flowchart TD - App[Application / UI] -->|prompt, steer, abort, config| Harness - Harness -->|snapshots + events| App - Harness -->|hooks + events| Ext[Extensions] - Harness --> Lanes[Lanes: main, ...
one operation each, parallel] - Lanes --> Loop[Step primitives
request / tools] - Loop --> Provider[LLM provider] - Loop --> Tools[Tools] - Harness --> Session[Session
tree · lanes · operation logs · global facts] - Session --> Storage[(memory / JSONL / SQLite)] - Harness -.->|telemetry| Obs[Observability] -``` - -The harness executes runs against one session. The session holds four kinds of state (section 2). Lanes execute in parallel inside one harness (section 3). Storage backends encode the session (Part III). - -# Part I — Concepts - -## 1. Goals - -- **Durable runs.** An accepted prompt is a durable operation. After a crash, a new process restores the session. It resumes the run from the last safe boundary. Every state that a crash can produce is recoverable. -- **Lanes.** A session hosts one or more lanes. A lane is a named position in the conversation tree. Each lane runs at most one operation at a time. Lanes run in parallel. A run and its queued messages belong to the lane that accepted them. Example: a Slack channel is a session; each thread is a lane. Interactive pi uses one lane and does not show the concept in its UI. Extensions get the full harness API, including lanes. Example: a subagent tool runs on a second lane of its parent's session. -- **No partial outcomes.** A crash inside any operation — run, compaction, navigation — leaves one of two states: the operation has not happened, or recovery can complete it. Nothing in between is observable. -- **Harness API.** Events observe execution and cannot change it. Hooks intercept execution and can change it: context, requests, tools, run boundaries. Extensions build on events and hooks. -- **Deterministic stepping.** Every effect — durable write, provider request, tool execution, hook, timer — crosses one injected boundary. In `drive: "manual"` the harness parks before each effect and a test drives it call by call: stop at any boundary, inject input, or close and reopen to simulate a crash. Production and tests run the same procedures; the drive mode only controls the boundary (section 15). -- **Observability.** All execution is instrumentable for logging and tracing, down to provider request and response internals. This channel is separate from the hook system. -- **UI model.** A client gets one atomic snapshot, then a live event stream. Events are not replayed. Reconnect means a new snapshot. -- **Single writer.** One harness writes a session at a time. The serving layer enforces this. All lanes of a session live in that one harness. Restore treats states that a single writer cannot produce as corruption. -- **v3 sessions load.** Old coding-agent v3 JSONL files open unchanged and restore idle. - -## Non-goals - -- **Exactly-once hook side effects.** A hook result becomes durable when the record or entry that consumes it commits. A crash before that commit can run the hook again (section 11 replay table). Side effects a hook makes on its own are invisible to the harness: HTTP calls, file writes. A hook that needs crash-safe external effects must be idempotent, for example keyed by operation id. -- **Provider stream resumption.** Partial streams are never persisted. An interrupted streaming request is retried or abandoned. Deferred requests are different and in scope: the provider returns a handle at once and serves the result later (e.g. `background: true` on a Responses API, batch APIs). pi-ai returns an assistant message with stop reason `deferred` that carries the handle; it is persisted like any assistant message. Redeeming the handle appends a normal assistant message. Recovery sees the unredeemed handle and fetches instead of paying for a new request. -- **Multiple writers.** Two processes on one session are out of scope. The serving layer routes all traffic for a session to the process that holds its harness. Lanes cover the workloads that look like multi-writer: parallel threads over shared history. -- **Replication.** A session lives in one place. Coordination-free sync of diverging copies is a different design. Nothing forecloses it later. -- **Coding-agent migration.** Migrating coding-agent to `AgentHarness` is out of scope. Compatibility means the new JSONL repository can read supported coding-agent v3 files. - -## 2. What a session is - -A session is durable state with four parts: - -1. **The tree** — the conversation. Entries with `parentId` links: messages, model/thinking/tool-activation changes, compaction summaries, branch summaries, custom entries. The tree is shared and passive. It belongs to no lane. It only grows; entries are never changed or deleted. -2. **Lanes** — where work happens. A lane is a name plus a leaf: the entry that future work extends. Every session has the lane `main`. Applications create more, keyed by external identity (a Slack thread id, an email thread id). -3. **Lane operation logs** — what happened and what must happen. One flat, chronological record sequence per lane: operation started, step attempted, tool started, message queued, operation finished. This is where durability is implemented: records exist so that a new process can continue a lane's work after a crash. Nothing reads them during normal execution. -4. **Global facts** — session-scoped values where the latest write wins: the session name, entry labels. Not part of the tree. Kept as append-only history; readers see the newest value. - -All writes across the four parts share one monotonic sequence number. The sequence orders global-fact history and lets a lane's operation log refer to tree positions. - -```text -tree (shared, append-only) lanes -a ── b ── c ── d main → d (op log: …) - └── e ── f slack:171943… → f (op log: …) - -global facts: name = "Refactor auth", label(b) = "checkpoint-1" -``` - -### Active and passive - -The tree and the global facts are passive: shared data, readable by anything. - -A lane is active. It owns its leaf, its operation log (at most one open operation), its queues, and its pending writes. Two lanes never share any of these. Every action of a lane produces entries chained to its leaf, or records in its own operation log. - -### Invariants - -- The tree is conversation only. No lane state, no orchestration state, no pointers live in it. -- An entry's parent chain never changes. Branches share prefixes; nothing is copied. -- A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry). -- Operation-log records never affect the tree. Deleting every operation log leaves a complete, valid conversation. -- At most one operation is open per lane. A state where one lane has two open operations is corruption. -- Entries are shared; records are not. Two lanes may have the same entry on their paths. A record belongs to exactly one lane. - -Records are not tree entries because they describe execution, not conversation: they must never enter model context, transcripts, branch queries, or forks, and within one lane their order is already their meaning — parent links would add nothing. - -## 3. Lanes - -A lane is a named position in the tree plus the work serialized on it. The closest existing concept is a git branch checked out in its own worktree: a name attached to a position, advanced by new work, movable to any entry without rewriting history, and never checked out twice. One difference to git intuition: navigation moves a lane to any entry, not only forward. - -Every session has the lane `main`. Applications create further lanes with a name and an anchor entry. Lane names are permanent application keys: a Slack thread id, an email thread id. No UI lists lanes in the abstract; the platform's own UI (the thread list) plays that role. - -A lane owns: - -- **Its leaf.** New entries chain to it and move it. Navigation jumps it. -- **Its operation log.** At most one open operation. A second operation on a busy lane is rejected; other lanes are unaffected. -- **Its queues.** Steering, follow-ups, and next-run messages target one lane. -- **Its configuration view.** Model, thinking level, and active tools are entries on the path behind the lane's leaf. Two lanes can run different models without knowing of each other. Tool implementations, resources, and stream options are harness-global; only their activation is per-lane. - -Rules: - -- Lanes run operations in parallel. The harness stays the single writer; lane records and entries interleave in the shared sequence. -- Creating a lane copies nothing. Lanes are not deleted or renamed. -- State-dependent mutations on one lane are linearized on that lane's mutation line: validation, at most one durable write, and the in-memory update complete before the next mutation starts (section 15). Provider, tool, hook, and retry work never occupies the mutation line. -- Two lanes at the same leaf diverge on their next append. The tree handles this; no coordination exists between lanes. -- A lane with an unfinished operation restores as suspended, independently of its siblings. Suspension has a reason: crash, or a deferred provider request (section 1). - -## 4. How work executes - -### Operations - -An operation is the unit of durable work on a lane. Three kinds: - -- **Run** — an accepted prompt, through all automatic continuations: tool calls, steering, follow-ups, auto-compaction. Ends when nothing is pending. -- **Compaction** — replaces old context with a summary entry. -- **Navigation** — moves the lane's leaf to an existing entry, optionally with a branch summary. - -An operation is accepted before it executes. Acceptance is durable: after a crash, an accepted operation is either completed by recovery or explicitly closed. Every accepted run ends `completed`, `failed`, or `aborted` (stopped by abort). Compaction and navigation may additionally end `declined` when their decision hook vetoes the accepted structural operation before its effect. - -### Runs, turns, and steps - -A run is a sequence of turns. A turn is one assistant step plus the complete tool batch requested by that assistant message. - -A step is a retryable unit of work inside an operation: produce an assistant message, a compaction summary, or a branch summary. A step may make zero, one, or several provider requests. A failed attempt retries the same step; the attempt count is durable and survives restarts. A deferred provider request ends an assistant step: the handle arrives inside a persisted assistant message that closes the step, the operation suspends, and redemption later appends the real result (section 1). - -Each tool call that starts an effect is also a step. `tool_started` opens it; its tool-result entry closes it. A parallel batch holds several open tool steps at once; their effects run concurrently and finalize in source order (section 14). - -### Queues and deferred writes - -Two mechanisms carry input into a running lane. They differ in abort behavior: - -- **Queues** carry conversational intent: `steer` corrects the current work, `followUp` adds work for when the model would stop, `nextRun` seeds the lane's next run. Steering and follow-ups die on abort; their payloads are returned to the caller. Next-run messages survive. -- **Deferred writes** carry facts: entries and configuration changes requested while a step is in flight. They survive abort and are applied even during cancellation. - -Both are durable at acceptance: the accepting call writes a record with the full payload to the lane's operation log, then resolves. The tree entry is written later, when the item is applied or consumed — the position where the model first sees it. If the process dies between acceptance and the tree write, recovery reads the record and performs the append. Accepted input is never lost. - -### Checkpoints - -Between turns, the lane passes a checkpoint: - -1. Apply pending deferred writes. -2. Consume queued steering messages. -3. Compact if the next request would not fit. - -Compaction has a reactive trigger too: a provider response that reveals the request did not fit — an overflow-form error, or a `length` stop below the intended output cap. That response is discarded and the run compacts and retries once (section 6, "Context overflow at an assistant step"). - -A turn with tool calls forces another turn so the model sees its results — with one exception: a batch in which every finalized tool result persisted `terminate: true` suppresses automatic tool continuation (steering or follow-up input can still start another turn). Follow-up messages are consumed only when tool continuation and steering are exhausted. The run ends when a checkpoint finds nothing pending. - -### Append-only context - -> Across the requests of a lane, provider context only grows at the tail. An insertion before the previous request's tail invalidates the provider's KV cache from that point on and multiplies token cost. - -This invariant is why mid-turn writes defer to checkpoints: checkpoint application appends at the tail. Compaction is the one deliberate exception; it trades one full cache invalidation for a smaller context. - -### Lane lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Idle: restored, no open operation - [*] --> Suspended: restored, open operation - Idle --> Running: operation accepted - Running --> Idle: finished - Running --> Cancelling: abort - Cancelling --> Idle: reconciled - Running --> Suspended: deferred handle persisted - Suspended --> Running: resume continues the open operation - Suspended --> Cancelling: abort -``` - -- States are per lane. One exception: a failed storage write faults the whole harness. A faulted harness stops all effects and rejects all calls; after the cause is fixed, reopening restores each lane from its records. -- **Suspended** means: an operation is open, nothing executes. Reached by restore after a crash, or deliberately when a deferred handle is persisted. `resume()` continues the operation; `abort()` closes it without further execution. -- **Abort** records the cancellation durably, signals running effects, and returns. Reconciliation follows: unresolved tool calls get synthetic results, and the transcript gets a closing assistant message. Automatic drive runs it in the background; manual drive leaves it parked at its next action. - -### Resume - -Resume continues the open operation. It never starts a new one. The entry point is wherever the records end: retry an unfinished step, redeem a deferred handle, reconcile a half-finished tool batch, or continue at the next checkpoint. Queued messages and deferred writes accepted before the crash are still pending and apply normally. - -# Part II — How execution is recorded - -Part II is backend-neutral. It defines the records a lane writes, when it writes them, and how recovery reads them back. Part III maps this onto APIs and storage. - -## 5. Records - -### The durability rule - -> Before an effect: write an intent record that names what will happen and the ids it will produce. After the effect: append the result as an entry with exactly those ids. - -There is no multi-record atomicity and none is needed. Each record and each entry is durable alone. A crash between intent and result leaves the intent unfulfilled; recovery decides per intent type: complete it, retry it, or close it with a synthetic result. An intent is fulfilled if and only if an entry with its provisioned id exists. The entry can itself name the next durable state: an assistant entry with `stopReason: "deferred"` fulfills its attempt's provisioned append and closes the step; what stays outstanding is the operation — the persisted handle awaits redemption (section 6). A provisioned id that exists with different content is corruption. - -### Provisioned ids - -Intent records carry the ids of entries that do not exist yet: - -```ts -/** An entry payload with its id pre-allocated. parentId, seq, and timestamp - are assigned by storage when the entry is appended: it chains to the - lane's then-current leaf. */ -type ProvisionedEntry = - T extends Entry ? Omit : never; -``` - -### Record catalog - -Every record belongs to one lane's operation log. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. Next-run queue records (`queue_enqueued` and their `queue_cancelled`) and standalone `adjustment` usage records carry no `runId`. - -```ts -interface RecordBase { - id: string; - seq: number; // shared sequence, section 2 - lane: string; - timestamp: number; // Unix ms -} - -// Acceptance boundary of an operation. Everything decided before acceptance -// is persisted here. This record's own id IS the runId that all other -// records of the operation carry. -interface OperationStartedRecord extends RecordBase { - type: "operation_started"; - sourceLeafId: string | null; // the lane's leaf at acceptance - intent: - | { - kind: "run"; - /** Normalized caller input after skill/template expansion, before - before_run. Kept for SuspendedOperation and before_resume. */ - originalPrompt: AgentMessage[]; - /** Captured nextRun items, then the prompt, then before_run - injections. Full payloads, provisioned ids. Capture happens in - the acceptance mutation (section 15): items present when it runs - belong to this run; later items belong to the next. */ - initialMessages: ProvisionedEntry[]; - /** Present only when a hook overrode the system prompt; fixed for the - whole run. Absent: the systemPrompt callback runs per request. */ - systemPromptOverride?: string; - /** Opaque state keyed by stable hook registration id. Each - before_resume handler receives only the value under its id. */ - resumeData?: Record; - } - | { - kind: "compaction"; - customInstructions?: string; - resultEntryId: string; // provisioned compaction entry - } - | { - kind: "navigation"; - targetId: string | null; // destination entry; null = root - summarize: boolean; - customInstructions?: string; - label?: string; // global fact, written at completion - summaryEntryId?: string; // provisioned branch-summary entry - }; -} - -// Written when abort() resolves. A request marker, not a terminal state: -// reconciliation follows, then operation_finished with outcome "aborted". -// Kills this operation's steer/follow-up queue items; next-run items survive. -interface AbortRequestedRecord extends RecordBase { - type: "abort_requested"; - runId: string; -} - -// Closes the operation. failed = orderly durable failure (for example, -// retries exhausted). aborted = closed by abort. declined = vetoed by a -// hook before any effect. -interface OperationFinishedRecord extends RecordBase { - type: "operation_finished"; - runId: string; - outcome: "completed" | "aborted" | "failed" | "declined"; - error?: { code: string; message: string }; -} - -// Written before each attempt at a retryable step. Marks: we are about to -// do this, for the n-th time. Steps are logged because they are -// retryable: the durable count caps retries across restarts — a -// crash-restart loop cannot reset it. One record per attempt; one attempt -// may make zero or several provider requests (split-turn compaction -// makes two). Deferred results need no extra -// record: the handle lives in the persisted assistant entry (section 1). -interface StepAttemptRecord extends RecordBase { - type: "step_attempt"; - runId: string; - step: "assistant" | "compaction" | "branch_summary"; - attempt: number; // 1-based within this step - /** The entry this attempt produces if it succeeds. Assistant attempts - provision a fresh id each; all attempts of one structural step reuse - one id (manual: the intent's; auto: the first attempt's). The give-up - error entry fulfills the last attempt's id. */ - resultEntryId: string; - /** Required exactly for compaction steps. Persists why the summary is - being generated so resume re-enters the same structural work without - re-deriving context pressure. */ - compactionReason?: "manual" | "threshold" | "overflow"; -} -// The model of a resumed request is not read from records: the lane's -// effective model is derived from its path, and a deferred handle's model -// is in the persisted assistant entry. - -// Written after before_tool and validation pass, before the tool executes. -// assistantEntryId + toolIndex is the durable invocation identity. -interface ToolStartedRecord extends RecordBase { - type: "tool_started"; - runId: string; - assistantEntryId: string; - toolIndex: number; - toolCallId: string; - toolName: string; - effectiveArgs: Record; // after before_tool - resultEntryId: string; // provisioned - /** The tool's declared replay safety, snapshotted at execution time. - Recovery re-executes an unfinished call only when this field AND the - current tool declaration both say "safe"; otherwise it writes a - synthetic "interrupted" result. */ - replay: "never" | "safe"; -} - -// Queue acceptance. The payload travels here; the entry appears at the -// consumption point. -interface QueueEnqueuedRecord extends RecordBase { - type: "queue_enqueued"; - queue: "steer" | "followUp" | "nextRun"; - runId?: string; // absent for nextRun - target: ProvisionedEntry; -} - -// Durable retraction of a pending queue item, before consumption. Without -// this record a crash would resurrect the item: recovery treats a -// queue_enqueued without its entry as pending. -interface QueueCancelledRecord extends RecordBase { - type: "queue_cancelled"; - runId?: string; // matches the queue_enqueued it kills - entryId: string; // the enqueued target's provisioned id -} - -// Deferred-write acceptance: an entry or configuration change requested -// while a step was in flight. Applied at the next checkpoint. -interface WriteDeferredRecord extends RecordBase { - type: "write_deferred"; - runId: string; - target: ProvisionedEntry; -} - -// The cost ledger. Written whenever usage is reported or adjusted, -// whatever happens to the response. Pure accounting: the reduction, -// recovery, and validity checks never read it, so it adds no recovery -// states and no crash-matrix rows. It records reported usage; a transport -// death mid-stream can bill tokens no one reported, and a crash between -// settle and this write loses that one item — the irreducible window. -type UsageRecord = RecordBase & { type: "usage"; usage: Usage } & ( - // A provider request settled, whatever the outcome. Written before any - // classification, retry decision, or discard. Split-turn compaction - // writes two records sharing one attempt. A pending deferred fetch that - // reports no usage writes no record. - | { cause: "assistant" | "compaction" | "branch_summary" | "deferred_fetch"; - runId: string; entryId: string; attempt: number; stopReason: TerminalStopReason } - // A finalized tool result reported nested LLM work; skipped when it - // reports none. A safe replay writes a second record for the second - // execution: both were billed. - | { cause: "tool"; runId: string; entryId: string; toolCallId: string } - // A hook-supplied summary carried usage the hook measured itself. - | { cause: "hook"; runId: string; entryId: string } - // Application-supplied, anytime (lane.recordUsage): reconciliation, - // estimates, corrections. Negative values are legal. - | { cause: "adjustment"; runId?: string; entryId?: string; details?: JsonValue } -); - -type LaneRecord = OperationStartedRecord | AbortRequestedRecord | OperationFinishedRecord - | StepAttemptRecord | ToolStartedRecord | QueueEnqueuedRecord | QueueCancelledRecord - | WriteDeferredRecord | UsageRecord; - -type NewRecord = - T extends LaneRecord ? Omit : never; -``` - -Blocked or invalid tool calls write no `tool_started`. No effect starts, so no intent is needed: the block is durable as a tool-result entry with `isError: true` and the block reason as content. A crash before that entry loses only the decision, and recovery makes it again — `before_tool` runs again for a call with no `tool_started` and no result. - -A tool step needs no outcome record. Its result entry is the complete durable outcome, including the batch-control decision: the tool-result entry persists `terminate` (section 12). A crash after execution but before the result entry follows the replay policy (section 6); re-finalization runs `after_tool` again, which the section 1 non-goal explicitly permits. - -Cost is the one concern where an outcome record exists: **cost durability must not depend on result durability**. Retryable steps are precisely the steps designed to produce responses that never become entries — failed attempts, exhausted series, discarded overflow responses — and their spend must not vanish with them. Every provider request therefore settles with a `usage` record before any classification, retry decision, or discard; tool-reported and hook-reported usage get records beside their entries; applications append `adjustment` records for anything the harness cannot see. - -A harness-written `usage` record always binds `entryId` to the provisioned id of the entry its measurement belongs to; whether that entry exists is a separate question — a failed attempt's or a discarded response's id never materializes, which is the point. Three layers separate cleanly: an entry's `usage` field is an **immutable snapshot** of the response(s) that produced that entry, written once at append and never touched again; the **effective cost of an entry** is a read-time query — the sum of all lanes' `usage` records bound to its id, base plus adjustments; the **session's cost** is the sum of all `usage` records. Recovery can honestly bill twice — a retried step or a replayed tool writes one record per execution — and the entry snapshot equals the newest non-adjustment record(s) of its id (for compaction and branch summaries: the successful attempt's). - -### Validity - -Recovery rejects a lane's log as corrupt when: - -- more than one operation is open; -- a record references an operation that does not exist, or follows its finish; -- attempt numbers are not consecutive within a step; -- `compactionReason` is absent from a compaction attempt or present on another step kind; -- a steer or follow-up `queue_enqueued` for a run follows its `abort_requested`; -- a `queue_cancelled` targets an id with no `queue_enqueued`, or one whose entry exists; -- attempts in one structural step disagree on `resultEntryId`, or any attempts of one step disagree on `compactionReason`; -- `tool_started.toolIndex` does not identify the stored `toolCallId` and `toolName` in its original assistant entry; -- two `tool_started` records share an invocation identity; -- a provisioned id exists with different content. - -## 6. What each action writes - -Traces at the storage level. All traces show one lane. Legend: - -```text -E entry appended to the tree (chained to the lane's leaf) -R record appended to the lane's operation log -L lane pointer move -G global fact written -H hook (awaited; hooks are Part I concepts, their API is Part III) -X crash site -``` - -### Run with one tool call - -```text - prompt("fix the bug") -H before_run may inject entries, override system prompt -R operation_started kind run; initial messages with provisioned ids -E user message the provisioned id from the intent -R step_attempt step assistant, attempt 1 -E assistant message [tool call] -H before_tool may change args or block -R tool_started effective args, provisioned result id, replay -H after_tool may patch result and terminate -E tool result the provisioned result id; persists the terminate decision -R step_attempt next turn's assistant step, attempt 1 -E assistant message "done" -H before_run_end nothing pending, returns nothing -R operation_finished completed -``` - -A crash between any two lines is recoverable. The general rule: an intent without its result entry is completed, retried, or closed with a synthetic result by recovery; a result entry without a consumed intent cannot exist. - -### Retry - -```text -R step_attempt attempt 1 - request fails -R usage the failed attempt's cost — never lost -R step_attempt attempt 2 — durable count -R usage -E assistant message -``` - -Every provider request settles with a `usage` record (section 5); the other traces omit them for brevity. Per-request hooks (`transform_context`, `before_request`, `after_response`) run inside every request and are omitted everywhere; Tier B records them (section 19). - -Crash during backoff: restore counts two attempts; resume starts attempt 3. The count never resets. Retryable errors below the cap are never appended as entries. Attempts exhausted — or a non-retryable terminal error — appends an assistant message with the error, then `operation_finished` failed: - -```text -E assistant message stop reason error; the failure is durable -X crash operation still open -R operation_finished recovery writes failed — never completed -``` - -The error entry is the terminal-failure marker. Recovery that finds it drains accepted writes and queued input; unless consumed steering or follow-up input starts new work, it closes the run failed (section 7). A run whose newest own message is a step-produced error can never be completed by recovery. - -### Context overflow at an assistant step - -`length` is ambiguous: generation stopped at some output boundary, but that boundary is either the intended output limit — compaction cannot help — or a smaller context or provider limit, where it can. The classification compares actual output usage (reasoning tokens included) against the **intended** output cap: - -```ts -function isRecoverableLength(message: AssistantMessage, desiredMaxOutput: number): boolean { - if (message.stopReason !== "length") return false; - // Reaching the caller's or model's intended cap is a genuine output-limit stop. - if (desiredMaxOutput > 0 && message.usage.output >= desiredMaxOutput) return false; - // Stopped below the intended cap: context pressure or provider-side truncation. - return true; -} -``` - -`desiredMaxOutput` is the caller-supplied `maxTokens` when set, else `model.maxTokens` — the intended limit **before** any context clamping. The value actually sent can never be the reference: some providers reject an explicit output cap outright (the OpenAI Codex backend returns HTTP 400 for `max_output_tokens`), and Pi clamps others to the remaining context. This covers a context-clamped request that returns 16 reasoning tokens against a 128k intent (recover), a Xiaomi/Qwen-style `length` with zero output (recover), and an explicit 1,024 cap fully used (genuine stop) — with no context-percentage heuristics. Overflow-form errors — a provider rejection matching the overflow patterns, or a silent success whose prompt exceeds the window — classify the same way and take the same path. - -A recoverable response is **discarded**: like a retryable error, it never becomes an entry, so nothing has to be scrubbed from context on retry, live or after a crash. Its provisioned result id stays unfulfilled; its cost is already durable in the `usage` record written when the request settled (section 5). - -```text -R step_attempt step assistant, attempt 1 - response: recoverable length below the intended cap, or overflow-form error -R usage the discarded response's cost — never lost - nothing else appended the response itself is discarded -H before_compaction reason overflow -R step_attempt step compaction, attempt 1 -E compaction entry -R step_attempt step assistant, attempt 1 — new step -E assistant message -``` - -**One recovery per conversational input.** An overflow compaction may start only when no overflow-reason compaction `step_attempt` is newer than this run's newest consumed conversational message (prompt, steering, or follow-up). A second recoverable response inside that window appends the give-up error entry and fails the run through the drain path — a `length` response never resets the guard; only consumed conversational input does. This bounds the compact-and-retry loop at one attempt per user action. A `before_compaction` decline or an empty compaction preparation for reason `overflow` is equally terminal: without compaction the request cannot fit. A hook-supplied overflow compaction writes its compaction `step_attempt` before the entry so the guard counts it — the one hook-supplied summary that writes an attempt record. - -Per crash site: - -| crash after | durable state | recovery | -|---|---|---| -| `step_attempt` (assistant) | unfinished assistant step | resume retries; a recoverable response classifies again live | -| `step_attempt` (compaction, overflow) | unfinished compaction step | resume the compaction step with the recorded reason | -| compaction entry | step closed by its entry | checkpoint path; a fresh assistant step follows | - -A genuine `length` stop — output at the intended cap — is appended and handled as before: with tool calls, the truncated batch fails every call without executing; without, the run proceeds to its normal finish. User-facing wording for any truncated response stays neutral ("response was truncated before completion") rather than claiming the configured output limit was reached. - -### Steering while a tool runs - -```text -E assistant message [tool call] -R tool_started - steer("focus on the tests") caller resolves here -R queue_enqueued steer, full payload, provisioned id -E tool result -E user message checkpoint consumes the queue item; provisioned id -R step_attempt next request sees the steering message -``` - -Crash before `queue_enqueued`: the steer never happened; the caller's promise never resolved. Crash after: recovery finds the record without its entry and appends it at the same point the checkpoint would have. - -A queued item can be durably retracted before consumption: - -```text -R queue_enqueued steer, full payload, provisioned id - cancelQueued(entryId) caller resolves here -R queue_cancelled the entry will never be appended -``` - -Crash between the two records: the item is still pending; the cancel promise never resolved. Cancellation and consumption are jobs on the lane mutation line, so `[cancel, consume]` and `[consume, cancel]` are the only histories (section 15). - -### Input at the finish boundary - -Same-lane decisions have one order: the lane mutation line (section 15). The final pending-work check and the terminal append are one `tryFinishRun` mutation, so a concurrent steer has exactly two histories: - -```text -steer first finish first -R queue_enqueued R operation_finished - tryFinishRun → continue steer() → NoActiveRun -E user message -... run continues -R operation_finished -``` - -Deferred writes and abort use the same ordering. A deferred write accepted before finish must be applied before the run can close; one accepted after finish observes an idle lane and appends directly. `abort_requested` before finish selects abort reconciliation; abort after finish returns `NoActiveOperation`. There is no third history — that is the entire mechanism. - -### Deferred write mid-turn - -```text -R step_attempt request in flight, context ends at user message U - session.appendMessage(M) caller resolves here -R write_deferred full payload, provisioned id -E assistant message A provider cached [.., U, A] -E message M checkpoint applies the write; tail append -``` - -Appending M directly would produce [.., U, M, A]: a valid provider sequence that invalidates the KV cache from M on, and a transcript claiming A saw M when it did not. The checkpoint prevents both (append-only context, section 4). - -### Abort during a tool - -```text -E assistant message [tool call] -R tool_started - abort() caller resolves here -R abort_requested steer/follow-up queues die; payloads returned -E tool result synthetic "interrupted", or real if it finished -E assistant message closing message, stop reason aborted -R operation_finished aborted -``` - -Crash after `abort_requested`: recovery completes the same reconciliation. Pending deferred writes are applied even here; queued steer/follow-up items are not. - -### Tool execution crash sites - -```text -E assistant message, calls c1, c2 -X1 before before_tool nothing durable for c1 -H before_tool(c1) -X2 decision made, nothing written same as X1 -R tool_started(c1) -X3 tool executing -H after_tool(c1) -X4 hook interrupted same durable state as X3 -E tool result c1 -X5 result durable c1 finished -``` - -| crash site | durable state | recovery | -|---|---|---| -| X1, X2 | no record, no result | full normal path; `before_tool` runs (again) | -| X3, X4 | `tool_started`, no result | replay safe (record AND current declaration): re-execute persisted args, `after_tool` on the fresh result. Otherwise: synthetic "interrupted" result, no hooks | -| X5 | result entry exists | skip c1; c2 is at X1 | - -Reconciliation handles each call of a batch at its own site, in source order. The step then ends normally. - -### Auto-compaction at a checkpoint - -```text -E tool result step ends - checkpoint: next request would not fit -H before_compaction may decline or supply the summary -R step_attempt step compaction — skipped if hook supplied -E compaction entry -R step_attempt step assistant; run continues on compacted context -``` - -Auto-compaction writes no `operation_started`; it belongs to the run. Manual `compact()` is its own operation: `operation_started` (kind compaction, provisioned result id) → hook → attempt → compaction entry → `operation_finished`. - -### Navigation - -```text - navigateTree(target, { summarize: true, label: "before-refactor" }) -R operation_started kind navigation; target, provisioned summary id, label -H before_navigation may decline or supply the summary -R step_attempt step branch_summary — skipped if hook supplied - summary text generated in memory only -L lane move → target one storage write; the commit point -E branch summary entry appends chain to the lane's leaf — now the target, - so the summary lands on the target branch -G label from the intent; latest-wins, idempotent -R operation_finished completed -``` - -The move commits first; every later write chains off durable state. No multi-object atomic write exists anywhere in the design. Acceptance rejects `target === sourceLeafId`, so "has the move happened" is always decidable: the lane's leaf equals `intent.targetId` if and only if the move committed. Per crash site: - -| crash after | recovery sees | action | -|---|---|---| -| `operation_started` | leaf at `sourceLeafId` | rerun hook or summary step, then move | -| summary generated | nothing durable of the text | regenerate under the same attempt cap | -| lane move | leaf at `intent.targetId` | append summary if `summaryEntryId` missing | -| summary entry | entry exists | set label, finish | -| label | fact set (idempotent) | finish | - -Between the move and `operation_finished`, readers see the lane at the target with an open navigation — a recoverable state, not an invalid one. The lane runs nothing else meanwhile; one operation per lane already guarantees that. - -### Deferred provider request - -```text -R step_attempt stream options request deferred execution -E assistant message stop reason deferred, carries the handle - lane suspends; prompt() resolves with outcome "suspended" - ... hours pass, maybe a different process ... - resume() newest entry on the lane's path is a deferred - assistant message with no successor - → the handle is unredeemed, redeem it - fetchDeferred(model, handle) model and handle from that entry -E assistant message the real result - run continues normally -``` - -The suspended lane is indistinguishable from a crashed one in storage: an open operation whose newest entry is a deferred assistant message with no successor. Restore lists it as suspended; `resume()` checks the handle. Redemption writes no intent record: it starts no new model work, and a committed successor entry prevents another fetch. - -Each `resume()` performs one fetch. Three outcomes: - -- **pending** — the provider returns stop reason `deferred` again. Nothing but a possible `usage` record is written (section 15); the lane re-suspends. Poll cadence is application policy. -- **ready** — a normal assistant message. It is appended as the successor and the run continues. -- **terminal** — the provider returns stop reason `error` (expired, unknown, consumed), or the fetch itself rejects; the harness converts a rejection to the same error-message form. The message is appended and the run finishes failed. Redemption failure never starts an automatic replacement request; steering or follow-up input already accepted for this run can still start a later turn. - -`abort()` on a suspended lane: `abort_requested` record, best-effort cancellation of the handle at the provider, then normal reconciliation and `operation_finished` aborted. The deferred entry stays in the transcript. - -Deferred assistant messages carry a handle, not content; they project to nothing in provider context. - -## 7. Recovery - -### Restore - -Opening a session restores every lane independently. Restore reads; it never appends and never starts effects. - -Recovery starts with indexed discovery, not a full log scan: - -1. `findOpenOperations(lane, { limit: 2 })` returns unfinished `operation_started` records newest first. Zero means idle, one means suspended, and two means corruption. Backends must answer this from replayed/indexed operation state; callers cannot infer it from only the newest start. -2. For an idle lane, one indexed query finds the newest run-kind `operation_started`, then filtered `queue_enqueued` / `queue_cancelled` queries above it reconstruct pending `nextRun` items. With no prior run, the same type-filtered queries read only pre-run queue state; unrelated usage adjustments are never scanned. -3. For a suspended lane, the open operation selects two bounded payload reads: - - **The lane's records** since that `operation_started`. Everything after the finish of the previous operation is irrelevant history. - - **The lane's own entries**: the path from its leaf back to the operation's anchor (`sourceLeafId`). These are exactly the entries this operation appended. - -Reduction may additionally perform point lookups for provisioned entry ids and bounded branch lookups for effective model, thinking, and active-tool configuration at the operation anchor. These are indexed lookups, not extra history scans. Every scan is bounded by the open operation or the still-relevant idle queue, not by total session history or another lane's activity. - -An idle lane's remaining state is pending next-run queue items. Next-run messages can be enqueued at any time; only the acceptance of a run consumes them — compaction and navigation pass over the queue. Pending items are the `queue_enqueued` records after the lane's most recent run-kind `operation_started` whose provisioned entries do not exist and that no `queue_cancelled` retracts. Items a run captured are listed in its intent's `initialMessages`, so a captured-but-unappended item is completed by that run's recovery and is never offered to the next run. - -### The reduction - -From those two reads, the lane's state: - -- **aborting** — an `abort_requested` record exists. -- **attempts used** — the newest `step_attempt`, when its `resultEntryId` has no entry, is the unfinished step; its `attempt` field is the durable count, its kind and `compactionReason` select the resume path. Closure is a point lookup, not adjacency inference: a step is closed exactly when the newest attempt's provisioned result exists. Earlier attempts' unfulfilled ids belong to finished work and need no inspection. -- **overflow recovery used** — a compaction `step_attempt` with reason `overflow` is newer than the newest consumed conversational message of this run (section 6, overflow guard). -- **tool batch** — the newest assistant entry with tool calls, each call matched against `tool_started` records and result entries (section 6, crash-site table). The assistant stop reason is retained: a `length` batch is truncated and never executes on recovery. Persisted `terminate` values on result entries decide whether the completed batch forces another turn. -- **deferred handle** — the newest own entry is a deferred assistant message with no successor. -- **newest own entry** — the last entry of the second read; the pure predicates (`needsAssistant()`, terminal failure, abort closure) read it. -- **pending queue items** — `queue_enqueued` records whose provisioned entry does not exist, excluding items retracted by `queue_cancelled` and steer/follow-up items killed by this run's `abort_requested`. -- **pending writes** — `write_deferred` records whose provisioned entry does not exist. -- **missing initial messages** — provisioned ids from the run intent without entries. -- **structural targets** — for compaction and navigation: does the provisioned result entry exist. - -The same rules run live: during normal execution the harness updates this state in memory as it writes; restore recomputes it from storage. State and records cannot disagree, because the state is defined as their reduction. `usage` records are invisible here: they are accounting, never orchestration. - -### Resume - -`resume()` continues the open operation from what the reduction says: - -- missing initial messages → append them (accepted input is never lost), even when aborting. -- aborting → reconcile: synthetic tool results, closing assistant message, `operation_finished` aborted. -- unresolved tool batch → per call: skip, re-execute, or synthesize (section 6). -- deferred handle → redeem (section 6). -- terminal failure — the newest own message is a step-produced assistant error (a give-up entry, a non-retryable request error, or a failed redemption; never an arbitrary deferred-write message) → apply accepted writes and consume queued conversational input; if nothing consumed starts new work, append `operation_finished` failed. Recovery never completes such a run. -- unfinished step → resume that exact step before consuming new checkpoint input: next attempt if the cap allows, else fail the operation. A compaction step resumes with its recorded `compactionReason`. -- otherwise → continue at the next checkpoint; pending writes and queue items apply normally there. - -Recovery appends are ordinary appends with one extra rule: skip any provisioned id that already exists. A crash during recovery therefore leaves less to recover; re-running recovery is always safe. Recovery repeats an unknown effect only when its policy permits it: a retryable step starts a new durable attempt, and a tool replays only when both replay declarations say `safe`. Interrupted hook handlers follow the section 11 replay table. - -Old v3 sessions contain no records. Every lane question answers "idle"; section 12 normalization restores `main` at the final retained logical entry (v3 `leaf` entries and discarded fact-like entries resolve through their nearest retained ancestor). - -# Part III — API and implementation - -## 8. Public API - -### The lane surface - -`AgentLane` is the operation surface of one lane. `AgentHarness` implements it for `main`: `harness.prompt(...)` is main's prompt. Every method is async, including getters an in-process implementation answers from memory: the interface must be implementable by a remote proxy, so no signature may promise synchronicity that only the local implementation can keep. Sync exceptions: `name`, and listener registration (`hooks.on`, `events.on`) — a server bridges events over its own transport, not registrations. - -```ts -interface AgentLane { - readonly name: string; // "main" on the harness itself - getLeafId(): Promise; - - // Operations. Never throw; every call resolves with a result (see below). - // At most one operation per lane; other lanes are unaffected. - prompt(text: string, images?: ImageContent[]): Promise; - prompt(message: AgentMessage | AgentMessage[]): Promise; - skill(name: string, additionalInstructions?: string): Promise; - promptFromTemplate(name: string, args?: string[]): Promise; - compact(options?: { customInstructions?: string }): Promise; - navigateTree(targetId: string | null, options?: NavigateOptions): Promise; - resume(): Promise; // continue this lane's open operation - abort(): Promise; // durable on resolve; reconciliation runs in background - - // Queues. Durable on resolve (queue_enqueued record); the returned - // entryId identifies the item until consumption. steer/followUp require - // an active run; nextRun and cancelQueued work anytime. - steer(text: string, images?: ImageContent[]): Promise; - steer(message: AgentMessage): Promise; - followUp(text: string, images?: ImageContent[]): Promise; - followUp(message: AgentMessage): Promise; - nextRun(text: string, images?: ImageContent[]): Promise; - nextRun(message: AgentMessage): Promise; - /** Durably retract a pending queue item (queue_cancelled record). */ - cancelQueued(entryId: string): Promise; - /** Append an adjustment usage record (section 5): reconciliation, - estimates, corrections. Allowed anytime; records are not context. */ - recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): - Promise; - - waitForIdle(): Promise; - runWhenIdle(callback: () => void | Promise): Promise; // runtime-only - - // Manual drive controls. Section 15 defines their exact behavior; they - // are usable only with AgentHarnessOptions.drive === "manual". - peekAction(): Promise; - executeAction(): Promise; - runToCompletion(): Promise; - - // Persisted configuration — entries on the path behind this lane's leaf, - // resolved by point queries. Setters resolve on durable acceptance; - // while a run is open they become deferred writes on this lane. - getModel(): Promise; setModel(model: Model): Promise; - getThinkingLevel(): Promise; setThinkingLevel(level: ThinkingLevel): Promise; - getActiveTools(): Promise; setActiveTools(names: string[]): Promise; - - /** This lane's view of the tree: reads default to this lane's leaf; - appends defer while a run is open and otherwise chain to the leaf - (section 12). */ - session: SessionTree; - - /** Scoped: this lane's transcript, state, queues, and events (section 9). */ - watch(): Promise<{ snapshot: LaneSnapshot; start: (listener) => void; unsubscribe: () => void }>; -} -``` - -All prompt overloads normalize to `AgentMessage[]`. Text plus images becomes one user message; an input message array keeps its order after validation. Skill and template expansion happens before normalization is stored. This normalized array is `OperationStartedRecord.intent.originalPrompt`; it excludes captured `nextRun` items and hook injections. - -### The harness - -```ts -class AgentHarness implements AgentLane { - /** Opens the session, restores every lane, starts no effects. - One suspended entry per lane with an open operation. */ - static create(options: AgentHarnessOptions): Promise<{ - harness: AgentHarness; - suspended: SuspendedOperation[]; - }>; - - // Lane management. Names are permanent application keys - // ("slack:1719432.0021"). Handles are stateless facades bound to the - // name: any number may exist, all equivalent; identity is the name, - // never the object. Lanes are not deleted or renamed. - lane(name: string): Promise; // lookup, never creates - createLane(name: string, at: string | null): Promise; - /** Inventory. Always includes "main". */ - lanes(): Promise; - - // Harness-global configuration: registries and runtime capabilities. - // Tool implementations are code and cannot persist; the active set - // (names) persists per lane. - getTools(): Promise; setTools(tools: AgentTool[], activeNames?: string[]): Promise; - getResources(): Promise; setResources(r: Resources): Promise; - getStreamOptions(): Promise; setStreamOptions(o: StreamOptions): Promise; - getRetryPolicy(): Promise; setRetryPolicy(p: RetryPolicy): Promise; - getCompactionSettings(): Promise; setCompactionSettings(s): Promise; - getSteeringMode(): Promise; setSteeringMode(m: QueueMode): Promise; - getFollowUpMode(): Promise; setFollowUpMode(m: QueueMode): Promise; - - /** Session-wide observer: lane inventory snapshot plus the unfiltered - event stream. No transcripts; compose with lane.watch(). */ - watchSession(): Promise<{ snapshot: SessionSnapshot; start; unsubscribe }>; - - // Harness-global. Every hook and event payload carries `lane`. - hooks: Hooks; - events: Events; - - /** Detach cleanly. Signals in-flight effects, waits for the append in - progress, releases the writer claim. Open operations stay resumable; - no shutdown record is needed. */ - close(): Promise; -} - -interface LaneInfo { - name: string; - leafId: string | null; - operation: null | { id: string; kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "aborting" }; -} - -``` - -### Options - -```ts -interface AgentHarnessOptions { - // Identity and providers - session: Session; - models: Models; // provider collection for all requests - - // Initial lane configuration — used when a lane's path has no persisted - // config entries; persisted config wins otherwise. - model: Model; - thinkingLevel?: ThinkingLevel; - activeToolNames?: string[]; - - // Runtime capabilities — harness-global, reconstructed at create() - tools?: AgentTool[]; - toolContext?: TContext | (() => TContext | Promise); - systemPrompt?: string | ((ctx) => string | Promise); // evaluated per request - resources?: Resources; // skills, prompt templates - - // Execution policy - streamOptions?: StreamOptions; // transport, headers, timeouts, deferred - retry?: RetryPolicy; // step attempt cap; the durable count - compaction?: CompactionSettings; - steeringMode?: QueueMode; - followUpMode?: QueueMode; - /** Batch default; a called tool declaring executionMode "sequential" - forces sequential regardless (section 14). */ - toolExecution?: "sequential" | "parallel"; // default parallel - /** automatic: operation methods drive their procedures to completion. - manual: the operation's effects park at the gate; peekAction() / - executeAction() / runToCompletion() drive them. Deterministic tests - and debuggers. Section 15. */ - drive?: "automatic" | "manual"; // default automatic - - // Projection - /** AgentMessage → provider messages, before each request. Default handles - bash executions, custom messages, summaries; validates at acceptance - that queued/prompted messages convert to user messages. */ - toProviderMessages?: (messages: AgentMessage[]) => Message[] | Promise; - /** Custom entry → context messages, at context build. Entries without a - projector never enter provider context. */ - entryProjectors?: Record; - - // Telemetry. The default context is a no-op. Section 18. - telemetryContext?: TelemetryContext; -} -``` - -### Results and tagged errors - -The public API uses a small vendored subset of the `better-result` v3 pattern. `packages/agent` does not take a runtime dependency on `better-result`. - -The subset contains only: - -- serializable `Result.ok()` and `Result.err()` values; -- `Result.isOk()` and `Result.isErr()` guards; -- `TaggedError` with a literal `_tag`, readonly payload, normal `Error` behavior, `.toJSON()`, and class-level `.is()`; -- exhaustive `matchError()`. - -```ts -export type Result = - | { ok: true; value: T } - | { ok: false; error: E }; - -export const Result = { - ok(value: T): Result { - return { ok: true, value }; - }, - err(error: E): Result { - return { ok: false, error }; - }, - isOk(result: Result): result is { ok: true; value: T } { - return result.ok; - }, - isErr(result: Result): result is { ok: false; error: E } { - return !result.ok; - }, -}; - -export interface TaggedErrorValue extends Error { - readonly _tag: Tag; - toJSON(): { _tag: Tag; message: string } & Record; -} - -export interface TaggedErrorFactory { - new ( - props: Props, - ): TaggedErrorValue & Readonly; - is(value: unknown): value is TaggedErrorValue; -} - -export declare function TaggedError(tag: Tag): TaggedErrorFactory; - -export type ErrorMatchers, R> = { - [Tag in E["_tag"]]: (error: Extract) => R; -}; - -export declare function matchError, R>( - error: E, - matchers: ErrorMatchers, -): R; -``` - -The implementation is expected to stay under about 80 lines, excluding tests. It has no mapping combinators, generator composition, promise wrappers, retry helpers, collection helpers, or `Panic` class. Promise remains the async boundary. `HarnessFault` uses native throwing and promise rejection for defects. - -Each expected rejection is one class. Its tag is a string literal. Its fields carry the data a caller needs. Use the v3 class form shown below; do not add a trailing `()` after the property type: - -```ts -class LaneBusy extends TaggedError("LaneBusy")<{ - lane: string; - operationId: string; - operationKind: "run" | "compaction" | "navigation"; - message: string; -}> {} - -class MissingIdentities extends TaggedError("MissingIdentities")<{ - lane: string; - tools: string[]; - models: string[]; - message: string; -}> {} -``` - -The remaining classes use the same base: - -| class | payload besides `message` | -|---|---| -| `NoActiveRun` | `lane` | -| `NoActiveOperation` | `lane` | -| `NothingToResume` | `lane` | -| `InvalidMessage` | `lane`, `reason` | -| `UnknownSkill` | `name` | -| `UnknownTemplate` | `name` | -| `UnknownTarget` | `targetId` | -| `UnknownQueueItem` | `lane`, `entryId` | -| `LaneExists` | `lane` | -| `InvalidLane` | `lane`, `reason` | -| `NothingToCompact` | `lane` | -| `Closed` | none | - -A transport serializes an error as `{ _tag, message, ...payload }` and reconstructs the class at the proxy boundary. Adding a rejection class changes the corresponding error union. An exhaustive `matchError` call then fails to type-check until its caller handles the new tag. - -An `Err` means the call did not create or accept the requested work. While the harness remains open and writable, every accepted operation resolves with `Ok`, including `aborted`, `failed`, and `suspended`: - -```ts -interface OperationError { - code: string; - message: string; -} - -type RunOutcome = - | { kind: "completed"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } - | { kind: "aborted"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } - | { kind: "failed"; leafId: string; error: OperationError; - finalEntryId?: string; finalMessage?: AssistantMessage } - | { kind: "suspended"; leafId: string; finalEntryId: string; deferred: DeferredHandle }; - -type CompactionOutcome = - | { kind: "completed"; leafId: string; entry: CompactionEntry } - | { kind: "declined"; leafId: string } - | { kind: "aborted"; leafId: string } - | { kind: "failed"; leafId: string; error: OperationError }; - -type NavigationOutcome = - | { kind: "completed"; newLeafId: string | null; summaryEntry?: BranchSummaryEntry } - | { kind: "declined"; leafId: string | null } - | { kind: "aborted"; leafId: string | null } - | { kind: "failed"; leafId: string | null; error: OperationError }; - -type RunRejected = LaneBusy | InvalidMessage | UnknownSkill | UnknownTemplate | Closed; -type CompactionRejected = LaneBusy | NothingToCompact | Closed; -type NavigationRejected = LaneBusy | UnknownTarget | Closed; -type ResumeRejected = LaneBusy | NothingToResume | MissingIdentities | Closed; -type QueueRejected = NoActiveRun | InvalidMessage | Closed; -type CancelQueuedRejected = UnknownQueueItem | Closed; -type AbortRejected = NoActiveOperation | Closed; - -type RunResult = Result<{ runId: string } & RunOutcome, RunRejected>; -type CompactionResult = Result<{ runId: string } & CompactionOutcome, CompactionRejected>; -type NavigationResult = Result<{ runId: string } & NavigationOutcome, NavigationRejected>; -type QueueResult = Result<{ entryId: string }, QueueRejected>; -type CancelQueuedResult = Result<{ - outcome: "cancelled" | "already_consumed" | "already_cleared"; -}, CancelQueuedRejected>; -type RecordUsageResult = Result; -type AbortResult = Result<{ - runId: string; - steer: AgentMessage[]; - followUp: AgentMessage[]; -}, AbortRejected>; - -type ResumeOutcome = - | ({ operation: "run"; runId: string } & RunOutcome) - | ({ operation: "compaction"; runId: string } & CompactionOutcome) - | ({ operation: "navigation"; runId: string } & NavigationOutcome); - -type ResumeResult = Result; - -type CreateLaneResult = Result; -``` - -`cancelQueued` outcomes mirror the mutation-line histories: `cancelled` means the entry will never be appended; `already_consumed` means the entry exists (the model saw or will see it); `already_cleared` means abort drained the item or an earlier cancel won. - -A storage write failure is not an `Err`. It faults the harness and rejects the promise with `HarnessFault`: - -```ts -class HarnessFault extends Error { - readonly cause: unknown; - - constructor(message: string, cause: unknown) { - super(message); - this.name = "HarnessFault"; - this.cause = cause; - } -} - -class HarnessClosed extends Error { - constructor() { - super("AgentHarness was closed while the operation was active"); - this.name = "HarnessClosed"; - } -} -``` - -Calls on a faulted harness reject with the same `HarnessFault` instance until the session is reopened. `close()` rejects process-local promises for accepted operations with `HarnessClosed`; their durable operations remain open and resumable. Result-returning calls made after `close()` return `Err(new Closed(...))`; other calls reject with `HarnessClosed`. An invariant violation also rejects. Promise rejection therefore means a defect or a dead harness, not an expected operation outcome. These errors do not belong to public `Result` error unions. - -`finalMessage` is the run's newest entry that projects to an assistant message; `finalEntryId` is that entry's id. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). The two differ when a deferred write was applied after the final assistant message. Full transcripts are not duplicated into results; they are in the session and were delivered as events. - -**Type provenance.** Core conversation and tool types (`AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, `ThinkingLevel`) come from `packages/agent/src/types.ts`. Provider types (`Model`, `Models`, `Usage`, `RetryPolicy`, stream options, deferred handles) come from `packages/ai`. The generic telemetry contract and schema machinery come from `packages/telemetry`; the AI-request and harness span schemas come from `packages/agent/src/harness/telemetry.ts`. Session, harness, hook, event, result, snapshot, navigation, and durable-record types are defined under `packages/agent/src/harness/`. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. - -### Suspended operations - -```ts -interface SuspendedOperation { - lane: string; - kind: "run" | "compaction" | "navigation"; - id: string; - startedAt: number; // Unix ms, from the operation_started record - reason: "crash" | "deferred"; - prompt?: AgentMessage[]; // runs: normalized original prompt - deferred?: DeferredHandle; // reason "deferred" - aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; // abort accepted pre-crash; - // cleared payloads, offered for requeue - missing: { tools: string[]; models: string[] }; // non-empty: resume() returns Err -} -``` - -### Examples - -```ts -// Interactive pi. suspended has 0 or 1 entries, always "main". -const { harness, suspended } = await AgentHarness.create({ session, models, model }); -for (const s of suspended) await (await harness.lane(s.lane))!.resume(); -await harness.prompt("fix the bug"); -await harness.steer("focus on the tests"); -await harness.setModel(opus); - -// Slack bot. Channel = session + main; thread = lane, keyed by thread id. -const key = `slack:${threadTs}`; -let thread = await harness.lane(key); -if (!thread) { - const created = await harness.createLane(key, pingedEntryId); - if (!created.ok) return handleLaneError(created.error); - thread = created.value; -} -await thread.prompt("summarize this thread"); // parallel to main and other threads -await thread.setModel(haiku); // this thread only -await thread.session.appendMessage(msg); // this thread's branch - -// Thread renderer: this lane only. -const { snapshot, start } = await thread.watch(); -render(snapshot.transcript); -start((event) => update(event)); - -// Deferred run (batch pricing). prompt() parks; a webhook or timer resumes. -const result = await thread.prompt("analyze this mailbox"); -if (result.ok && result.value.kind === "suspended") schedulePoll(thread); -// later: await thread.resume(); - -// Dashboard: inventory + firehose, no transcripts. -const s = await harness.watchSession(); -for (const lane of s.snapshot.lanes) { - if (lane.operation?.status === "suspended") await (await harness.lane(lane.name))!.resume(); -} -``` - -## 9. Snapshots and subscription - -A UI needs current state plus every change after it, with no gap. This includes the transport gap: a server that proxies a harness must deliver the snapshot to its client before any event reaches the wire. `watch()` buffers until the consumer arms delivery: - -```ts -const { snapshot, start, unsubscribe } = await lane.watch(); // harness.watch() = main's - -await send(client, { kind: "snapshot", snapshot }); // snapshot is on the wire -start((event) => send(client, event)); // flush buffer in order, then live -``` - -`watch()` captures the snapshot and starts buffering in one step. `start(listener)` flushes the buffer in order and switches to live delivery. Each event arrives exactly once, in order. No sequence numbers, no registration race. `unsubscribe()` drops the subscription and its buffer; a watcher that never calls `start()` buffers without bound. - -`watch()` is lane-scoped: this lane's transcript, operation state, queues, pending writes, and only this lane's events. A Slack thread renderer sees its thread and nothing else. `watchSession()` is the session-wide observer: lane inventory, no transcripts, unfiltered event stream. A dashboard composes both: `watchSession()` for the overview, `lane.watch()` per opened thread. - -```ts -interface QueuedItem { - entryId: string; // correlates with QueueResult and cancelQueued - message: AgentMessage; -} - -interface LaneSnapshot { - lane: string; - /** This lane's branch, oldest first: the context window plus its - compaction entry. Older history is paged via session queries. */ - transcript: Entry[]; - leafId: string | null; - - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "aborting"; - startedAt: number; // Unix ms - /** status "suspended": everything a client needs to offer resume/abort. - The same data create() returned; a remote UI only sees snapshots. */ - suspended?: SuspendedOperation; - /** Live progress, when mid-turn. What the watcher would have - accumulated from streaming events. */ - streamingMessage?: AssistantMessage; - runningTools: { - toolCallId: string; - toolName: string; - args: unknown; - partialResult?: AgentToolResult; - }[]; - retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; - }; - - queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; - pendingWrites: { id: string; entry: ProvisionedEntry }[]; - - faulted: boolean; // harness-wide, mirrored into every snapshot -} - -interface SessionSnapshot { - lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; - faulted: boolean; -} -``` - -Rules: - -- Configuration is not in snapshots. Getters return the current value; `config_update` events (section 10) tell a UI when to re-read. One source of truth. -- `streamingMessage` and `runningTools` let a client that attaches mid-turn render immediately, without replaying events. -- Reconnect means a new `watch()`. Against a living harness the new snapshot includes live progress. Only process death loses stream state: a restored harness has no partial streams to report, and the snapshot shows the suspended operation instead. The durable transcript is complete either way. Surviving transport drops is the serving layer's job. -- A lane watcher receives the section 10 event vocabulary filtered to its lane, plus harness-global events such as `fault` and `usage`. `watchSession()` and `events.on(type, listener)` receive everything; `events.on` is live-only — no snapshot, no buffer. -- Watchers are independent; each has its own buffer and its own `start()` gate. - -## 10. Events - -One flat stream. `events.on(type, listener)` receives everything; lane watchers receive their lane's events (section 9). - -Guarantees: - -- Passive. A throwing listener is caught and reported as a `handler_error` event plus telemetry; it never affects execution. A listener that throws while handling `handler_error` goes to telemetry only. -- Ordered. Delivery follows process order, identical for watchers and `events.on`. Concurrent lanes do not promise `seq`-ordered passive delivery; durable consumers use `getLog()`. -- Not persisted, not replayed. Reconnect means a new `watch()`. -- Events that report durable facts fire after the fact is committed; what an event announces is already queryable. -- Events report final values, after hook transformation. -- Payloads are JSON-serializable and secret-free; a server can proxy them verbatim. Live objects (models, tools) are referenced by name, never embedded. -- Lane-scoped events carry `lane: string` (omitted below); harness-global events omit it — except `usage`, which is delivered harness-globally and carries the record's lane in its payload. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. - -### Catalog - -```ts -// Run lifecycle -{ type: "run_start"; runId } -{ type: "run_resume"; runId } // resume() entered (any operation kind) -{ type: "run_suspend"; runId; deferred: DeferredHandle } // lane parked -{ type: "run_abort"; runId; steer: AgentMessage[]; followUp: AgentMessage[] } // abort accepted; cleared payloads -{ type: "run_end"; runId; outcome: "completed" | "aborted" | "failed"; - leafId; finalEntryId?; finalMessage?; error? } -{ type: "fault"; code; message } // harness-wide -{ type: "handler_error"; error; stack? } & ({ kind: "hook"; hook } | { kind: "event"; event }) - -// Steps and retries. First-try success emits no retry events. -{ type: "turn_start"; runId; turnId } -{ type: "turn_end"; runId; turnId; message: AssistantMessage; toolResults: ToolResultMessage[] } -{ type: "retry_scheduled"; runId; step; attempt; maxAttempts; delayMs; errorMessage } -{ type: "retry_start"; runId; step; attempt } -{ type: "retry_end"; runId; step; attempt; success: boolean; finalError? } - -// Messages. Every message entering the tree fires these, regardless of -// source. message_end means committed; entryId is the tree entry. -{ type: "message_start"; runId?; message: AgentMessage } -{ type: "message_update"; runId; message: AgentMessage; event: AssistantMessageEvent } // streaming only -{ type: "message_end"; runId?; message: AgentMessage; entryId: string } - -// Tools -{ type: "tool_start"; runId; turnId; toolCallId; toolName; args } // effective args -{ type: "tool_update"; runId; turnId; toolCallId; toolName; partialResult } -{ type: "tool_end"; runId; turnId; toolCallId; toolName; result; isError; terminate } - -// Tree, queues, facts -{ type: "entry_added"; entry: Entry } // non-message entries -{ type: "write_pending"; runId; entryId; entry } // deferred write accepted; entry_added - // or message_end follows with the same id -{ type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] } -{ type: "fact_update" } & ( - | { fact: "name"; name: string } - | { fact: "label"; targetId: string; label: string | undefined }) - -// Configuration. Compact payloads; clients re-read via getters. -{ type: "config_update" } & ( - | { property: "model"; value: { provider; modelId }; previous } - | { property: "thinkingLevel"; value; previous } - | { property: "activeTools"; value: string[]; previous: string[] } - | { property: "tools" | "resources" | "streamOptions" | "retryPolicy" - | "compactionSettings" | "steeringMode" | "followUpMode" }) - -// Structural operations. End events mirror operation outcomes. -{ type: "compaction_start"; runId; reason: "manual" | "threshold" | "overflow" } -{ type: "compaction_end"; runId; reason; outcome: "completed" | "declined" | "aborted" | "failed"; - entry?: CompactionEntry; fromHook: boolean; error? } -{ type: "navigation_start"; runId; targetId } -{ type: "navigation_end"; runId; outcome: "completed" | "declined" | "aborted" | "failed"; - oldLeafId; newLeafId; summaryEntry?; error? } - -// Lanes -{ type: "lane_created"; at: string | null } - -// Cost. Harness-global delivery — every watcher receives it — with the -// record's lane in the payload. totals is the session-wide ledger sum as -// of this commit: stateless consumers render it (seed once via getStats()); -// provenance consumers read the record. Cross-lane delivery is -// process-ordered, not seq-ordered; a rare inversion self-heals on the -// next event. -{ type: "usage"; lane: string; record: UsageRecord; totals: Usage } -``` - -### Nesting - -```text -run_start - turn_start - message_start / message_update* / message_end assistant committed - tool_start / tool_update* / tool_end per call - message_end tool results, source order - turn_end - compaction_start ... compaction_end auto, at a checkpoint, when needed - turn_start ... turn_end until nothing is pending -run_end -``` - -A UI's busy indicator spans `run_start`..`run_end`, and the `compaction_start`/`navigation_start` brackets for standalone operations. Resumed structural operations re-emit their start event (`recovery: true`) so brackets always balance. - -Failed attempts emit `retry_scheduled`, then `retry_start`, then `retry_end` when retrying resolves either way. `run_suspend` ends event flow for the parked lane; the next `run_resume` continues it. - -## 11. Hooks - -Hooks are awaited interception points. Registration mirrors events, with an optional stable registration id: - -```ts -const off = harness.hooks.on("before_tool", async (event) => { - if (event.toolName === "bash") return { block: { reason: "not allowed" } }; -}); - -harness.hooks.on("before_run", async () => ({ - resumeData: { version: 1 }, -}), { id: "extension.example" }); -``` - -Semantics, uniform across all hooks: - -- Registration is harness-global. Every hook event carries `lane` (omitted below); a handler scopes itself. -- `before_run` and `before_resume` registrations require a stable `id`. An id is unique within one hook name; duplicate registration rejects synchronously. The same extension uses the same id for both hooks across restarts. The runner stores each `before_run` handler's `resumeData` under its id and hands each `before_resume` handler only the value under the same id. -- `before_run` runs on the normalized caller prompt, outside the lane mutation line, before acceptance. It does not see captured nextRun items; the acceptance mutation captures those afterwards (section 15). A rejected acceptance (busy lane) discards the hook output. -- Handlers run sequentially in registration order. Each transformation handler sees the output of the previous one; returned `messages` append and a returned `systemPrompt` replaces the current value. -- A throwing handler does not fail the run: it is skipped, reported via `handler_error`, and the remaining handlers run. One exception: `before_tool` fails closed — a throwing handler blocks the tool. A skipped policy handler must not allow a tool it might have blocked. -- Hook results that feed durable state are persisted before execution proceeds: `before_run` output lands in the `operation_started` record, `before_tool` effective arguments in the `tool_started` record, and the finalized `after_tool` result plus `terminate` decision in the tool-result entry. The hook's return alone is not durable; a crash before that commit can run it again. -- Events report post-hook values; observers never see pre-hook state. - -### Catalog - -```ts -// Run boundaries ------------------------------------------------------ - -// Once per run, before acceptance. Not re-run on retry or resume; its -// output is persisted in the operation_started record. -before_run: { - event: { prompt: AgentMessage[]; systemPrompt: string; resources }; - result: { - messages?: AgentMessage[]; // persisted as entries after the prompt - systemPrompt?: string; // persisted override, fixed for the run - resumeData?: JsonValue; // stored under this handler's registration id - } | undefined; -} - -// On resume(), before any effect. Rebuilds process-local extension state. -// Must be idempotent: a crash can rerun it. Cannot rewrite the prompt. -before_resume: { - event: - | { runId; kind: "run"; prepared: { prompt: AgentMessage[]; systemPromptOverride? }; - resumeData?: JsonValue } - | { runId; kind: "compaction" | "navigation"; resumeData?: JsonValue }; - result: void; -} - -// At a normal finish boundary: no tool continuation, no queued messages. -// Returned follow-ups continue the same run; the runner commits them -// conditionally — an abort that wins while the hook runs drops the -// follow-up (section 15). Does not run for abort, terminal failure, or -// exhausted auto-compaction. May fire again after a crash at the same -// boundary; handlers that must not double-fire keep their own durable -// marker. -before_run_end: { - event: { runId; messages: AgentMessage[] }; - result: { followUp?: string } | undefined; -} - -// Request pipeline ---------------------------------------------------- - -// Per request. AgentMessage level, before toProviderMessages. Pruning, -// injection, custom-message handling. Ephemeral: shapes what the provider -// sees, never what the session contains. -transform_context: { - event: { messages: AgentMessage[] }; - result: { messages: AgentMessage[] } | undefined; -} - -// Per request. Provider-neutral request options. -before_request: { - event: { model: Model; step: "assistant" | "compaction" | "branch_summary"; attempt; streamOptions }; - result: { streamOptions?: StreamOptionsPatch } | undefined; -} - -// Per request. Provider-specific wire payload. Last stop. -before_payload: { - event: { model: Model; payload: unknown }; - result: { payload: unknown } | undefined; -} - -// Per response, after the stream finishes, before the assistant message -// is committed. The committed message is what events and the session see. -after_response: { - event: { status: number; headers: Record; message: AssistantMessage }; - result: { message?: AssistantMessage } | undefined; // must keep role -} - -// Tools --------------------------------------------------------------- - -// After validation, before execution. Effective args are persisted in the -// tool_started record. Not re-run for a call whose tool_started exists. -before_tool: { - event: { toolCallId; toolName; args: Record }; - result: { args?: Record; block?: { reason: string } } | undefined; -} - -// After execution, before the result entry is committed. Patch semantics, -// field by field. Runs on safe replay; not on synthetic results. -after_tool: { - event: { toolCallId; toolName; args; content; details; isError; usage? }; - result: { content?; details?; isError?; usage?; terminate?: boolean } | undefined; -} - -// Structural operations ------------------------------------------------ - -// Decline, adjust, or supply the summary. Runs after operation_started, -// live and on resume alike. Not re-run when the result entry exists or -// any step_attempt for this work already exists (hook-written or generated -// — records cannot distinguish them, and neither needs the hook again). -before_compaction: { - event: { reason: "manual" | "threshold" | "overflow"; preparation: CompactionPreparation; customInstructions? }; - result: { decline?: boolean; compaction?: CompactResult } | undefined; -} - -before_navigation: { - event: { targetId; preparation: NavigationPreparation }; - result: { decline?: boolean; summary?: { summary: string; details?; usage? } } | undefined; -} -``` - -### Replay across retry and resume - -Hooks re-run only where the work itself re-runs. Persisted outputs are never recomputed. - -| hook | fresh | retry | resume | -|---|---|---|---| -| `before_run` | once | no | no (persisted) | -| `before_resume` | no | no | yes, idempotent | -| `transform_context`, `before_request`, `before_payload` | per request | yes | yes | -| `after_response` | per response | per response | per response | -| `before_tool` | per call | — | not when `tool_started` exists | -| `after_tool` | per executed result | — | on safe replay only | -| `before_compaction`, `before_navigation` | per operation | no | not when a result entry or any `step_attempt` for this work exists | -| `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | - -## 12. Session and SessionTree - -### Entries - -The tree content. No other entry types exist; pointers and global facts are not entries (section 2). - -```ts -interface EntryBase { - type: string; - id: string; - seq: number; // shared sequence; read-side, storage-assigned - parentId: string | null; // storage-assigned: the appending lane's leaf - timestamp: number; // Unix ms, storage-assigned -} - -interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; - terminate?: true } -interface ModelChangeEntry extends EntryBase { type: "model_change"; provider: string; modelId: string } -interface ThinkingLevelEntry extends EntryBase { type: "thinking_level_change"; thinkingLevel: string } -interface ActiveToolsEntry extends EntryBase { type: "active_tools_change"; activeToolNames: string[] } -interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; - retainedTail: AgentMessage[]; - tokensBefore: number; details?; usage? } -interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; summary: string; - details?; usage? } -interface CustomEntry extends EntryBase { type: "custom"; customType: string; data? } - -type Entry = MessageEntry | ModelChangeEntry | ThinkingLevelEntry | ActiveToolsEntry - | CompactionEntry | BranchSummaryEntry | CustomEntry; -``` - -A harness-written assistant `MessageEntry` always contains a `SettledAssistantMessage`; `pending` is rejected before any durable write. A v4 tool-result `MessageEntry` additionally persists the finalized batch-control decision as `terminate?: true` beside `message`. It is orchestration state for the reduction (section 7), never model context; the projection to provider messages ignores it. `AgentToolResult.terminate` exists at the tool API level but `ToolResultMessage` does not carry it, so the entry field is the durable form. - -Every v4 compaction — generated or hook-supplied — stores the complete `retainedTail`; an empty tail is `[]`, never omission. The compaction entry is a self-contained checkpoint: context builds never read past it. Entry `usage` fields — on assistant messages, tool results, compactions, and branch summaries — are immutable display snapshots of the response(s) that produced that entry: a message entry matches its one producing record; a compaction or branch-summary entry carries its successful attempt's request(s), never failed attempts. The durable ledger is the `usage` records; effective cost including later adjustments is a read-time ledger query by `entryId` (sections 5, 13). - -v3 files additionally contain `custom_message`, `label`, `session_info`, and `leaf` entries, plus old compaction entries that use `firstKeptEntryId`. Load normalizes them before exposing the v4 tree: - -- `custom_message` becomes a custom agent message. -- `label` and `session_info` become global facts (latest by file position wins) and disappear from the logical tree. A label targets its nearest retained parent. -- `leaf` entries disappear; `main`'s leaf resolves through the last `leaf` entry, then to the nearest retained ancestor if that target was discarded. -- Each retained child of a discarded entry is reparented to the discarded entry's nearest retained ancestor. -- An old compaction resolves `firstKeptEntryId` against its own branch and materializes that range as `retainedTail`. V4 never exposes or persists `firstKeptEntryId`. -- v3 entry timestamps are ISO strings and convert to Unix milliseconds. - -Read-only opens keep the physical v3 file unchanged; the first v4 write persists the normalized form (section 13). - -### SessionTree - -The tree-facing contract. Each lane exposes one view (`lane.session`); `Session` itself implements it for `main`. Reads pass through always. A write through a lane view enters that lane's mutation line: while a run is open — including suspension and cancellation — it becomes a durable deferred write; during compaction or navigation it waits for the operation to end; on an idle lane it appends directly. Writes on a standalone `Session` (no harness attached) apply immediately. - -```ts -interface EntryQuery { - type?: Entry["type"]; - customType?: string; // for type "custom" - order?: "newestFirst" | "oldestFirst"; // default newestFirst - limit?: number; - cursor?: EntryCursor; -} - -/** Bounds of a branch scan. Default: the whole path, leaf to root. */ -interface BranchBounds { - start?: string; // default: the view's lane leaf - stopAtType?: Entry["type"]; // scan ends after the first match, inclusive - stopAtId?: string; -} - -interface SessionTree { - getLeafId(): Promise; - getEntry(id: string): Promise; - getStats(): Promise; - - // Global facts. Latest wins; not branch-scoped. "set", not "append": - // append vocabulary is reserved for tree writes. - getName(): Promise; - setName(name: string): Promise; - getLabel(targetId: string): Promise; - setLabel(targetId: string, label: string | undefined): Promise; - - /** Session-wide, all branches, sequence order. */ - findEntries(query?: EntryQuery): Promise; - findEntry(query?: EntryQuery): Promise; - - /** Branch-scoped: the path from start toward root. */ - findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise; - findEntryOnBranch(query?: EntryQuery & BranchBounds): Promise; - - // Writes. Resolve on durable acceptance; the returned id is the entry's - // id (provisioned when the write defers). - appendMessage(message: AgentMessage): Promise; - appendCustomEntry(customType: string, data?: unknown): Promise; -} -``` - -Query semantics: a branch scan takes the path from `start` to root, walks it in `order` direction, stops after a `stopAt` match (inclusive), filters, then applies `limit` and `cursor`. - -- `newestFirst` with `stopAtType: "compaction"` ends at the newest compaction: the context window. -- `type` and `customType` filter results; a `stopAt` entry is returned only if it passes the filter. -- Extension patterns: effective state = `findEntryOnBranch({ type: "custom", customType })`; collections = `findEntriesOnBranch(...)`; global inventory = `findEntries(...)`. -- Context build is a branch scan with `stopAtType: "compaction"`, projected through `entryProjectors` and `toProviderMessages`. Its projection is the compaction summary, the materialized `retainedTail`, then the entries after the compaction; nothing before the compaction is read. -- `SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. - -Read consistency: finders and `getEntry` return committed entries only. A deferred write is not in the tree until applied; a handler that appends and immediately queries does not see its own write. Pending writes are visible in the snapshot, correlated by provisioned id. - -### Session - -`Session` adds the lane surface and the record log. It is usable standalone — no harness required. In production the harness writes records; recovery fixtures and Tier A tests prefill them through the same API. Lanes, entries, and facts are Session-level. - -```ts -class Session implements SessionTree { // bound to "main" - constructor(storage: SessionStorage, options?: { idGenerator?: IdGenerator }); - /** Process-local id provisioning used by Session and harness. Default - UUIDv7; tests inject a deterministic generator. Sync by design. */ - readonly idGenerator: IdGenerator; - - /** SessionTree bound to a lane: reads default to its leaf, appends chain - to it and advance it. The only write-binding mechanism; no SessionTree - method takes a lane parameter. view("main") behaves like the Session. */ - view(lane: string): SessionTree; - - // Lanes — permanent named pointers. Durable via storage (section 13). - getLanes(): Promise<{ lane: string; leafId: string | null }[]>; - createLane(lane: string, at: string | null): Promise; // rejects existing names - moveLane(lane: string, to: string | null): Promise; - - /** Low-level provisioned append for the harness, recovery, and test - fixtures. Bypasses the SessionTree deferral policy; a harness caller - already holds the lane mutation line. */ - appendEntry(entry: ProvisionedEntry, lane: string): Promise; - - // Records — harness and recovery write these; applications may append - // usage adjustment records (section 5) and nothing else. - appendRecord(record: NewRecord): Promise; - findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - findRecords(query?: RecordQuery): Promise; - /** Unfinished operation starts, newest first. limit: 2 distinguishes the - valid zero/one states from multiple-open-operation corruption. */ - findOpenOperations(lane: string, options?: { limit?: number }): Promise; - /** Full chronological view: entries, records, facts, lane moves, - merged by seq. Debugging and tests. */ - getLog(options?: { afterSeq?: number; limit?: number }): Promise; -} - -interface IdGenerator { next(): string; } - -interface RecordQuery { - lane?: string; - type?: LaneRecord["type"]; - runId?: string; - /** Valid only with type "operation_started". */ - operationKind?: OperationStartedRecord["intent"]["kind"]; - afterSeq?: number; - order?: "oldestFirst" | "newestFirst"; - limit?: number; -} -``` - -`Session` exposes no `getStorage()` escape hatch: all writes flow through `Session`, which is the single writer the storage contract assumes. - -**Ownership rule:** after an application passes a `Session` to `AgentHarness.create()`, it mutates that session only through the harness and its lane views until `close()` resolves. Concurrent writes through the original standalone reference are unsupported caller misuse; the harness adds no machinery for it. - -## 13. Storage - -### Contract - -One session per storage instance. Storage persists and answers queries; `Session` owns validation and view binding. Storage never executes operations, queues, or recovery. Record payloads are opaque except for indexed columns and the required open-operation recovery projection. - -```ts -interface SessionStorage { - getMetadata(): Promise; - - // Lanes - getLanes(): Promise<{ lane: string; leafId: string | null }[]>; - createLane(lane: string, at: string | null): Promise; - moveLane(lane: string, to: string | null): Promise; - - /** Durable on resolve. Input carries no parentId, seq, or timestamp; - storage assigns all three. parentId is the lane's current leaf; the - entry becomes the lane's new leaf, in the same transaction. Callers - cannot pass a stale parent because they never pass one. */ - appendEntry(entry: ProvisionedEntry, lane: string): Promise; - appendRecord(record: NewRecord): Promise; - - // Reads - getEntry(id: string): Promise; - findEntries(query?: EntryQuery): Promise; - /** start is mandatory here; defaulting to a lane's leaf is view sugar. */ - findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise; - findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - findRecords(query?: RecordQuery): Promise; - findOpenOperations(lane: string, options?: { limit?: number }): Promise; - getLog(options?): Promise; - - // Global facts - getName(): Promise; setName(name: string): Promise; - getLabel(id: string): Promise; setLabel(id, label): Promise; - getStats(): Promise; -} -``` - -Contract rules, all backends: - -- One monotonic `seq` across entries, records, facts, and lane moves. -- Storage linearizes concurrent writes from all lanes of the session and allocates `seq` inside each write's atomic commit; callers never read, reserve, or increment the sequence. Write promises resolve in commit order. The lane mutation line (section 15) serializes decisions; this rule serializes the writes underneath them — both are needed, neither replaces the other. -- A write is durable when its promise resolves; events fire after. -- `Session` and the harness provision ids with `session.idGenerator`; storage enforces per-session uniqueness at append. -- Every durable payload must be JSON-serializable. `Session` validates before dispatch so Memory, JSONL, and SQLite accept the same values; Memory does not retain values JSONL would reject. -- Reads return immutable data. -- `findOpenOperations` is a required recovery projection: Memory maintains it with its record state, JSONL derives it while replaying the file, and SQLite answers it from the lane's current open-operation projection. It returns unfinished starts newest first and must expose a second result when a replayed/imported backend observes multiple open operations so recovery can reject corruption. Backends with conditional current-state projections may reject a second `operation_started` append instead of creating that corruption through their normal write API. -- No general conditional writes exist. Single-writer plus the lane mutation line make compare-and-set unnecessary for normal appends and pointer/fact updates. The lane open-operation projection is the narrow exception: starting an operation conditionally sets the lane's open operation from `null` to the run id, and a failed update means the lane is already busy. -- One writer per session, enforced by the serving layer; SQLite additionally rejects a second writer itself. Per session, not per backend: one SQLite database hosts many sessions, each with its own single writer. -- Any write failure faults the harness (section 4). The store is left a valid prefix. -- Global-fact and lane-move history is kept, never rewritten: latest by `seq` wins. History is the cheaper implementation (insert, never update), and lane-move history is a reflog if anyone ever wants one. -- For format-4 sessions, the token and cost fields returned by `getStats()` are the sum of `usage` records across all lanes — one rule, no entry-derived billing, and no double counting by construction. `messageCount` counts all message entries in the session tree, including entries copied into a fork. A fork initializes the count from its copied entries, then increments it for newly appended message entries. Backends maintain both as running projections, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their usage stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. - -### Memory - -Plain structures: entry map, record list, lane map, fact lists, one seq counter, one session-wide write queue. Append validates, clones, allocates `seq` at the head of that queue, commits; reads clone out. The reference implementation: the parity test suite runs against it first. - -### JSONL - -The concrete repository is `JsonlSessionRepo`. Its metadata and options extend the backend-neutral contracts: - -```ts -interface JsonlSessionMetadata extends SessionMetadata { - cwd: string; - path: string; - modifiedAt: number; // filesystem mtime used for listing order - sourceFormat: 3 | 4; - /** Present only when a v3 parent path could not yet be resolved to an id. */ - legacyParentSessionPath?: string; -} -interface JsonlSessionCreateOptions extends SessionCreateOptions { - cwd: string; - metadata?: Record; -} -interface JsonlSessionListOptions { cwd?: string; } -``` - -A v3 `parentSession` path resolves to the parent header's id when that file is available. If it is unavailable, metadata retains `legacyParentSessionPath`; first-write conversion preserves that optional header field rather than silently dropping the relationship. Format-4 code uses `parentSessionId` for repository relationships. `modifiedAt` is read from the filesystem and is not a sequenced session mutation. - -The repository layout matches coding-agent v3. Under `sessionsRoot`, each resolved cwd uses a directory named `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`. New files are named `${createdAtIso.replace(/[:.]/g, "-")}_${sessionId}.jsonl`. `list({ cwd })` scans that cwd's directory; `list()` scans every direct child directory. First-write v3 conversion replaces the original file in place and never changes its directory or filename. - -One file per session: a header line, then one JSON object per line, in `seq` order. Every logical mutation is exactly one line; a line is the atomic unit. - -```text -{"kind":"header", "version":4, id, createdAt, cwd, parentSessionId?, legacyParentSessionPath?, metadata?} -{"kind":"entry", "lane":"main", id, parentId, type, timestamp, ...} // append; advances main -{"kind":"entry", id, parentId, type, timestamp, ...} // fork import; advances no lane -{"kind":"record", "lane":"main", id, runId?, type, timestamp, ...} -{"kind":"lane", "lane":"slack:t1", "leafId":"e42"} // create or move -{"kind":"fact", "fact":"name", "name":"Refactor auth"} -{"kind":"fact", "fact":"label", "targetId":"e17", "label":"checkpoint"} -``` - -- Open reads the whole file into memory; all queries run against that state. One session-wide append queue serializes writes from every lane, one line each; the queue allocates `seq`, and its order is the line order. Every storage mutation in this section is exactly one line — nothing in the design needs a multi-line atomic write. -- The repository does not retain created or opened storage instances. It knows how to locate and load sessions, then transfers each storage and its write queue to the returned `Session`. Reopening loads a fresh storage instance; the serving layer's single-writer ownership rule prevents concurrent opens for writing. Repository operations are not serialized, so callers await operations with ordering dependencies. -- The optional `lane` on an entry line is envelope metadata and dies at decode. When present, the line atomically appends the entry and advances that lane; replay requires `parentId` to equal its current leaf. When absent, the line imports a fork entry without moving a lane. Entries expose `seq` but no lane. -- Torn tail: a malformed final line is the append that died mid-write. Open truncates it; the write was never acknowledged, nothing is lost. A malformed line anywhere else is corruption; open rejects. -- Durability is process-crash level: a resolved append call. No fsync promise; if power-loss durability is ever needed, it becomes an explicit capability. -- v3 files: entries only, no `kind` tags. Open builds the normalized logical tree from section 12; every entry belongs to `main`, and `main`'s leaf resolves through the last `leaf` entry to its nearest retained ancestor. Before the first v4 append, the file is rewritten once with a v4 header (write temp, rename). This is the single conversion the compatibility policy allows. Read-only opens never rewrite. - -### SQLite - -SQLite uses a greenfield schema with one persisted leaf per lane. - -```sql -session_sequences (session_id, next_seq) -- atomic seq allocator -entries (session_id, seq, id, parent_id, type, timestamp, payload) -records (session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload) -lanes (session_id, lane, leaf_id, open_operation_id) -- current pointer + open op projection -lane_moves (session_id, seq, lane, leaf_id) -- history; getLog parity -facts (session_id, seq, kind, key, value) -- name, labels; latest by seq -branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) -branch_tips (session_id, branch_id, tip_id) -- PRIMARY KEY (session_id, tip_id) -writer_leases (session_id, owner_id, fence, expires_at_ms) -- writer claim - --- indexes -records: (session_id, lane, type, seq), (session_id, lane, type, op_kind, seq) -branch_entries: (session_id, branch_id, entry_type, entry_seq) - (session_id, entry_id) -- reverse lookup: entry → branches -``` - -`writer_leases` enforces one writer per session with expiring, fenced claims. Storage renews the claim inside every write transaction and while idle. Repository-owned cleanup releases only its matching owner and fence. - -`open()` acquires that writer claim. `list()` never acquires or renews writer leases: it reads every matching session directly from the session catalog and projects the latest name fact into the top-level `SqliteSessionMetadata.name` field for server-side inventory. Application-owned `SqliteSessionMetadata.metadata` remains unchanged. - -`branch_entries` and `branch_tips` are a private read cache. No interface exposes them; no other backend has them; rebuilding them from parent pointers is an explicit repair operation, never a runtime fallback. - -Two invariants carry the whole design: - -- **Every entry is in at least one branch.** Every append inserts its entry into a branch (extend or copy, below). A branch holds a full root path; below any entry it contains, it agrees with every other branch containing that entry, because parent chains are unique. -- **Tips are unique.** A branch only ever ends in the entry that was just created — extension and copy both place a brand-new entry at the end — so no two branches share a tip. `branch_tips` answers "does a branch end at X" with one point lookup, 0 or 1 rows. - -**Read plan** — `findEntriesOnBranch({ start })`, any entry, tip or not: - -1. Reverse index: look up `start` → any containing branch. -2. Range scan that branch, `entry_seq <= start.seq` (parent-before-child makes path order equal seq order), join entries, apply filters and stops. - -**Append plan** — `appendEntry(entry, lane)`, one transaction. The storage instance queues writes before opening the transaction; the transaction increments the session's sequence row and uses the returned value, so concurrent lane calls cannot receive the same `seq` and their promises resolve in that order. - -1. `leaf = lanes[lane].leaf_id`; allocate `seq` from `session_sequences`; insert the entry with `parent_id = leaf`. -2. `branch_tips` lookup: does a branch end at `leaf`? - - Yes → insert one `branch_entries` row there; update that tip to the new entry. - - No → new branch: copy rows `entry_seq <= leaf.seq` from any branch containing `leaf`, insert the new entry's row, insert its tip. (Empty lane: no copy, just the new branch.) -3. `lanes[lane].leaf_id = entry.id`. Update fact/stats projections. Commit, then events. - -The four cases, `Bn: [...]` are one branch's rows in seq order: - -```text -Case 1 — plain append. The overwhelmingly common case: one lookup, one row. - - tree: a(1)─b(2)─c(3) lanes: main→c cache: B1:[a b c] - main appends d(4): a branch ends at c → extend - tree: a─b─c─d lanes: main→d cache: B1:[a b c d] - -Case 2 — two lanes, one leaf. First extends, second copies. - - lanes: main→c, t1→c cache: B1:[a b c] - t1 appends u(4): B1 ends at c → extend B1:[a b c u] - (B1 now runs past main's leaf — harmless: main's reads stop at seq ≤ 3) - main appends d(5): no branch ends at c → copy B2:[a b c d] - tree: a─b─c─u lanes: main→d, t1→u - └─d - -Case 3 — lane parked mid-history. createLane("t2", at=b), then append. - - lanes: main→d, t2→b cache: B1:[a b c u], B2:[a b c d] - t2 reads: b found in B1 (or B2), scan seq ≤ 2 — nothing built - t2 appends x(6): no branch ends at b → copy B3:[a b x] - -Case 4 — a branch still ends at an entry that has children. - - From case 2: B1:[a b c u], B2:[a b c d]; t1 navigates away, main navigates to c. - main appends e(7): c has children (u, d) — but the tip test asks the - right question: does a branch END at c? No → copy. - If instead a branch DID end there (its continuation had gone to another - branch's copy), the tip test extends it — one row instead of a path copy. - The has-children test would copy needlessly; the tip test never does. -``` - -Stale branches (no lane resolves through them) are kept. - -Every restore query is an index seek plus a bounded scan: a lane's open operation via `(lane, type, seq)`, its last run-kind start via `(lane, type, op_kind, seq)`, its records above the operation via the same index, its own entries via the read plan from its leaf. No query touches another lane's traffic. - -## 14. Agent-loop building blocks - -`agent-loop.ts` exposes building blocks that own no durable state and know nothing about sessions, records, or lanes. The harness composes them and inserts durability writes between their phases. - -### Streaming one assistant response - -```ts -export interface StreamAssistantConfig { - model: Model; - systemPrompt?: string; - tools?: AgentTool[]; - /** AgentMessage[] → AgentMessage[]. Pruning, injection. */ - transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - /** AgentMessage[] → provider messages. */ - toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; - /** Dispatch. models.streamSimple resolves auth per request (credential - store, expiring tokens, header merge, env, baseUrl) — no auth surface - on this config. streamFn overrides dispatch for tests. */ - models: Models; - streamFn?: StreamFn; - /** SimpleStreamOptions carries apiKey/headers/env overrides, transport, - timeouts, metadata, deferred — and onPayload/onResponse, the mounting - points for the before_payload and after_response hooks. */ - streamOptions?: SimpleStreamOptions; - /** Explicit parent for request telemetry. Section 18. */ - telemetryContext: TelemetryContext; - signal?: AbortSignal; -} - -/** One provider request. Emits message_start / message_update / message_end - to the sink; returns the final assistant message. Provider errors are - in-band: stopReason "error" | "aborted" | "deferred". Does not mutate - its inputs — persistence is the caller's job. */ -export function streamAssistant( - messages: AgentMessage[], - config: StreamAssistantConfig, - emit: AgentEventSink, -): Promise; -``` - -### Tool execution - -Tools declare recovery safety. Omission means `"never"`: - -```ts -interface AgentTool { - replay?: "never" | "safe"; - // existing fields -} -``` - -Three phases per call, exposed separately because the harness needs to write between them and recovery needs phase 2 and 3 without phase 1: - -```ts -type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; tool: AgentTool; args: unknown }; -type ImmediateOutcome = { kind: "immediate"; result: AgentToolResult; isError: true }; - // unknown tool, invalid args, blocked, aborted -type FinalizedToolCall = { toolCall: AgentToolCall; result: AgentToolResult; isError: boolean }; - -/** Phase 1 — clearance. Tool lookup, prepareArguments, schema validation, - beforeToolCall (may replace args or block), validation of replacement - args, abort checks. No effect starts here. */ -export function prepareToolCall( - toolCall: AgentToolCall, tools: AgentTool[], callbacks: ToolCallbacks, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise; - -/** Phase 2 — the effect. Streams tool_execution_update via the sink and - drains pending update events before resolving. Never throws; failures - become error results. */ -export function executeToolCall( - prepared: PreparedToolCall, emit: AgentEventSink, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise<{ result: AgentToolResult; isError: boolean }>; - -/** Phase 3 — afterToolCall patch, field by field; a throwing callback - becomes an error result. */ -export function finalizeToolCall( - prepared: PreparedToolCall, executed: { result; isError }, callbacks: ToolCallbacks, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise; - -/** content ?? [] normalization, addedToolNames passthrough, timestamp. */ -export function createToolResultMessage(finalized: FinalizedToolCall): ToolResultMessage; -export function createErrorToolResult(text: string): AgentToolResult; - -export interface ToolCallbacks { - beforeToolCall?(call, args, signal): Promise<{ - args?: Record; - block?: { reason: string }; - } | undefined>; - afterToolCall?(call, args, result, isError, signal): Promise; - /** Between phases 1 and 2: the durability point. The harness writes its - tool_started record here. Called in source order in both modes — - preparation is always sequential. */ - onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; - /** After phase 3, before the result message is emitted; source order. - The harness appends the result entry here, persisting the finalized - terminate decision on it (section 12). */ - onToolResult?(message: ToolResultMessage, terminate: boolean): Promise; -} - -/** Batch-driver rules: - - stopReason "length" fails every call without executing: streamed - arguments are salvage-parsed and can validate while silently - truncated; none are safe. - - Mode: sequential when options.toolExecution === "sequential" or when - any called tool declares executionMode "sequential"; else parallel. - - Parallel mode: phase 1 and onToolStart run sequentially in source - order; phase 2 runs concurrently; phases 3, onToolResult, and message - emission happen in source order after all executions settle. - - Abort: no further calls are prepared; already-executing calls settle. - - terminate: true when every finalized result sets terminate. */ -export function executeToolBatch( - assistant: AssistantMessage, tools: AgentTool[], callbacks: ToolCallbacks, - options: { toolExecution?: "sequential" | "parallel" }, emit: AgentEventSink, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise<{ messages: ToolResultMessage[]; terminate: boolean }>; -``` - -### Compatibility wrapper - -The existing public interface of `agent-loop.ts` does not break. Every export keeps its signature and behavior: `agentLoop`, `agentLoopContinue`, `runAgentLoop`, `runAgentLoopContinue`, `AgentEventSink`, and the config surface they consume (`getSteeringMessages`, `getFollowUpMessages`, `prepareNextTurn`, `shouldStopAfterTurn`, `beforeToolCall`, `afterToolCall`, event order included). They compose `streamAssistant` and `executeToolBatch` with the no-op `TelemetryContext` — no durability, no new semantics. The existing `agent-loop` and `agent` test suites pass unchanged. - -## 15. Harness internals - -The code below is the specification of harness behavior, composed from the section 14 blocks. Live calls and resume run the same procedures: `prompt()` runs `runProcedure()` after acceptance; `resume()` runs it with the operation already recorded. Everything is lane-scoped; procedures of different lanes run concurrently and meet only at the storage append path. - -Part III adds no new durability semantics over Part II. It adds two mechanisms: the **effects boundary**, which makes every crash site steppable, and the **lane mutation line**, which closes the check-then-act races between a running procedure and the public lane surface. - -### The effects boundary - -Every effect a procedure performs goes through one injected `Effects` handle, `fx`. In `drive: "automatic"` the handle passes straight through to the session, the models, the tools, and the hook runner. In `drive: "manual"` the same handle is wrapped in a gate (below). The method list is the complete crash-site catalog: stopping before or after one of these calls is exactly a section 6 X state. - -```ts -interface Effects { - // Durable writes. Each validates and commits at the head of the lane's - // mutation line (below), then updates LaneState. - appendEntry(entry: ProvisionedEntry, telemetryContext: TelemetryContext): Promise; - appendRecord(record: NewRecord, telemetryContext: TelemetryContext): Promise; - moveLane(to: string | null, telemetryContext: TelemetryContext): Promise; - setFact(fact: FactWrite, telemetryContext: TelemetryContext): Promise; - - // Conditional commits. Decision and write in one mutation-line job. - tryFinishRun(runId: string, outcome: "completed" | "failed", - telemetryContext: TelemetryContext, - error?: OperationError): Promise<"finished" | "continue">; - finishOperation(runId: string, outcome: "completed" | "declined" | "failed" | "aborted", - telemetryContext: TelemetryContext, - error?: OperationError): Promise<"finished" | "continue">; - commitRunEndFollowUp(runId: string, item: ProvisionedEntry, - telemetryContext: TelemetryContext): Promise<"committed" | "dropped">; - consumeQueueItem(runId: string, queue: "steer" | "followUp", entryId: string, - telemetryContext: TelemetryContext): Promise<"consumed" | "skipped">; - applyPendingWrite(runId: string, entryId: string, - telemetryContext: TelemetryContext): Promise<"applied" | "skipped">; - - // External effects. - streamAssistant(request: AssistantRequest, - telemetryContext: TelemetryContext): Promise; - executeTool(prepared: PreparedToolCall, - telemetryContext: TelemetryContext): Promise<{ result: AgentToolResult; isError: boolean }>; - fetchDeferred(model: Model, handle: DeferredHandle, - telemetryContext: TelemetryContext): Promise; - cancelDeferred(model: Model, handle: DeferredHandle, - telemetryContext: TelemetryContext): Promise; - - // Interception and time. - runHook(name: K, event: HookEvent, - telemetryContext: TelemetryContext): Promise>; - sleep(delayMs: number, telemetryContext: TelemetryContext): Promise<"elapsed" | "aborted">; -} -``` - -Rules: - -- Reads (`getEntry`, `findEntriesOnBranch`, context building, id allocation) are not effects and never gate. -- **Construction rule:** procedures receive only `fx` plus their current `TelemetryContext` — never the session, models, tools, or hook runner directly. Every `Effects` call receives that context as its final non-payload parameter; section 15 procedure snippets omit repetitive context threading where it would obscure control flow and show it where parentage matters. Tool objects handed to `executeToolBatch` are wrapped so each `execute` routes through `fx.executeTool`; the section 14 callbacks route through `fx.runHook`, `fx.appendRecord`, and `fx.appendEntry`, always with the current scope context. The rule is enforced by construction and by a test: any operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. -- `fx.streamAssistant` wraps section 14 `streamAssistant` with authenticated dispatch through `Models`; `transform_context`, `before_payload`, and `after_response` run inside it via `fx.runHook`. Summary steps force `deferred: false`; a deferred structural result is a defect. -- The `fx` implementation converts a rejected `fetchDeferred` into a `stopReason: "error"` assistant message, so expected provider failures stay in-band. Unexpected rejections from durable writes fault the harness (section 4). - -### The lane mutation line - -Every race in this design has one shape: a decision is made from lane state, an `await` passes, then a durable write commits the stale decision. The fix is structural. Each lane has one process-local FIFO — a promise chain — and every state-dependent decision commits inside one job on it: - -```ts -let tail: Promise = Promise.resolve(); - -function mutateLane(job: () => Promise): Promise { - const result = tail.then(job); - tail = result.then(() => undefined, () => undefined); - return result; -} -``` - -A job is: validate against live `LaneState` → at most one durable write → update `LaneState`. Nothing else. Provider requests, tool executions, hooks, and backoff never run inside a job; they run between jobs, which is exactly why every commit revalidates inside its own job. Because jobs run one at a time, two concurrent operations on a lane have exactly two possible histories — `[A, B]` or `[B, A]` — and both are defined outcomes. No third, interleaved history exists. - -The jobs, by caller: - -- **Lane surface** (ungated, enqueue directly): - - *Operation acceptance* — validate idle, capture the pending `nextRun` items into `initialMessages`, write `operation_started`, set `state.operation`. The second of two concurrent acceptances sees the first and rejects `busy` with no write. `before_run` ran before this job, outside the line, on the prompt only. - - *Queue acceptance* (`steer`, `followUp`) — validate an active, non-aborting run; write `queue_enqueued`. `nextRun` validates nothing and always accepts. - - *Queue cancellation* (`cancelQueued`) — no `queue_enqueued` for the id: `Err(UnknownQueueItem)`; target entry exists: `already_consumed`; not pending (abort-drained or already cancelled): `already_cleared`; else write `queue_cancelled` and remove the item from its pending set. - - *Deferred-write acceptance* (lane-view writes, config setters) — run open: write `write_deferred`; structural operation open: wait for it to end, then re-enter; idle: append the entry directly. - - *Abort* — write `abort_requested`, set `aborting`, drain `pendingSteer`/`pendingFollowUp` (payloads return to the abort caller and in the `run_abort` event), signal the active effect's `AbortController`. - - *Resume admission* — reserve the lane's single execution slot; no write. -- **Procedure via `fx`** (gated in manual mode): - - `tryFinishRun` — if aborting or anything pending, write nothing and return `"continue"`; else write `operation_finished` and idle the lane. - - `consumeQueueItem` — if the item is still pending and the run is not aborting, append its entry and remove it; else `"skipped"`. - - `applyPendingWrite` — same shape for deferred writes; they apply even while aborting. - - `commitRunEndFollowUp` — write `queue_enqueued` only while the run is active and non-aborting; else `"dropped"`. - - `finishOperation` — terminal record unless preempted: a non-abort outcome returns `"continue"` when an abort marker exists; an `"aborted"` outcome returns `"continue"` while deferred writes are still pending, so reconciliation applies them first. - - Plain `appendEntry`/`appendRecord`/`moveLane`/`setFact` — unconditional single writes, still serialized by the line. - -Two examples, both orders legal, nothing else possible: - -```text -steer vs finish abort vs before_run_end follow-up -[steer, finish]: [abort, commit]: - queue_enqueued; pendingSteer=[x] abort_requested; queues drained - tryFinishRun → "continue" commitRunEndFollowUp → "dropped" - run consumes the steer reconciliation; no record after abort -[finish, steer]: [commit, abort]: - operation_finished; lane idle queue_enqueued committed - steer → NoActiveRun, no write abort drains it; payload returned -``` - -### Race catalog - -The complete list. Each row names the two legal histories and the jobs that force them. Tier C (section 19) tests both orders of every row. - -| # | race | histories | mechanism | -|---|---|---|---| -| 1 | `prompt()` vs `prompt()` | one accepted; other `busy`, no write | acceptance job | -| 2 | `steer`/`followUp` vs run finish | consumed at a checkpoint · `NoActiveRun` | queue acceptance + `tryFinishRun` | -| 3 | deferred write vs run finish | applied before close · idle direct append | write acceptance + `tryFinishRun` | -| 4 | abort vs run finish | reconciliation, outcome `aborted` · `NoActiveOperation` | abort job + `tryFinishRun` | -| 5 | abort vs queue consumption | entry appended, not in abort payload · returned by abort, skipped | `consumeQueueItem` + abort drain | -| 6 | abort vs `before_run_end` follow-up | committed then drained by abort · dropped, nothing behind the marker | `commitRunEndFollowUp` | -| 7 | `nextRun` vs acceptance | captured by this run · belongs to the next | capture inside acceptance | -| 8 | deferred write vs abort close | applied during reconciliation · applied before it | `finishOperation("aborted")` loops | -| 9 | config/tree write vs acceptance snapshot | committed before the run's first request · deferred write | both are line jobs; snapshots read after acceptance | -| 10 | abort vs in-flight provider/tool effect | effect settles · effect interrupted | irreducible: signal cancellation; only the procedure commits results (abort path owns synthetics) | -| 11 | cross-lane writes | any interleaving | storage `seq` linearization (section 13); lanes share no state | -| 12 | `cancelQueued` vs consumption | consumed first: `already_consumed` · cancelled first: consumption skips, the model never sees it | cancel job + `consumeQueueItem` | - -Row 10 is the one race no ordering can remove: an external effect may have happened even though its result never arrived. The design's answer is the section 5 intent record plus the replay policy — the same answer as for a crash. - -### Drive modes - -`drive: "automatic"` passes `fx` through; zero overhead. `drive: "manual"` wraps the operation's `fx` in a gate: every method call parks before executing and surfaces a JSON-safe description. - -```ts -type ActionInfo = - | { kind: "append_entry"; entryType: Entry["type"]; entryId: string } - | { kind: "append_record"; recordType: LaneRecord["type"] } - | { kind: "move_lane"; to: string | null } - | { kind: "set_fact"; fact: "name" | "label" } - | { kind: "try_finish_run"; outcome: "completed" | "failed" } - | { kind: "finish_operation"; outcome: "completed" | "declined" | "failed" | "aborted" } - | { kind: "commit_follow_up" } - | { kind: "consume_queue_item"; queue: "steer" | "followUp"; entryId: string } - | { kind: "apply_pending_write"; entryId: string } - | { kind: "stream_assistant"; step: "assistant" | "compaction" | "branch_summary"; attempt: number } - | { kind: "execute_tool"; toolCallId: string; toolName: string } - | { kind: "fetch_deferred" | "cancel_deferred"; provider: string; id: string } - | { kind: "hook"; name: HookName } - | { kind: "sleep"; delayMs: number }; -``` - -```ts -class GatedEffects implements Effects { - private readonly queue: { info: ActionInfo; release: () => Promise }[] = []; - - private gate(info: ActionInfo, run: () => Promise): Promise { - return new Promise((resolve, reject) => { - this.queue.push({ - info, - release: async () => { await run().then(resolve, reject); }, - }); - this.arrived(); // wakes a pending driver - }); - } - - appendRecord(record: NewRecord, telemetryContext: TelemetryContext) { - return this.gate({ kind: "append_record", recordType: record.type }, - () => this.inner.appendRecord(record, telemetryContext)); - } - // ... one wrapper per method -} -``` - -The public controls, on the lane (section 8): - -- `peekAction()` resolves with the description of the next parked call, or `undefined` when no operation exists or the operation has settled. No side effect; calling it twice returns the same action. -- `executeAction()` releases exactly the parked call `peekAction()` describes. It then waits until that call settles, the operation settles, or the released call parks a nested action; it returns the next parked action or `undefined`. It never releases two actions. -- `runToCompletion()` releases until the operation settles. -- Two concurrent drivers are a programmer defect, as is calling the controls in automatic mode. - -Semantics that make tests deterministic: - -- The gate is reentrant. A released action may call another `fx` method — notably `transform_context`, `before_payload`, and `after_response` hooks reached inside `stream_assistant`. The nested call parks as its own action. The driver observes and releases it before the outer action can continue; it never waits for the outer action while hiding the nested park. Every hook therefore remains an independent crash boundary without deadlocking manual drive. -- The gate serializes. Parallel tool batches issue phase-2 calls in source order (phase 1 is sequential, section 14); the gate parks them as separate `execute_tool` actions and manual mode runs them one at a time. Parallelism is a production optimization; source-ordered finalization already fixes the semantics, so automatic and manual modes produce the same durable log. -- The lane surface stays ungated. While the procedure is parked, a test calls `steer()`, `abort()`, `session.appendMessage()` — their jobs run on the mutation line immediately. Both orders of every race-catalog row are constructed by choosing whether to call the surface method before or after `executeAction()`. -- `close()` while parked: every parked call rejects with `HarnessClosed`, the local operation promise rejects, nothing else commits. The durable state is exactly the prefix of released effects — the definition of a crash site. Reopen the backend and `resume()` runs ordinary section 7 recovery. In automatic mode `close()` signals the in-flight effect, waits for the append in progress, and releases the writer claim; open operations stay resumable either way. - -### Live lane state - -```ts -interface EffectiveLaneConfiguration { - model: { provider: string; modelId: string }; - thinkingLevel: ThinkingLevel; - activeToolNames: string[]; -} - -interface TerminalFailureState { - entryId: string; - source: "step" | "deferred_fetch"; - message: AssistantMessage; -} - -/** In-memory orchestration state per lane. Always equal to the laneState - produced by reducing the lane's records and own entries (section 7): live - commits update it; restore recomputes it. */ -interface LaneState { - lane: string; - leafId: string | null; - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - intent: OperationStartedRecord["intent"]; - aborting: boolean; - step: null | { // unfinished step: newest attempt's result entry missing - kind: "assistant" | "compaction" | "branch_summary"; - attempts: number; - resultEntryId: string; // the newest attempt's provisioned result - compactionReason?: "manual" | "threshold" | "overflow"; - }; - toolBatch: null | ToolBatchState; - missingInitialMessages: ProvisionedEntry[]; - pendingSteer: ProvisionedEntry[]; - pendingFollowUp: ProvisionedEntry[]; - pendingWrites: ProvisionedEntry[]; - deferred: DeferredHandle | null; // unredeemed handle - overflowRecoveryUsed: boolean; // section 6 overflow guard, from the reduction - /** Newest entry this operation appended; pure predicates read it. */ - newestOwn: null | { entryId: string; type: Entry["type"]; - role?: AgentMessage["role"]; stopReason?: TerminalStopReason }; - targets: { result?: boolean; summary?: boolean }; // structural ops - }; - pendingNextRun: ProvisionedEntry[]; -} - -interface ToolBatchState { - assistantEntryId: string; - calls: { // original source order and ordinals - toolIndex: number; - toolCall: AgentToolCall; - started?: ToolStartedRecord; - resultExists: boolean; - terminate?: boolean; // persisted on the result entry - }[]; - truncated: boolean; // assistant stopReason was "length" - unresolved: boolean; -} - -interface LaneReductionInput extends RecordLogSlice { - leafId: string | null; - /** Entries appended by the open operation, oldest first. Empty when idle. */ - ownEntries: readonly Entry[]; - /** Bounded effective-state lookups at the operation anchor or idle leaf, - oldest first. */ - configurationEntries: readonly Entry[]; - /** Harness option fallbacks used when no persisted value exists. */ - defaults: EffectiveLaneConfiguration; -} - -interface LaneReductionResult { - laneState: LaneState; - effectiveConfiguration: EffectiveLaneConfiguration; - /** Non-null only when newestOwn is an error produced by a step or deferred fetch, - never for an arbitrary error-shaped deferred write. */ - terminalFailure: TerminalFailureState | null; -} - -function reduceLaneState(input: LaneReductionInput): LaneReductionResult; -``` - -Four control-flow signals travel by exception inside a procedure; none escapes to a caller. `RunFailed` carries a terminal failure into the drain-and-finish path. `Park` unwinds when a deferred handle was persisted; the lane suspends. `Aborted` unwinds to the abort path. `Overflow` routes a discarded recoverable response (section 6) into the compact-and-retry path. Any other rejection faults the harness. - -```ts -class RunFailed { constructor(readonly error: OperationError) {} } -class Park { constructor(readonly handle: DeferredHandle) {} } -class Aborted {} -class Overflow {} // recoverable response discarded; its cost is already in the ledger - -const newId = (): string => session.idGenerator.next(); - -/** Recovery-safe re-entry everywhere: skip a provisioned id that already - exists (verify equal content; different content is corruption). */ -async function appendIfMissing(target: ProvisionedEntry): Promise { - if (!(await session.getEntry(target.id))) await fx.appendEntry(target); -} -``` - -### Dispatch - -```ts -async function resume(): Promise { - if (missing.tools.length || missing.models.length) { - return Result.err(new MissingIdentities({ lane: state.lane, ...missing, - message: "Missing tools or models" })); - } - await fx.runHook("before_resume", beforeResumeEvent(state)); // per registration id (section 11) - emit({ type: "run_resume", runId: op.id, recovery: true }); - // tagResume re-tags an operation Result as a ResumeResult: Ok gains - // { operation }, Err passes through unchanged. - switch (op.kind) { - case "run": return tagResume("run", await runProcedure()); - case "compaction": return tagResume("compaction", await compactionProcedure()); - case "navigation": return tagResume("navigation", await navigationProcedure()); - } -} - -async function runProcedure(): Promise { - try { - for (const m of [...op.missingInitialMessages]) await appendIfMissing(m); // never dropped - if (op.aborting) return await abortPath(); - - if (op.deferred) { - const redeemed = await redeemDeferred(); // may throw Park, RunFailed, Aborted - if (hasToolCalls(redeemed)) await runToolBatch(redeemed); - } - if (op.toolBatch?.unresolved) await reconcileToolBatch(op.toolBatch); - - // A crash mid-step resumes that exact step before new checkpoint input - // is consumed (section 7). Live retry and recovery consume identically. - if (op.step?.kind === "assistant") { - const outcome = await runTurn(); - if (outcome) return outcome; - } else if (op.step?.kind === "compaction") { - await autoCompact(requireAutoReason(op.step)); // recorded reason - } else if (op.step) { - throw new Error("Run has a branch-summary step"); // corruption - } - - if (newestOwnMessageIsStepError(state)) { // terminal-failure marker (section 7) - return await handleRunFailed(existingFailure(state)); - } - return await driverLoop(); - } catch (e) { - return await handleRunSignal(e); - } -} - -async function handleRunSignal(e: unknown): Promise { - if (e instanceof Park) return suspended(e.handle); // discard procedure; lane parked - if (e instanceof Aborted) return await abortPath(); - if (e instanceof RunFailed) return await handleRunFailed(e.error); - throw e; // storage/defect → faulted harness -} -``` - - -**Fixed-point self-check.** When `resume()` completes, parks, or closes its operation, the harness recomputes the section 7 reduction from storage and compares its `laneState` to the live `LaneState`. A mismatch is corruption and faults the harness — writer/reducer drift is caught the moment it happens instead of one crash later. The check is cheap (the same two bounded reads restore performs) and runs in production, not only under test. - -### The loop - -```ts -async function driverLoop(): Promise { - while (true) { - // checkpoint — each consumption is a conditional mutation-line job - for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); - for (const m of steeringForThisCheckpoint(op)) await fx.consumeQueueItem(op.id, "steer", m.id); - if (op.aborting) return await abortPath(); - if (await contextOverLimit()) await autoCompact(pressureReason()); // may throw RunFailed - - if (needsAssistant()) { - const outcome = await runTurn(); - if (outcome) return outcome; - continue; // fresh checkpoint - } - - for (const m of followUpsForThisCheckpoint(op)) await fx.consumeQueueItem(op.id, "followUp", m.id); - if (needsAssistant() || hasPendingWork()) continue; - - // finish boundary - const r = await fx.runHook("before_run_end", { runId: op.id, messages: runMessages() }); - if (r?.followUp) { - await fx.commitRunEndFollowUp(op.id, provisionUserMessage(newId(), r.followUp)); - } - if (hasPendingWork()) continue; - - const done = await fx.tryFinishRun(op.id, "completed"); - if (done === "finished") return finished("completed"); - // "continue": accepted input or abort won the ordering — loop - } -} - -async function runTurn(): Promise { - let assistant: AssistantMessage; - try { - assistant = await assistantStep(); // may throw Park, RunFailed, Aborted, Overflow - } catch (e) { - if (e instanceof Overflow) return await recoverOverflow(); - throw e; - } - if (assistant.stopReason === "aborted" || op.aborting) return await abortPath(); - if (hasToolCalls(assistant)) await runToolBatch(assistant); - return undefined; -} - -async function recoverOverflow(): Promise { - if (op.aborting) return await abortPath(); - if (op.overflowRecoveryUsed) { // once per conversational input (section 6) - await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state, truncationError())); - return await handleRunFailed(truncationError()); - } - await autoCompact("overflow"); // declined or nothing to compact → RunFailed - return undefined; // driverLoop loops; needsAssistant is still true -} - -async function handleRunFailed(error: OperationError): Promise { - try { - // Drain accepted input. No before_run_end, no further model work - // unless consumed conversational input restarts the loop. - while (true) { - for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); - let consumed = 0; - for (const m of steeringForThisCheckpoint(op)) { - if (await fx.consumeQueueItem(op.id, "steer", m.id) === "consumed") consumed++; - } - if (consumed === 0) { - for (const m of followUpsForThisCheckpoint(op)) { - if (await fx.consumeQueueItem(op.id, "followUp", m.id) === "consumed") consumed++; - } - } - if (op.aborting) return await abortPath(); - if (consumed > 0) return await driverLoop(); // input clears the failure - const done = await fx.tryFinishRun(op.id, "failed", error); - if (done === "finished") return finished("failed", error); - } - } catch (e) { - return await handleRunSignal(e); - } -} -``` - -`needsAssistant()`: the newest own message is a user, steering, follow-up, or tool-result message — except a completed tool batch in which every result persisted `terminate: true`, which does not by itself force another turn (section 4). `hasPendingWork()`: pending writes, pending queue items, or `needsAssistant()`. - -### Steps - -A failed attempt appends nothing. Besides the successful response, only a deferred handle, a terminal message, or the final give-up error enters the tree (section 6, retry trace). - -```ts -async function assistantStep(): Promise { - while (true) { - if (op.aborting) throw new Aborted(); - const attempt = (op.step?.kind === "assistant" ? op.step.attempts : 0) + 1; - if (attempt > retry.maxAttempts) { - const error = retriesExhausted(); - // The give-up entry fulfills the last attempt's provisioned id. - await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state, error)); - throw new RunFailed(error); - } - - const options = await fx.runHook("before_request", - { model: laneModel(state), step: "assistant", attempt, streamOptions }); - const resultEntryId = newId(); - await fx.appendRecord(stepAttempt(op.id, "assistant", attempt, resultEntryId)); - - const final = await fx.streamAssistant(assistantRequest(state, options)); - await fx.appendRecord(usageRecord("assistant", op.id, resultEntryId, attempt, final)); // ledger, before any branch - - if (isRecoverableOverflow(final, state)) { - throw new Overflow(); // discarded; resultEntryId stays unfulfilled - } - if (final.stopReason === "deferred") { - await fx.appendEntry(assistantEntry(resultEntryId, final)); - emit({ type: "run_suspend", runId: op.id, deferred: final.deferred }); - throw new Park(final.deferred); - } - if (final.stopReason === "error" && isRetryable(final)) { - await fx.sleep(retryDelay(attempt)); // retry events around this - continue; // durable count already advanced - } - - await fx.appendEntry(assistantEntry(resultEntryId, final)); - if (final.stopReason === "error") throw new RunFailed(messageError(final)); - return final; // stop, toolUse, genuine length, aborted - } -} -``` - -`isRecoverableOverflow(final, state)` is `isContextOverflow(final)` — overflow-pattern errors and silent overflow — or `isRecoverableLength(final, desiredMaxOutput(state))` from section 6, where `desiredMaxOutput(state)` is the caller-supplied `maxTokens` when set, else the lane model's `maxTokens`. The check runs before the retryable-error branch: an overflow-form error compacts instead of retrying the same oversized request. - -`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. For reason `overflow` the appending procedure also writes the compaction `step_attempt`, so the once-per-input guard counts the recovery (section 6). - -### Deferred redemption - -```ts -async function redeemDeferred(): Promise { - const final = await fx.fetchDeferred(deferredModel(state), op.deferred!); - const resultEntryId = newId(); - if (final.stopReason !== "deferred" || hasReportedUsage(final)) { - await fx.appendRecord(usageRecord("deferred_fetch", op.id, resultEntryId, 1, final)); - } - if (op.aborting) throw new Aborted(); - if (final.stopReason === "deferred") { - requireSameHandle(final.deferred, op.deferred!); // mismatch is a defect (section 16) - throw new Park(op.deferred!); // pending; no other write - } - if (final.stopReason === "aborted") throw new Aborted(); - - await fx.appendEntry(assistantEntry(resultEntryId, final)); // ready or terminal - if (final.stopReason === "error") throw new RunFailed(messageError(final)); - return final; -} -``` - -One fetch per `resume()`. Pending re-parks without a write. A terminal answer — returned or converted from a rejected fetch — lands as the error entry and fails the run through the normal drain path, which still honors input accepted before the failure (section 6). - -### Tools - -The live path is section 14 `executeToolBatch`; the durability callbacks route through `fx`, so the gate and the traces see every write in order: - -```ts -async function runToolBatch(assistant: AssistantMessage, telemetryContext: TelemetryContext): Promise { - const resultIds = new Map(); // toolCallId → provisioned id - - await executeToolBatch(assistant, gatedActiveTools(), { - beforeToolCall: async (call, args) => { - return await fx.runHook("before_tool", - { toolCallId: call.id, toolName: call.name, args }); // may patch args or block - }, - onToolStart: async (call, effectiveArgs) => { - const resultEntryId = newId(); - resultIds.set(call.id, resultEntryId); - await fx.appendRecord(toolStarted(op.id, { - assistantEntryId: newestAssistantEntryId(state), - toolIndex: indexOf(assistant, call), - toolCallId: call.id, toolName: call.name, - effectiveArgs, resultEntryId, - replay: declaredReplay(call), - })); - }, - afterToolCall: (call, args, result, isError) => - fx.runHook("after_tool", { toolCallId: call.id, toolName: call.name, args, ...result, isError }), - onToolResult: async (message, terminate) => { - // Blocked/invalid calls have no tool_started and no provisioned id; - // their error result entry gets a fresh id (section 5). - const entryId = resultIds.get(message.toolCallId) ?? newId(); - if (message.usage) { - await fx.appendRecord(toolUsageRecord(op.id, entryId, message.toolCallId, message.usage)); - } - await appendIfMissing(resultEntry(entryId, message, terminate)); - }, - }, { toolExecution: config.toolExecution }, emitLaneEvents, telemetryContext, abortSignal); -} -``` - -The recovery path handles each call at its crash site, in source order, keeping original ordinals: - -```ts -async function reconcileToolBatch(batch: ToolBatchState, telemetryContext: TelemetryContext): Promise { - if (batch.truncated) { // stopReason "length": never execute - for (const call of batch.calls) { - if (!call.resultExists) await appendIfMissing(truncatedToolResult(newId(), call.toolCall)); - } - return; - } - - for (const call of batch.calls) { - if (call.resultExists) continue; - - if (call.started) { // X3: effect outcome unknown - if (call.started.replay === "safe" && currentDeclaration(call) === "safe") { - const prepared = { kind: "prepared", toolCall: call.toolCall, - tool: toolByName(call.started.toolName), - args: call.started.effectiveArgs }; // persisted, not re-derived - const executed = await fx.executeTool(prepared); - const finalized = await finalizeToolCall(prepared, executed, - { afterToolCall }, telemetryContext, abortSignal); // fx-wired hook callback - if (finalized.result.usage) { - await fx.appendRecord(toolUsageRecord(op.id, call.started.resultEntryId, - call.toolCall.id, finalized.result.usage)); // the replay's own record - } - await appendIfMissing(resultEntry(call.started.resultEntryId, - createToolResultMessage(finalized), finalized.result.terminate === true)); - } else { - await appendIfMissing(syntheticResult(call.started.resultEntryId, "interrupted")); - } - } else { // X1/X2: full path, original ordinal - await runToolBatchForSingleCall(call); - } - } -} -``` - -### Abort - -`abort()` itself is a lane-surface job (mutation line, above): marker, queue drain, signal, resolve. Reconciliation is procedure work. If the operation was suspended with no procedure running, `abort()` starts one at the abort path; manual mode leaves it parked at its first action. - -```ts -async function abortPath(): Promise { - if (op.deferred) await fx.cancelDeferred(deferredModel(state), op.deferred); // best effort: - // rejection → telemetry, then proceed - while (true) { - for (const call of op.toolBatch?.calls ?? []) { - if (call.resultExists) continue; - await appendIfMissing(syntheticResult(idFor(call), call.started ? "interrupted" : "aborted")); - } - for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); // facts survive abort - if (!newestOwnMessageIsAborted(state)) await appendIfMissing(abortClosureEntry(newId(), state)); - - const done = await fx.finishOperation(op.id, "aborted"); - if (done === "finished") return finished("aborted"); - // "continue": a deferred write arrived meanwhile — apply it before closing - } -} -``` - -### Structural operations - -```ts -async function compactionProcedure(): Promise { - try { - if (op.aborting) return await abortStructural(); - if (!op.targets.result) { - let result: CompactResult | undefined; - if (!op.step) { // no attempt yet: the decision hook may still run - const hook = await fx.runHook("before_compaction", - { reason: "manual", preparation: preparation(state), - customInstructions: op.intent.customInstructions }); - if (hook?.decline) return await finishStructural("declined"); - result = hook?.compaction; - if (result?.usage) { - await fx.appendRecord(hookUsageRecord(op.id, op.intent.resultEntryId, result.usage)); - } - } - result ??= await summaryStep("compaction", "manual", op.intent.resultEntryId); - await appendIfMissing(compactionEntry(op.intent.resultEntryId, result)); - } - return await finishStructural("completed"); - } catch (e) { return await handleStructuralSignal(e); } -} - -/** Inside a run, at a checkpoint or after an overflow response. Same hook, - same durable attempts and cap as manual compaction; no nested operation - records. Exhausted retries throw RunFailed — the enclosing run drains - and finishes failed, without before_run_end (section 11). For reason - "overflow", a hook decline or an empty preparation also throws - RunFailed: without compaction the request cannot fit (section 6). */ -async function autoCompact(reason: "threshold" | "overflow"): Promise { - const resultEntryId = op.step?.kind === "compaction" ? op.step.resultEntryId : newId(); - if (op.step?.kind !== "compaction") { // no durable compaction decision yet; on the overflow - // path op.step is the abandoned assistant step - const prep = preparation(state); - if (nothingToCompact(prep)) { - if (reason === "overflow") throw new RunFailed(truncationError()); - return; - } - const hook = await fx.runHook("before_compaction", { reason, preparation: prep }); - if (hook?.decline) { - if (reason === "overflow") throw new RunFailed(truncationError()); - return; - } - if (hook?.compaction) { - if (reason === "overflow") { // the once-per-input guard counts this attempt - await fx.appendRecord(stepAttempt(op.id, "compaction", 1, resultEntryId, reason)); - } - if (hook.compaction.usage) { - await fx.appendRecord(hookUsageRecord(op.id, resultEntryId, hook.compaction.usage)); - } - await appendIfMissing(compactionEntry(resultEntryId, hook.compaction)); - return; - } - } - const result = await summaryStep("compaction", reason, resultEntryId); - await appendIfMissing(compactionEntry(resultEntryId, result)); -} - -async function navigationProcedure(): Promise { - try { - if (op.aborting) return await abortStructural(); - const moved = state.leafId === op.intent.targetId; // acceptance rejected target == source - let summary: SummaryValue | undefined; - - if (op.intent.summarize && !op.targets.summary) { - if (!moved && !op.step) { // decision hook: once, pre-move - const hook = await fx.runHook("before_navigation", - { targetId: op.intent.targetId, - preparation: preparation(state) }); // preparation derives from - // intent.sourceLeafId — valid pre- and post-move - if (hook?.decline) return await finishStructural("declined"); - summary = hook?.summary; - if (summary?.usage) { - await fx.appendRecord(hookUsageRecord(op.id, op.intent.summaryEntryId!, summary.usage)); - } - } - summary ??= await summaryStep("branch_summary", undefined, - op.intent.summaryEntryId!); // regenerates after a post-move crash - } - - if (!moved) await fx.moveLane(op.intent.targetId); // the commit point (section 6) - if (op.intent.summarize && !op.targets.summary) { - await appendIfMissing(summaryEntry(op.intent.summaryEntryId!, summary!)); // chains to the target - } - if (op.intent.label !== undefined) { - await fx.setFact(labelFact(op.intent.targetId, op.intent.label)); // idempotent - } - return await finishStructural("completed"); - } catch (e) { return await handleStructuralSignal(e); } -} - -async function finishStructural(outcome: "completed" | "declined") { - const done = await fx.finishOperation(op.id, outcome); - if (done === "continue") return await abortStructural(); // abort won the ordering - return structuralOutcome(outcome); -} - -async function abortStructural() { - // Nothing to reconcile: structural operations own no tool batch, and - // lane-view writes wait for them (section 12). - await fx.finishOperation(op.id, "aborted"); - return structuralOutcome("aborted"); -} - -async function handleStructuralSignal(e: unknown) { - if (e instanceof Aborted) return await abortStructural(); - if (e instanceof RunFailed) { - const done = await fx.finishOperation(op.id, "failed", e.error); - return done === "continue" ? await abortStructural() : structuralOutcome("failed", e.error); - } - throw e; -} -``` - -Hook-to-block wiring, in one table: - -| harness hook | insertion point | -|---|---| -| `transform_context` | inside `fx.streamAssistant` (`StreamAssistantConfig.transformContext`) | -| `before_request` | before `fx.streamAssistant`, patches stream options | -| `before_payload` | inside the stream function, provider level | -| `after_response` | on the stream result, before the entry is appended | -| `before_tool` | `ToolCallbacks.beforeToolCall` (phase 1) | -| `after_tool` | `ToolCallbacks.afterToolCall` (phase 3) | -| `before_run_end` | `driverLoop` finish boundary; result committed via `fx.commitRunEndFollowUp` | -| `before_resume` | `resume()` dispatch, before any effect | -| — (record/entry writes) | `ToolCallbacks.onToolStart` / `onToolResult` via `fx` | - -Notes: - -- Auto-compaction inside a run runs under the run's own records; no nested operation. -- There is no "crashed mid-step" case in the code: an interrupted attempt is an attempt without a result entry, and the cap check decides retry versus `RunFailed`. -- Parallel batches and crash sites compose: `tool_started` records are written in source order during the sequential phase-1 pass, so a crash mid-batch leaves a source-order prefix of records — some with results, some without (section 6 table applies per call). -- An aborted assistant message (`stopReason: "aborted"`) skips tool execution; `abortPath()` owns the synthetic results. -- A crash between the navigation move and its summary entry loses the in-memory summary text; recovery regenerates it under the same attempt cap. A hook-supplied summary lost in that window is regenerated rather than re-asked: the hook's decline authority ended at the move. - -## 16. pi-ai: deferred requests - -Everything is per-request; batch APIs can implement the same shape through a custom provider. - -```ts -// Request. Providers map this to their native mechanism, e.g. -// background: true on a Responses API, or a batch submission. -interface SimpleStreamOptions extends StreamOptions { - deferred?: boolean | { window?: "15m" | "1h" | "24h" }; - // ... other options -} - -// Response. A deferred request resolves quickly with a handle instead of -// content. The message is persisted like any assistant message; the handle -// is the durable fact recovery needs. -type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred"; -// Agent-side settled-result narrowings. -type TerminalStopReason = Exclude; -type SettledAssistantMessage = AssistantMessage & { stopReason: TerminalStopReason }; - -interface DeferredHandle { - provider: string; - modelId: string; - api: string; - id: string; // provider token: response id, batch id + row - expiresAt?: number; // Unix ms - pollAfterMs?: number; // provider hint - data?: JsonValue; // provider conversion data -} - -interface AssistantMessage { - // ... other fields - stopReason: StopReason; - deferred?: DeferredHandle; // present iff stopReason === "deferred" -} - -// Authenticated HTTP request plumbing shared by stream, image, and deferred -// provider operations. Generation and streaming-transport controls are not -// part of this interface. -interface ProviderRequestOptions> { - signal?: AbortSignal; - /** Explicit parent for this logical pi-ai operation. Inherited by stream, - simple-stream, deferred fetch/cancel, and image options. */ - telemetryContext?: TelemetryContext; - apiKey?: string; - fetch?: FetchFunction; - env?: ProviderEnv; - onPayload?: (payload: unknown, model: TModel) => - unknown | undefined | Promise; - onResponse?: (response: ProviderResponse, model: TModel) => void | Promise; - headers?: ProviderHeaders; - timeoutMs?: number; - maxRetries?: number; - maxRetryDelayMs?: number; -} - -interface DeferredFetchOptions extends ProviderRequestOptions> { - /** Maximum provider long-poll duration. Omitted or zero checks once. */ - wait?: number; -} - -type DeferredCancelOptions = ProviderRequestOptions>; - -// Redemption lives on the provider. The two methods are optional: their -// presence is the capability signal. A provider without them never returns -// stopReason "deferred" and ignores the deferred request option. -export interface ProviderStreams { - stream(model: Model, context: Context, options?: StreamOptions): AssistantMessageEventStream; - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; - - /** Redeem a handle. Same return type as streamSimple; downstream code is - identical. Polls or re-attaches until terminal, then emits the normal - events and final message. Resolution states, all in-band: - - ready: normal message (stop | toolUse | length) - - still pending: stopReason "deferred" with the same handle (after - `wait` expires; wait: 0 checks once) - - terminal: stopReason "error" (expired, unknown, consumed) */ - fetchDeferred?(model: Model, handle: DeferredHandle, - options?: DeferredFetchOptions): AssistantMessageEventStream; - - /** Best effort; providers without cancellation omit it. */ - cancelDeferred?(model: Model, handle: DeferredHandle, - options?: DeferredCancelOptions): Promise; -} -``` - -`ProviderRequestOptions.telemetryContext` is inherited by `StreamOptions`, `SimpleStreamOptions`, `DeferredFetchOptions`, `DeferredCancelOptions`, and `ImagesOptions`; provider, `Models`, `ImagesModels`, and direct stream/image dispatch preserve it unchanged. `buildBaseOptions()` also preserves it when built-in `streamSimple()` implementations convert to provider-specific stream options. - -`pending` is internal to a mutable live-stream message. Request-wrapper results use `SettledAssistantMessage`; harness-written entries, durable usage records, and settled `pi.ai.request` spans cannot contain `pending`. Telemetry normalizes terminal `toolUse` to `tool_use`. - -The harness uses the authenticated `Models` dispatch surface rather than talking to a provider object directly: - -```ts -type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms; -type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms; - -interface Models { - // other methods - fetchDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredFetchOptions): Promise; - cancelDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredCancelOptions): Promise; -} -``` - -`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge). Their options carry the normal HTTP request settings, lifecycle callbacks, and model transforms; fetch options additionally carry the provider long-poll duration. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. - -A terminal fetch answer is final for the run: the harness appends the error message and fails the operation, never starts an automatic replacement request, and converts a rejected fetch promise into the same `stopReason: "error"` message form so expected provider and authentication failures stay in-band. On a returned still-deferred message it requires the complete handle to equal the persisted handle: a provider cannot replace durable handle data without a write, so a mismatch is a defect. - -Deferred assistant messages carry a handle, not content. Session context projection omits them from provider context; durable suspension and redemption use the persisted handle. - -Stop-reason normalization is the adapter's job, and the harness branches only on the normalized value. For OpenAI Responses: `incomplete_details.reason === "max_output_tokens"` maps to `stopReason: "length"`; `content_filter` maps to a non-retryable `stopReason: "error"`. Adapters may retain the provider's reason as `rawStopReason` for diagnostics; core logic never reads it. - -## 17. Forks and subagents - -One copy primitive on the session repository: - -```ts -type ForkOptions = - | { scope?: "branch"; entryId?: string; position?: "before" | "at" } // one path, root to fork point - | { scope: "tree" }; // all entries, every branch - -repo.fork(source, options & { id?, parentSessionId? }): Promise; -repo.create({ id?, parentSessionId? }): Promise; -``` - -- Entries only. JSONL copies them without `lane`, then writes the final lane pointers. No records, no queues: a fork starts idle, every lane question answers "no open operation". No records also means no ledger: a fork's token and cost statistics start at zero — cost belongs to the session that incurred it; entry usage snapshots still display. Its `messageCount` is initialized from all copied message entries. -- Lanes: `scope: "branch"` → the fork has only `main`, at the fork point. `scope: "tree"` → every lane name and leaf pointer is copied. No operation logs or queues are copied either way, so every forked lane is idle. -- Facts: `scope: "tree"` copies all; `scope: "branch"` copies the name always, labels only when their target entry was copied. -- The fork point may be any message entry. A copy whose tip sits mid-tool-batch is still promptable: pi-ai's transformMessages inserts synthetic empty results for orphaned tool calls at request build time. -- The source is untouched; copying while it runs reads the committed prefix. -- Linkage is `parentSessionId`, set by `fork()` and settable on `create()` — the basis for subagent parent/child tracking and export bundles. -- A subagent tool derives its child session id deterministically from its invocation (`f(parentSessionId, toolCallId)`): a safe replay reattaches to the same child instead of spawning a twin, and the child stays discoverable from the parent even when a crash swallowed the tool result. -- Policy, restated from Part I: a platform thread that shares history with its channel is a lane; a fork is for isolation — subagents, exports, clones. A subagent can also run on a lane of its parent's session when isolation is not wanted. - -## 18. Telemetry - -Telemetry uses explicit context propagation. Core code does not use `AsyncLocalStorage`, global current-span state, or runtime-specific context APIs: pi runs in Node, Bun, browsers, and workers, so no runtime's ambient-context mechanism can be the core abstraction. An adapter may use ambient context internally — for example, an OpenTelemetry adapter may activate its native child context so HTTP auto-instrumentation attaches correctly — but pi always passes the parent explicitly. - -Pi ships no exporter and requires no backend-specific telemetry implementation. It does ship `InMemoryTelemetryContext` as the deterministic backend-neutral reference implementation; applications may use it for process-local capture or supply a `TelemetryContext` adapter that bridges spans into OTel, Sentry, logs, or another backend. The adapter is trusted to obey the callback contract below. It owns backend ids and native context objects; core never carries trace-id plumbing. - -### Package ownership - -The generic contract, schema-definition machinery, shared no-op, and in-memory reference implementation live under `packages/telemetry/src/` and are exported from `@earendil-works/pi-telemetry`. The runner-independent conformance cases live under `packages/telemetry/src/testing/` and are exported from `@earendil-works/pi-telemetry/testing`. Pi-ai imports only `TelemetryContext` for request options; it owns no span schema or helper and emits no telemetry itself. `packages/agent/src/harness/telemetry.ts` owns both `AI_TELEMETRY_SCHEMA` / `startAiSpan()` and `HARNESS_TELEMETRY_SCHEMA` / `startHarnessSpan()`, plus the readonly `AGENT_TELEMETRY_SCHEMAS` tuple that composes their typed vocabularies without merging their schema data or versions. The agent package root re-exports those domain schemas, helpers, tuple, and the generic telemetry surface. There is one generic contract and one domain-schema owner. - -`AgentHarnessOptions.telemetryContext` defaults to the no-op context, and the agent-side request wrapper emits `pi.ai.request` through the agent-owned AI schema. - -Both schemas are pi-owned. Span names use the `pi.ai.*`, `pi.harness.*`, and `pi.session.*` families; attributes use the same pi-owned `pi.*` vocabulary and do not adopt an external semantic-convention namespace. Adapters translate them when useful; the emitted pi vocabulary remains stable regardless of backend convention churn. - -### Context contract - -```ts -type AttributeValue = - | string - | number - | boolean - | readonly string[] - | readonly number[] - | readonly boolean[]; - -interface SpanAttributes { - [name: string]: AttributeValue | undefined; -} - -interface SpanOptions { - name: string; - attributes?: SpanAttributes; -} - -type SpanStatus = - | { status: "ok" } - | { status: "error"; error?: { name: string; message: string } }; - -interface TelemetryContext { - startSpan( - options: SpanOptions, - callback: (span: TelemetrySpan) => T | Promise, - ): Promise; -} - -interface TelemetrySpan extends TelemetryContext { - addEvent(name: string, attributes?: SpanAttributes): void; - setAttributes(attributes: SpanAttributes): void; - setStatus(status: SpanStatus): void; -} -``` - -The telemetry package exports the shared no-op context and the deterministic in-memory reference context. The harness and compatibility wrapper select the no-op when no application context is supplied. Under the context contract, `startSpan()` creates the child and invokes its callback synchronously, exactly once, before returning a promise. It keeps the span open until the callback's value or promise settles: - -- return or resolve: default status `ok`, then automatic end; -- synchronous throw: return a promise rejected with the same thrown value, after automatic error status and end; -- asynchronous rejection: automatic error status and end, then rejection with the same value; -- expected failure represented by a value: the callback calls `setStatus({ status: "error", ... })` before returning; -- repeated `setStatus()` calls are last-write-wins; automatic completion never overwrites an explicit status; -- `setAttributes()` merges keys; a later defined value overwrites an earlier one and `undefined` is ignored; -- calls on a settled span are inert and never throw. - -Adapters preserve the callback's result and error. Their recording methods are synchronous, passive, and must not throw; asynchronous exporters buffer internally and flush on their own schedule. If native span creation or recording fails, the adapter suppresses that failure, ignores the failed recording call atomically, substitutes no-op behavior, and still invokes the business callback exactly once. A nonconforming adapter is an application defect. The no-op implementation invokes the callback with one shared inert span, allocates no per-span object, inspects and retains no attributes, and otherwise preserves the callback's behavior. Flushing a real adapter at shutdown is the application's responsibility. - -The harness runtime passes context to every effectful implementation boundary as a normal argument. No core function looks up a current context: - -```ts -streamAssistant(messages, configWithTelemetryContext, emit); -prepareToolCall(call, tools, callbacks, telemetryContext, signal); -executeToolCall(prepared, emit, telemetryContext, signal); -finalizeToolCall(prepared, executed, callbacks, telemetryContext, signal); -fx.appendEntry(entry, telemetryContext); -fx.runHook(name, event, telemetryContext); -``` - -A `TelemetrySpan` is also the explicit child `TelemetryContext`. Passing the callback span to lower-level work creates nesting through the ordinary call graph. The schema-typed API below automates that handoff by giving each callback a child starter bound to its live span; it does not use ambient mutable context. Every `Effects` method receives its parent as a parameter, and parallel tools use separate child spans and therefore separate parent contexts. - -### Typed schema - -The low-level adapter accepts the open `SpanAttributes` bag. Pi instrumentation never constructs untyped span names or attribute bags directly. The agent package exports the two plain, serializable domain schema objects and their typed helpers for that purpose. - -```ts -type TelemetryAttributeType = - | "string" - | "number" - | "boolean" - | "string[]" - | "number[]" - | "boolean[]"; - -interface TelemetryAttributeMetadata { - description: string; - sensitive?: boolean; - cardinality?: "low" | "high"; -} - -type TelemetryAttributeDefinition = TelemetryAttributeMetadata & ( - | { type: "string"; values?: readonly string[]; examples?: readonly string[] } - | { type: "number"; values?: readonly number[]; examples?: readonly number[] } - | { type: "boolean"; values?: readonly boolean[]; examples?: readonly boolean[] } - | { type: "string[]"; elementValues?: readonly string[]; examples?: readonly (readonly string[])[] } - | { type: "number[]"; elementValues?: readonly number[]; examples?: readonly (readonly number[])[] } - | { type: "boolean[]"; elementValues?: readonly boolean[]; examples?: readonly (readonly boolean[])[] } -); - -type TelemetryStartAttributeDefinition = TelemetryAttributeDefinition & { required: boolean }; -type TelemetryEventAttributeDefinition = TelemetryAttributeDefinition & { required: boolean }; - -interface TelemetryEventDefinition { - description: string; - attributes: Record; -} - -type TelemetryParentDefinition = - | { kind: "any" } - | { kind: "root_or_external" } - | { kind: "spans"; spans: readonly string[] }; - -interface TelemetrySpanDefinition { - description: string; - /** Exhaustive allowed-parent rule. "external" means a caller-owned span - outside the pi schemas. */ - parents: TelemetryParentDefinition; - startAttributes: Record; - /** Completion enrichment only. Every end attribute is optional; startSpan() - owns ending the span regardless of which attributes were set. */ - endAttributes: Record; - events?: Record; - status: { default: "ok"; errorWhen: string }; -} - -interface TelemetrySchemaDefinition { - version: number; - spans: Record; -} - -declare function defineTelemetrySchema(schema: T): T; -``` - -`defineTelemetrySchema()` is a typed identity helper; the returned value is ordinary serializable data, not a validation runtime. Span names, attribute types, required keys, and literal `values` are inferred from that value. The tables below are the normative domain vocabulary; `packages/agent/docs/telemetry-schema.md` is its generated reference. - -`createTypedSpanStarter(context, schemas)` binds one explicit parent context to the combined span vocabulary of a non-empty readonly schema tuple. The schemas retain independent objects, ownership, documentation, and versions; the tuple is not a third merged schema. Span names must be unique across the tuple and duplicate literal names fail compilation. The schema values are otherwise type-inference inputs only and are not inspected or retained at runtime. - -The returned `TypedSpanStarter` is a per-name overload set that accepts only a declared literal name and that span's exact start attributes. A union-valued name must be narrowed before the call so its runtime name cannot be paired with another span's attributes. Its callback receives the schema-scoped span plus another starter over the same schema tuple bound to the callback span. The child starter therefore creates correctly nested spans without ambient context or manual rebinding, and concurrent callbacks receive independent starters: - -```ts -const AGENT_TELEMETRY_SCHEMAS = [ - AI_TELEMETRY_SCHEMA, - HARNESS_TELEMETRY_SCHEMA, -] as const; - -const startSpan = createTypedSpanStarter( - telemetryContext, - AGENT_TELEMETRY_SCHEMAS, -); - -await startSpan("pi.harness.step", stepAttributes, async (stepSpan, startChildSpan) => { - stepSpan.setAttributes({ "pi.step.outcome": "succeeded" }); - return startChildSpan("pi.ai.request", requestAttributes, async (requestSpan) => { - requestSpan.setAttributes({ "pi.ai.response.stop_reason": "stop" }); - }); -}); -``` - -The callback span still retains the open generic `TelemetryContext.startSpan()` method, so it can be passed to a starter for a different schema tuple when an integration intentionally crosses vocabularies. `createTypedSpanStarter()` itself adds no runtime span, schema validation, parent-rule enforcement, or durable state. - -The following tables are normative input to the schema objects. `!` means a required start attribute; `?` means an optional start attribute. Every end attribute is optional enrichment. Array element sets use `elementValues`; all other closed sets use `values`. The automatic throw/reject rule from the context contract applies to every span in addition to the explicit status rule shown. - -#### AI request schema - -`AI_TELEMETRY_SCHEMA` declares no pi-written span events and one span. Its parent rule is `{ kind: "any" }`: - -| span | allowed parents | status | -|---|---|---| -| `pi.ai.request` | root or any caller span | error on throw/reject or a returned result with stop reason `error`; `aborted` and `deferred` are normal outcomes | - -| `pi.ai.request` start attribute | type | requirement | values / meaning | -|---|---|---|---| -| `pi.ai.operation` | string | ! | `stream`, `fetch_deferred`, `cancel_deferred`, `generate_images` | -| `pi.ai.provider` | string | ! | selected provider id | -| `pi.ai.model` | string | ! | requested model id | -| `pi.ai.api` | string | ! | provider API id | -| `pi.ai.streaming` | boolean | ! | whether this operation returns a stream | -| `pi.ai.deferred` | boolean | ? | whether the operation requests or participates in deferred execution | - -| `pi.ai.request` end attribute | type | values / meaning | -|---|---|---| -| `pi.ai.response.model` | string | concrete response model, when reported | -| `pi.ai.response.id` | string | provider response id; high cardinality | -| `pi.ai.response.stop_reason` | string | `stop`, `length`, `tool_use`, `error`, `aborted`, `deferred`; terminal `toolUse` normalizes to `tool_use`, and `pending` is never recorded | -| `pi.ai.http.status_code` | number | final HTTP status when exposed by the provider path | -| `pi.ai.usage.input_tokens` | number | reported input tokens | -| `pi.ai.usage.output_tokens` | number | reported output tokens | -| `pi.ai.usage.cache_read_tokens` | number | reported cache-read tokens | -| `pi.ai.usage.cache_write_tokens` | number | reported cache-write tokens | -| `pi.ai.usage.reasoning_tokens` | number | reported reasoning subset of output | -| `pi.ai.usage.total_tokens` | number | reported total tokens | -| `pi.ai.usage.cost` | number | reported total cost | -| `pi.ai.stream.chunk_count` | number | number of streamed update chunks, without chunk content | -| `pi.ai.stream.time_to_first_chunk_ms` | number | elapsed milliseconds to first update chunk | -| `pi.ai.error.type` | string | low-cardinality provider or transport error class | - -The schema declares no per-chunk telemetry event. The assistant stream carries live deltas while telemetry records only aggregate chunk count and first-chunk latency. Default telemetry never contains request or response content. - -#### Harness schema - -The three operation spans share `pi.session.id` (string, required, high cardinality), `pi.lane.name` (string, required, high cardinality), `pi.operation.id` (string, required, high cardinality), and `pi.operation.recovery` (boolean, required). Each also requires `pi.operation.kind` with only the literal matching that span. Operation error status may add optional end attributes `pi.error.code` and `pi.error.type`, both low-cardinality strings; free-form error messages are status diagnostics, not schema attributes. - -| span | allowed parents | start attributes | optional end attributes | explicit error status | -|---|---|---|---|---| -| `pi.harness.run` | root or application span | common operation attributes plus `pi.operation.kind`: `run` | `pi.operation.outcome`: `completed`, `aborted`, `failed`, `suspended` | outcome `failed` | -| `pi.harness.compaction` | root or application span | common operation attributes plus `pi.operation.kind`: `compaction` | `pi.operation.outcome`: `completed`, `declined`, `aborted`, `failed` | outcome `failed` | -| `pi.harness.navigation` | root or application span | common operation attributes plus `pi.operation.kind`: `navigation` | `pi.operation.outcome`: `completed`, `declined`, `aborted`, `failed` | outcome `failed` | -| `pi.harness.checkpoint` | `pi.harness.run` | `pi.lane.name`!, `pi.operation.id`!, `pi.checkpoint.kind`!: `normal`, `failure_drain`, `abort_reconcile` | none | only throw/reject | -| `pi.harness.turn` | `pi.harness.run` | `pi.lane.name`!, `pi.operation.id`!, `pi.turn.id`! string, high cardinality | none | only throw/reject | -| `pi.harness.step` | `pi.harness.turn`, `pi.harness.checkpoint`, `pi.harness.compaction`, or `pi.harness.navigation` | `pi.lane.name`!, `pi.operation.id`!, `pi.step.kind`!: `assistant`, `compaction`, `branch_summary`; `pi.step.attempt`! number; `pi.compaction.reason`?: `manual`, `threshold`, `overflow` | `pi.step.outcome`: `succeeded`, `retry`, `failed`, `aborted`, `deferred`, `overflow` | outcome `retry` or `failed` | -| `pi.harness.tool` | `pi.harness.turn` for live work or `pi.harness.run` for reconciliation | `pi.lane.name`!, `pi.operation.id`!, `pi.turn.id`? string high-cardinality, `pi.tool.name`! string, `pi.tool.call_id`! string high-cardinality, `pi.tool.replay`!: `never`, `safe`; `pi.tool.recovery`! boolean | `pi.tool.is_error` boolean for the raw phase-2 execution result | `pi.tool.is_error: true` | -| `pi.harness.hook` | root or the current harness/AI scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.hook.name`! string with values from `HookName`, `pi.hook.registration_id`? string | `pi.hook.outcome`: `completed`, `skipped`, `blocked`, `failed` | handler throw, including fail-closed `before_tool` | -| `pi.harness.sleep` | `pi.harness.step` or `pi.harness.run` | `pi.operation.id`!, `pi.sleep.delay_ms`! number | `pi.sleep.outcome`: `elapsed`, `aborted` | only throw/reject | -| `pi.harness.event_handler` | root or the scope emitting the event | `pi.event.type`! low-cardinality string with the section 10 event discriminants, `pi.lane.name`? string high-cardinality | none | listener throw; the event system catches it after the span rejects | -| `pi.session.write` | root or the current harness scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.session.mutation`!: `entry`, `record`, `lane`, `fact`; `pi.session.item_type`? string | `pi.session.seq` number when the committed API exposes it | storage rejection | - -The parent column maps directly to `TelemetryParentDefinition`: “root or application span” is `root_or_external`; “root or the current scope” and “root or any caller span” are `any`; every finite pi span list uses `spans` with exactly those names. `pi.harness.tool` wraps phase 2 (`executeTool`) only and settles before `after_tool` finalization: `pi.tool.is_error` describes the raw execution result, there is no final `terminate` attribute, and blocked or invalid calls that never execute emit no tool span. Live execution supplies the active turn id and parents the span to `pi.harness.turn`; reconciliation has no durable turn id, omits it, and parents the span directly to the resumed `pi.harness.run` invocation. The `pi.hook.name` values array is exactly `before_run`, `before_resume`, `before_run_end`, `transform_context`, `before_request`, `before_payload`, `after_response`, `before_tool`, `after_tool`, `before_compaction`, and `before_navigation`. The `pi.event.type` values array contains every `type` discriminant in the section 10 catalog and no others. `pi.harness.hook` describes one registered handler invocation, so isolated handler failures have their own status without failing the enclosing run. `pi.harness.event_handler` does the same for passive listener failures. The harness schema declares no span events initially. - -Dynamic identifiers and names are attributes, never span names. The schema definitions are the exhaustive vocabulary pi instrumentation may emit. - -The agent package exports both schemas, `AGENT_TELEMETRY_SCHEMAS`, each span-name union, per-name start/end/combined attribute types, event types, discriminated span unions, and typed `startAiSpan()` / `startHarnessSpan()` helpers. The telemetry package exports `createTypedSpanStarter()` and `TypedSpanStarter`; callers can bind the agent tuple when one scope needs both AI-request and harness spans. Every typed starter or domain helper accepts only that span's start attributes; its callback receives a schema-scoped view of the live span whose `setAttributes()` accepts only that span's optional end attributes and whose `addEvent()` accepts only declared event names and attributes. Individual calls reject missing required attributes, duplicate composed span names, unknown attributes, type mismatches, and invalid closed-set values at compile time. TypeScript does not try to prove that any end setter ran; `startSpan()` always owns automatic settlement. The scoped view erases to the generic `TelemetrySpan`; production performs no schema validation. - -The schema objects are also the documentation source. `packages/agent/scripts/generate-telemetry-docs.ts`, exposed through package scripts `generate-telemetry-docs` and `check:telemetry-docs`, generates the combined AI-request and harness reference at `packages/agent/docs/telemetry-schema.md`. The Markdown file is repository documentation, not an npm package file; published consumers import both serializable schema objects from the agent package root. Schema `version` starts at 1; package changelogs record compatible additions and breaking renames, removals, type changes, or meaning changes. Explicit migration metadata is added only if a real consumer needs automatic translation. - -### Effects and nesting - -Telemetry wrappers follow ownership of ordinary work. The procedure layer wraps orchestration scopes — operation invocation, checkpoint, turn, and retryable step — and passes each callback's `TelemetrySpan` as the parent parameter to work below it. `Effects` wraps the atomic effect it owns. Telemetry is not part of the gated action vocabulary and creates no durable crash boundary. - -```ts -async function assistantAttempt( - turnContext: TelemetryContext, - attempt: number, - resultEntryId: string, -): Promise { - return startHarnessSpan( - turnContext, - "pi.harness.step", - { - "pi.lane.name": state.lane, - "pi.operation.id": op.id, - "pi.step.kind": "assistant", - "pi.step.attempt": attempt, - }, - async (stepContext) => { - await fx.appendRecord( - stepAttempt(op.id, "assistant", attempt, resultEntryId), - stepContext, - ); - const final = await fx.streamAssistant(assistantRequest(state), stepContext); - await fx.appendRecord( - usageRecord("assistant", op.id, resultEntryId, attempt, final), - stepContext, - ); - return final; - }, - ); -} -``` - -Section 14's `streamAssistant()` is the logical model-request wrapper. It starts `pi.ai.request` with `startAiSpan()`, passes that callback span as `ProviderRequestOptions.telemetryContext` through `Models`, records only schema-declared aggregate response fields, and returns the same assistant message. `Effects.executeTool()` similarly wraps only phase 2 in `pi.harness.tool`; hook and event runners follow the same explicit-parent pattern. - -| owner / method | target telemetry | -|---|---| -| operation dispatcher | `pi.harness.run`, `pi.harness.compaction`, or `pi.harness.navigation` | -| checkpoint / turn / step procedure scopes | corresponding `pi.harness.*` scope span | -| `appendEntry`, `appendRecord`, `moveLane`, `setFact`, and a conditional commit that writes | `pi.session.write`; a conditional no-write result emits no write span | -| `streamAssistant`, `fetchDeferred`, `cancelDeferred` | `pi.ai.request` with the matching `pi.ai.operation` | -| `executeTool` | `pi.harness.tool` | -| `runHook` | one `pi.harness.hook` per registered handler | -| `sleep` | `pi.harness.sleep` | -| passive event delivery | one `pi.harness.event_handler` per listener | - -A context object and adapter-native span are process-local capabilities. Neither is persisted in a record, entry, snapshot, event, or deferred handle. - -### Span lifetime - -One operation span wraps one admitted in-process invocation of operation work. An initial `prompt()` / `compact()` / `navigateTree()` starts its span only after its `operation_started` acceptance commit; an admission `Err` such as `LaneBusy`, `InvalidMessage`, `NothingToCompact`, or `UnknownTarget` emits no operation span. A `resume()` starts its wrapper only after lane reservation, identity checks, and the other expected rejection checks pass. Each successful resume admission gets another span with the same durable operation id and recovery `true`. Repeated deferred polling therefore produces repeated ordinary wrapper spans correlated by operation id — no extra public lifecycle concept or durable telemetry state. - -- a returned `completed`, `declined`, `aborted`, or `suspended` result resolves normally; instrumentation may enrich the span with the matching allowed outcome; -- a returned `failed` result explicitly sets error status and still resolves normally as the public API requires; it may also enrich the span with outcome `failed`; -- `close()`, a harness fault, or an invariant defect rejects the callback and therefore ends the local span as an error automatically; -- actual process death runs no cleanup, so the backend may lose or retain an incomplete span; the next process simply creates a new span on `resume()`. - -If an outcome attribute is set, run spans never use `declined`; that value exists only in the compaction and navigation schemas. Trace context is not durable. Persisting a backend-specific trace token would couple recovery data to one telemetry system. A serving layer may link a resumed span to an earlier trace when it has that information. - -The span tree follows execution scopes: - -```text -pi.harness.run -├─ pi.harness.checkpoint -│ └─ pi.harness.step compaction, attempt -├─ pi.harness.turn -│ ├─ pi.harness.step assistant, attempt -│ │ ├─ pi.ai.request provider, model, stop reason -│ │ └─ pi.harness.sleep retry delay -│ └─ pi.harness.tool tool name, call id, replay -├─ pi.harness.hook -├─ pi.harness.event_handler -└─ pi.session.write entry/record/lane/fact - -pi.harness.compaction manual operation -pi.harness.navigation -``` - -The procedure layer owns operation, checkpoint, turn, and step scopes. `Effects` owns session writes, phase-2 tool execution, hooks, and sleep. The request-dispatch wrapper around `Models` owns `pi.ai.request`; passive event delivery owns handler spans. Each owner receives its parent context explicitly. - -### Safety and testing - -Default attributes carry only schema-declared identifiers, names, counts, durations, stop reasons, status codes, and usage. They must never carry prompts, completions, tool arguments, tool output, file content, provider payloads, headers, or credentials. Schema fields flag any future sensitive or high-cardinality attribute explicitly. - -Telemetry remains separate from events and hooks: - -- Events are public live observation. -- Hooks can change execution. -- Telemetry is passive process-local diagnostics. - -## 19. Testing strategy - -Three tiers. Each tests a different claim; none replaces another. - -### Tier A — reduction and resume - -Prefill a session with the records and entries of one section 6 crash state through the public `Session` API (`appendRecord`, low-level `appendEntry`), open the harness, call `resume()`, assert the durable result. - -```ts -await session.appendRecord(opStarted("run", { originalPrompt, initialMessages: [userEntry] })); -await session.appendEntry(userEntry, "main"); -await session.appendRecord(stepAttempt("assistant", 1)); -await session.appendEntry(assistantWithToolCall, "main"); -await session.appendRecord(toolStarted({ replay: "safe", resultEntryId: "result-1" })); -// This durable prefix is X3. - -const { harness, suspended } = await AgentHarness.create(options); -expect(suspended).toHaveLength(1); -expect((await harness.resume()).ok).toBe(true); -``` - -Coverage: every X1–X5 tool state, replay safe/never/changed declarations, every source-order position in a batch, truncated (`length`) batches proving no execution, abort before and after each durable point, the terminal-failure marker with and without later consumed input, missing initial messages, pending, cancelled, and abort-killed queue items, deferred writes, deferred handles (pending, ready, terminal, rejected fetch, mismatched handle, abort), unfinished steps resuming before new checkpoint input is consumed — including steering accepted during an interrupted retry — attempt caps across restart including auto-compaction exhaustion, every overflow crash site from the section 6 table, post-move navigation states from the section 6 table, section 5 validity rejections, and half-completed recovery (run the same prefix through recovery twice). - -The in-memory backend is the reference. The parity suite runs the same setups against memory, JSONL, and SQLite; one case runs concurrent writes on two lanes and asserts unique increasing `seq` and identical `getLog()` order; another asserts every backend rejects the same non-JSON payloads. - -### Tier B — writer conformance - -Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), lane move (`L`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, queue cancellation, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, context overflow (discard, guard, hook-supplied), manual compaction, navigation (move-first), deferred suspension and every fetch outcome. This tier catches the critical regression class: an effect starting before its intent record. - -Tier B also asserts the append-only-context invariant (section 4) executably: within a run, every faux-provider request's message list extends the previous request's as an exact prefix — except across a compaction entry, the one sanctioned invalidation. This turns the KV-cache discipline from prose into a failing test whenever a write path inserts before the tail. - -### Tier C — deterministic interleavings - -`drive: "manual"` against the real `AgentHarness`, the faux provider, and a real backend. The gate is the only test hook; there is no second machine. - -```ts -const { harness } = await AgentHarness.create({ session, models, model, tools: [calc], drive: "manual" }); -const promptResult = harness.prompt("calculate"); - -while ((await harness.peekAction())?.kind !== "execute_tool") await harness.executeAction(); - -// X3: intent durable, effect not started -const started = await session.findRecords({ lane: "main", type: "tool_started" }); -expect(await session.getEntry(started[0]!.resultEntryId)).toBeUndefined(); - -expect((await harness.steer("focus on tests")).ok).toBe(true); // surface is ungated -await harness.runToCompletion(); -expect((await promptResult).ok).toBe(true); -``` - -Crash simulation is `close()` at a chosen boundary, then reopening the same backend and resuming. Crash sites are derived mechanically, not hand-picked: drive each section 6 trace in manual mode, snapshot the backend after **every** `executeAction()`, then reopen every snapshot and `resume()` — and run recovery twice per snapshot, proving half-completed recovery is safe. New effects added to a trace get crash coverage automatically. Coverage: **both orders of every race-catalog row (section 15)**, input injected between arbitrary actions, abort while a cancellable effect is parked and while it runs, and automatic versus manual drive producing identical durable logs and outcomes for the same scripted provider. - -Gate invariants, asserted across Tier C: - -- After every `resume()` outcome, the recomputed reduction's `laneState` equals live `LaneState` (the section 15 fixed-point self-check fired and passed). -- `peekAction()` has no side effect and is stable until `executeAction()`. -- `executeAction()` releases exactly the peeked action, never a later one. -- Stopping before an action leaves exactly the preceding durable prefix. -- While parked, zero storage writes and zero provider or tool calls happen (construction rule, section 15). -- Every accepted operation gets exactly one `operation_finished` unless it suspends. -- A faulted append leaves a valid prefix and faults the whole harness. - -### Other suites - -- The telemetry reference adapter and every third-party adapter run the exported conformance cases for synchronous admission, result/rejection identity, automatic and explicit status, attribute merging, event order, post-settlement behavior, parentage, and unreadable-payload suppression. -- Runtime telemetry tests use the in-memory reference to assert exact schema-conforming span trees and independently valid start/end/event bags on every status path. End attributes remain optional. Content and secret fixtures assert absence, not merely redaction. -- The existing `agent-loop` and `agent` suites pass unchanged — the section 14 compatibility criterion. -- Event ordering per section 10, including `message_end` after commit. -- Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`. -- Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt; a pending deferred fetch that reports no usage writes none); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` token and cost fields equal the ledger sum and the `usage` event's totals after every commit; fork token and cost fields start at zero while `messageCount` includes all copied message entries; v3 conversion preserves totals through the aggregate import adjustment. -- Overflow classification against the reported provider shapes: prompt 268,009 of a 272,000 window and 81,217 of 84,500 (recoverable), non-zero reasoning-only output, cache-write-heavy usage, a Codex-style provider that rejects `max_output_tokens`, a genuine 1,024-token cap fully used (not recoverable), and `length → length` stopping after exactly one recovery per conversational input. -- v3 fixtures: labels, session info, and `leaf` entries mid-chain and at end of file, old `firstKeptEntryId` compactions — all open as one normalized idle `main` lane. - -## 20. Implementation status and work packages - -Work is limited to `packages/agent`, `packages/session-backends/sqlite-node`, `packages/telemetry`, and the telemetry request-option surface in `packages/ai`. Other package source is off limits. In particular, this plan does not migrate `packages/coding-agent`; I0's completed dependency wiring is the only exception. Coding-agent v3 compatibility means only that the new JSONL repository can read supported v3 sessions. - -### Claiming and completing a package - -1. Sync with `main`. A package is claimable only when its checkbox is empty, every dependency is checked, and no active reservation owns the package or overlapping primary files. -2. Add `**Reserved: by @.**` immediately above the package entry. Land that change alone with commit message `docs(agent): reserve `. The package is claimed only after this commit reaches `main`; if another conflicting reservation lands first, remove yours and choose again. -3. Start from the reservation commit. Read the referenced design and primary files. -4. Work in this loop: - 1. Implement the package's described behavior within its primary files. Incomplete public operations keep rejecting with `HarnessNotImplemented`. - 2. Implement comprehensive focused tests that encode the package's acceptance criteria and every design invariant the package owns. Smoke tests and happy-path coverage alone are insufficient; each owned invariant must have an executable assertion. - 3. Iterate on the implementation and tests until the behavior is complete and all affected tests pass. - 4. If the design does not hold, stop and consult Mario on Discord. After agreement, update the design and package description, then return to step 1. -5. Run `npm run check`. The implementation PR or commit removes its reservation and changes the package checkbox to checked. If work is abandoned, remove the reservation without checking the package. - -### Track F — scaffold truth and public ownership - -- [x] **F0 — harden the scaffold.** Dependencies: none. - - Primary files: `packages/agent/src/harness/agent-harness.ts`, `packages/agent/test/harness/agent-harness-scaffold.test.ts`. - - Inventory every public method. Preserve only behavior that is genuinely correct without an operation runtime, such as immutable harness-global configuration copies and direct leaf reads. Make every other placeholder reject with `HarnessNotImplemented` instead of returning empty snapshots, idle state, or no-op drive/wait success. - - Before R3, `AgentHarness.create()` may open only a record-free session. It rejects any session containing records rather than reporting a false empty suspended list. - - Acceptance: a table-driven scaffold test covers every public method and proves no unfinished method reports plausible success. - -### Public method ownership - -This table is exhaustive. A package does not remove `HarnessNotImplemented` from a method until it owns the listed semantics and tests. - -| public surface | owning package | -|---|---| -| scaffold-safe `name`, `getLeafId`, record-free create, runtime settings | F0 | -| `AgentHarness.create()` restore and `suspended` inventory | R3 | -| `lane`, `createLane`, `lanes`, lane facades, lane-bound session reads | H0 | -| resources, stream/retry/compaction settings, queue modes | F0 | -| tool registry plus persisted active-tool selection | H4 | -| `prompt`, `skill`, `promptFromTemplate` | H1 | -| run `resume`, retries, terminal failure | H2 | -| `steer`, `followUp`, `nextRun`, `cancelQueued` | H3 | -| persisted model/thinking/active-tools, lane-view writes, `recordUsage` | H4 | -| `abort`, `waitForIdle`, `runWhenIdle`, close settlement | H5 | -| live tools and tool events | H6 | -| tool recovery through `resume` | H7 | -| deferred-handle `resume` and cancellation | H8 | -| `compact` and compaction resume | C1–C3 | -| `navigateTree` and navigation resume | N1 | -| `peekAction`, `executeAction`, `runToCompletion` primitives/integration | I5/H0 | -| hooks/events registration primitives and harness wiring | I1/I2/H0 | -| `watch`, `watchSession`, complete snapshots | O1 | - -### Track QA — legacy test salvage - -Implementation packages derive their tests from this design and do not use the promotion test matrix. The QA track alone owns `packages/agent/docs/harness-v2-test-matrix.md`. Old tests are evidence, not specification: QA ports a case only when it still expresses a target-design invariant and comprehensive current coverage does not already exist. - -- [x] **QA1 — inventory removed tests.** Dependencies: none. - - Inventory the tests removed by the harness promotion and record whether each case is covered, inapplicable, or blocked on a new implementation package. - - Acceptance: every removed case has a disposition in the matrix; no production or test code changes. - -- [x] **QA2 — salvage storage and query tests.** Dependencies: QA1, R0. - - Port worthwhile bounded-query, corruption, fork, immutable-read, lane, record-query, and recovery-query cases whose replacement APIs already exist. Skip deleted implementation details and behavior already covered by backend conformance. - - Acceptance: each reviewed storage/query case is covered by a cited current test, ported as a comprehensive invariant test, marked inapplicable, or left blocked on J1–J5. - -- [ ] **QA3 — salvage remaining legacy tests.** Dependencies: QA2, J5, O2. - - After the new storage and harness runtime are complete, review every matrix case still blocked or uncovered. Port only still-valid invariants against the new public APIs; do not restore deleted APIs or old implementation details. QA3 may change focused tests and the matrix, but no production code. - - Acceptance: every matrix row ends covered by a cited current test, ported by a comprehensive new test, or explicitly inapplicable; no row remains blocked or uncovered. - -### Track R — recovery query, reducer, and restore - -These packages merge R0 → R1 → R2 → R3. R1 and R2 add a reducer module instead of growing `agent-harness.ts`. R3 is the first package in this track that owns `agent-harness.ts` and therefore runs after F0. - -- [x] **R0 — recovery-query contract.** Dependencies: none. - - Primary files: `packages/agent/src/harness/session/types.ts`, `session.ts`, `memory.ts`, SQLite record storage/repository files, backend conformance, and focused recovery-query tests. - - Add `RecordQuery.operationKind` and `findOpenOperations(lane, { limit })` exactly as specified in sections 7, 12, and 13. Memory maintains the projection, JSONL will derive it during replay, and SQLite answers it from the lane open-operation projection. - - Prove that zero/one open operations are distinguishable, that normal writes cannot start a second operation on a busy lane, and that the latest run-kind start is an indexed query. Add the lane open-operation projection. - - Acceptance: memory and SQLite have identical query behavior, invalid query combinations reject, and no restore algorithm needs a full historical scan. - -- [x] **R1 — pure record-log validity.** Dependencies: R0. - - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. - - Validate the section 5 corruption rules from discovered open starts, bounded records, and point-looked-up entries, with no writes or effects. - - Acceptance: one focused rejection test per validity bullet, plus valid prefixes at every section 6 crash point. - -- [x] **R2 — pure lane-state reduction.** Dependencies: R1. - - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. - - Implement the section 15 `LaneReductionInput` → `LaneReductionResult` contract. Derive pending queues/writes, attempts, tool batches, deferred handles, structural targets, and idle next-run state into `laneState`; derive effective configuration and terminal-failure provenance beside it from the same section 7 query inputs. - - Keep `LaneState` limited to orchestration state. Reduction exclusively owns all three outputs; later recovery packages consume `LaneReductionResult` and do not re-reduce tool or operation records. - - Acceptance: table-driven tests cover idle and every suspended state, configuration fallback/override, and terminal-failure provenance; reduction is deterministic and performs no writes. - -**Reserved: R3 by @vegarsti.** - -- [ ] **R3 — harness restore inventory.** Dependencies: F0, R2. - - Primary files: `packages/agent/src/harness/agent-harness.ts`, reducer integration helpers, and restore tests. - - Wire `AgentHarness.create()` to use indexed open-operation discovery, bounded idle/open scans, explicit provisioned-id point lookups, and bounded configuration lookups. Return accurate `SuspendedOperation[]` without starting effects. - - Acceptance: idle and multi-lane restore write nothing, multiple open operations reject as corruption, suspended metadata is complete, and one lane never scans another lane's traffic. `resume()` may still reject as unimplemented. - -### Track J — JSONL storage - -**In progress and reserved: @davidbrai.** The work began before this plan was split into J0–J5. Before merge, the track owner must include or rebase onto R0's recovery-query contract and report which J packages are complete. Other agents must not pick a J package while this ownership marker remains. - -These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete `JsonlSessionRepo` export, and `packages/agent/test/harness/session/jsonl*.test.ts`. They merge J0 → J1 → J2 → J3 → J4 → J5 and may proceed in parallel with tracks L and I after R0. - -- [x] **J0 — JSONL metadata and codec contracts.** Dependencies: R0. - - Primary files: JSONL type/codec modules and focused codec tests; no public repository export yet. - - Implement the `JsonlSessionMetadata`, create/list options, format-4 header, line discriminants, `modifiedAt`, metadata, and parent-id/legacy-parent-path rules from section 13. - - Acceptance: type and codec round trips cover every header field and line kind; no filesystem lifecycle yet. -- [x] **J1 — format-4 per-session storage.** Dependencies: J0. - - Implement one-session replay/write support for entries, records, lanes, facts, statistics, branch queries, operation-kind queries, and open-operation projection. - - Keep it internal; do not export a partially implemented repository. - - Acceptance: focused round-trip tests cover every mutation, shared `seq`, query bounds, immutable reads, and JSON validation. -- [x] **J2 — format-4 repository lifecycle and forks.** Dependencies: J1. - - Add create/open/list/delete, one writer queue per session, metadata ordering/filtering, branch/tree forks, and the concrete public `JsonlSessionRepo` export. - - Acceptance: the complete backend-neutral conformance suite passes against JSONL, including concurrent lane writes and forks. -- [ ] **J3 — format-4 crash and corruption behavior.** Dependencies: J2. - - Add torn-tail truncation, malformed-interior rejection, missing-reference rejection, and lifecycle/concurrency edge cases. - - Acceptance: acknowledged writes survive reopen and malformed non-tail data is never silently repaired. -- [ ] **J4 — read-only v3 normalization.** Dependencies: J3. - - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, leaf resolution, discarded-entry reparenting, old compactions, timestamps, parent mapping, and idle `main`. - - A read-only open must not modify the physical file. No coding-agent source or test is changed. - - Acceptance: fixture tests cover every normalization rule in section 12 and malformed v3 input. -- [ ] **J5 — first-write v3 conversion.** Dependencies: J4. - - Rewrite through a temporary format-4 file on the first mutation, preserve metadata/facts/tree and resolved or legacy parent linkage, and add the aggregate v3 usage adjustment. - - Acceptance: crash-safe conversion tests cover failure before rename, successful reopen, statistics preservation, unresolved legacy parent paths, and no second conversion. - -### Track I — primitives - -I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins after R2 fixes the `LaneState` shape. These packages use separate modules with focused unit tests; I5 remains primitive-only and does not edit `agent-harness.ts`. - -- [x] **I0 — telemetry contracts, typed schemas, and no-op context.** Dependencies: none. - - Primary files: `packages/telemetry/src/index.ts`, `packages/telemetry/src/memory.ts`, `packages/telemetry/src/testing/`, and focused tests; pi-ai request-option types/propagation and focused tests; `packages/agent/src/harness/telemetry.ts`, `packages/agent/src/index.ts`, focused tests, package scripts, `packages/agent/scripts/generate-telemetry-docs.ts`, and generated `packages/agent/docs/telemetry-schema.md`. Do not edit `agent-harness.ts`; its canonical context type is landed, while H0 owns option renaming/defaulting/storage and execution threading after convergence. - - In telemetry, implement the one canonical section 18 callback-based `TelemetryContext` / `TelemetrySpan` contract, shared no-op context, deterministic in-memory reference adapter, runner-independent adapter conformance cases, serializable `defineTelemetrySchema()` machinery, and `createTypedSpanStarter(context, schemas)` composition with child-bound starters. - - In pi-ai, add optional `telemetryContext` to `ProviderRequestOptions` so every stream, deferred, and image option inherits it; provider, `Models`, `ImagesModels`, direct dispatch, and simple-option conversion preserve it. Pi-ai owns no domain schema or helper. - - In agent, define the complete normative `AI_TELEMETRY_SCHEMA` and `HARNESS_TELEMETRY_SCHEMA`, their inferred types, the readonly `AGENT_TELEMETRY_SCHEMAS` composition tuple, and typed `startAiSpan()` / `startHarnessSpan()` helpers. Export both schemas, the tuple, and helpers, and re-export the generic telemetry surface from the agent package root. Do not duplicate the generic contract and do not adopt OTel or another external semantic convention. - - Generate the combined repository-only Markdown reference from the runtime schema values with the named agent package scripts. Production helpers perform no runtime schema validation; schemas compile-time-check each pi-written start/end/event call and remain importable as machine-readable data. - - Wire telemetry before pi-ai in workspace, local-release, publish, profiling, and coding-agent binary build order; add source-test aliases and refresh workspace/generated dependency locks. - - Landed coverage: focused tests exercise no-op synchronous admission, returned-value and sync/async rejection preservation, explicit no-op child propagation, one shared frozen inert span with no payload inspection, exact start/optional-end inference, multi-schema vocabulary composition, child-starter parent propagation, rejection of duplicate span names and missing, unknown, empty-schema, and invalid closed-set attributes, absence of declared span events, schema JSON serialization, the in-memory reference against every exported adapter conformance case, option propagation across provider/`Models` stream and deferred dispatch, direct and `ImagesModels` image dispatch, built-in simple-option conversion, and generated-document freshness. O2 will use the reference adapter to test pi's runtime status and nesting behavior with captured spans. -- [ ] **I1 — hook registry and runner.** Dependencies: none. - - Primary files: `packages/agent/src/harness/hooks.ts`, `packages/agent/test/harness/hooks.test.ts`. - - Implement typed registration, stable-id validation, ordered aggregation, error isolation, fail-closed `before_tool`, and per-id resume data handling. - - Acceptance: focused tests cover every section 11 aggregation and failure rule; no operation wiring yet. -- [ ] **I2 — passive events and watch buffering.** Dependencies: none. - - Primary files: `packages/agent/src/harness/events.ts`, `packages/agent/test/harness/events.test.ts`. - - Implement passive listener isolation and the snapshot/start/unsubscribe buffer primitive used by lane and session watchers. - - Acceptance: no snapshot/event gap, ordered one-time flush, independent watchers, and `handler_error` recursion safety; no operation wiring yet. -- [ ] **I3 — lane mutation line.** Dependencies: R2. - - Primary files: `packages/agent/src/harness/lane-runtime.ts`, focused mutation-line tests. - - Implement the per-lane FIFO and state-update discipline with test-only jobs for every conditional history in section 15. - - Acceptance: jobs never interleave, rejected jobs do not poison the queue, and no external effect runs inside a job. -- [ ] **I4 — automatic `Effects` implementation.** Dependencies: I0, I1, I3, L3. - - Primary files: `packages/agent/src/harness/effects.ts`, focused effects tests. - - Implement durable writes, conditional commits, provider/tool/hook adapters, sleep, fault propagation, and live-state updates behind the complete `Effects` interface. - - Acceptance: every external effect and durable write crosses `Effects`, and a failed write faults the whole harness. -- [ ] **I5 — manual gate primitive.** Dependencies: I4. - - Primary files: `packages/agent/src/harness/gated-effects.ts`, focused gate tests. - - Implement `GatedEffects` action descriptions, stable peek, exactly-one release, reentrant nested actions, run-through, and parked rejection without wiring public lane controls yet. - - Acceptance: zero effects while parked, nested hook actions surface without deadlocking their released parent, and durable-prefix close simulations pass at the primitive boundary. - -### Track L — agent-loop building blocks - -These packages all own `packages/agent/src/agent-loop.ts` and therefore merge strictly L1 → L2 → L3. Existing `agent-loop` and `agent` tests pass unchanged after each package. - -- [ ] **L1 — extract assistant streaming.** Dependencies: I0. - - Add `streamAssistant()` and `StreamAssistantConfig`, including explicit telemetry context; route the compatibility loop's request path through it without changing events or results. - - Acceptance: focused stream tests cover settled-result narrowing (a final `pending` value is a defect), plus unchanged existing loop tests. -- [ ] **L2 — extract tool-call phases.** Dependencies: L1. - - Add `prepareToolCall()`, `executeToolCall()`, `finalizeToolCall()`, result helpers, replay declaration, explicit telemetry contexts, and durability callbacks without changing batch behavior. - - Acceptance: phase tests cover validation, blocking, abort, callback failure, updates, and patches. -- [ ] **L3 — compose tool batches and compatibility wrappers.** Dependencies: L2. - - Add `executeToolBatch()` with sequential/parallel source ordering, truncation, abort, and `terminate` rules; make every legacy loop export a thin composition using the no-op context. - - Acceptance: source-order and parallelism tests plus unchanged `agent-loop` and `agent` suites. - -### Track H — harness integration and run execution - -H0 converges restore and primitives into `agent-harness.ts`. H0–H8 then merge strictly in order. Each package adds its Tier A recovery cases, Tier B exact trace, relevant events/hooks, and Tier C interleavings rather than deferring testing to the end. - -- [ ] **H0 — lane facades and primitive integration.** Dependencies: R3, I2, I5. - - Wire durable lane lookup/creation/inventory, equivalent name-bound facades, canonical hook/event/telemetry types, rename `AgentHarnessOptions.context` to `telemetryContext` with the no-op default and stored root context, public manual-drive controls, and ownership/close plumbing. - - Acceptance: repeated facades are equivalent, lanes remain isolated, public drive controls match gate actions, and no placeholder operation is accidentally enabled. -- [ ] **H1 — one successful no-tool run.** Dependencies: H0, L3, I1. - - Implement `prompt`, skill/template expansion, run acceptance, capture of already-pending next-run items, initial appends, one assistant step, usage record, message commit, conditional finish, result, and basic run/turn/message events/hooks. - - H3 later owns public next-run enqueue/cancel/race behavior; H1 owns capture into `operation_started.initialMessages`. - - Acceptance: automatic/manual durable logs are identical; closing after every released action restores the expected suspended prefix. -- [ ] **H2 — retry, run resume, and terminal failure.** Dependencies: H1. - - Add durable attempt counts, retry policy/backoff/events, unfinished-assistant resume, give-up error entries, terminal-failure drain, and fixed-point checks for these states. - - Acceptance: retry caps survive reopen; failed attempts record usage but no message; half-completed recovery is idempotent. -- [ ] **H3 — queues and checkpoints.** Dependencies: H2. - - Add next-run/steer/follow-up acceptance and modes, cancellation, checkpoint consumption, queue events, and finish-boundary conditionals. Consume the queue state produced exclusively by R2. - - Acceptance: both orders of race rows 2, 5, 7, and 12; provider context grows only at the tail. -- [ ] **H4 — deferred writes, persisted configuration, and adjustments.** Dependencies: H3. - - Add deferred lane-view tree/configuration writes, direct idle writes, model/thinking/active-tool persistence and lookup, `recordUsage`, pending-write snapshots/events, and finish conditionals. - - Acceptance: both orders of race rows 3 and 9; accepted writes survive crashes and abort markers; adjustments affect ledger totals but never entries. -- [ ] **H5 — abort, wait, run-when-idle, and close.** Dependencies: H4. - - Add durable abort acceptance, queue draining, pending-write application, synthetic closure messages/results, suspended abort, idle waiters/callbacks, and process-local close settlement. - - Acceptance: both orders of race rows 4, 6, 8, and 10 and crash/reopen after every abort action. -- [ ] **H6 — live durable tool batches.** Dependencies: H5. - - Wire section 14 tool callbacks through `Effects`; write `tool_started` before execution, persist finalized results and `terminate`, report usage, and emit tool events. - - Acceptance: exact one-tool and parallel-batch traces; no blocked/invalid tool writes an intent; source-order finalization is stable. -- [ ] **H7 — tool recovery.** Dependencies: H6. - - Consume R2's X1–X5 reduced state and reconcile it; replay only when persisted and current declarations are safe, preserve ordinals, and handle truncated batches without execution. Do not duplicate reducer logic. - - Acceptance: complete tool crash matrix, changed replay declarations, parallel-prefix crashes, and idempotent second recovery. -- [ ] **H8 — deferred provider redemption.** Dependencies: H7. - - Integrate the already-landed pi-ai deferred APIs: suspend, pending re-park, ready continuation, terminal/rejected fetch failure, handle mismatch, and best-effort cancellation. - - Select and document whether `resume()` uses a non-zero `fetchDeferred` wait or checks once and re-parks immediately. - - Acceptance: one fetch per resume; pending writes nothing except reported usage; terminal errors never start replacement requests. - -### Track C/N — structural operations - -These packages also own `agent-harness.ts` and merge after H8, in order C1 → C2 → C3 → N1. - -- [ ] **C1 — manual compaction operation.** Dependencies: H8. - - Add acceptance, hook decision, durable summary attempts/usage, complete `retainedTail`, result entry, abort/failure, and structural resume. - - Acceptance: exact manual-compaction traces and every crash boundary; hook-supplied summaries obey the same persisted entry contract. -- [ ] **C2 — threshold auto-compaction.** Dependencies: C1, H4. - - Run compaction inside the active run at checkpoints without a nested operation and continue the assistant loop. - - Acceptance: append-only context holds except at the compaction boundary; repeated compaction retains the previous checkpoint tail. -- [ ] **C3 — overflow recovery.** Dependencies: C2, H2. - - Classify recoverable overflow/length results, discard them after usage accounting, compact, retry once per conversational input, and fail boundedly. - - Acceptance: every provider shape and crash row from sections 6 and 20, including hook decline and `length → length`. -- [ ] **N1 — move-first navigation.** Dependencies: C3. - - Add acceptance, abandoned-branch preparation, hook/generated summary, move commit, post-move summary/fact writes, abort/failure, and structural resume. - - Acceptance: every navigation crash row, including regeneration after a post-move crash and target/source validation. - -### Track O — observability and core completion - -These packages merge O1 → O2 → O3 → O4 after N1, with QA3 between O2 and O3. QA3 also requires J5. They may not modify `packages/coding-agent/**`. - -- [ ] **O1 — snapshots and event completeness.** Dependencies: N1, I2. - - Finish live lane/session snapshots, event filtering, streaming/running-tool state, and all section 10 event insertion points. - - Acceptance: event nesting/order tests and attach-mid-operation snapshot tests with no subscription gap. -- [ ] **O2 — runtime telemetry instrumentation.** Dependencies: O1, I0. - - Insert operation/checkpoint/turn/step wrappers at their procedure scopes, effect and passive-handler spans at their owning boundaries with `startHarnessSpan()`, and logical model-request spans with `startAiSpan()`. Populate only schema-declared attributes, including parallel tool children and resumed operation correlation; expected in-band failures set error status explicitly. - - Acceptance: captured telemetry has exact schema-conforming span trees for success, failure, suspend/resume, retry, compaction, and parallel tools; every emitted start/end/event bag conforms independently, callback spans settle exactly once, and no undeclared names, content, or secrets appear in defaults. -- [ ] **O3 — action-prefix and race audit.** Dependencies: O2, QA3. - - Complete Tier C for every race row, mechanically reopen every action prefix, compare automatic/manual logs, and verify reducer/live-state fixed points. - - Acceptance: every race row has both orders and no documented crash action lacks a reopen test. -- [ ] **O4 — backend parity and final core audit.** Dependencies: J5, O3. - - Run the complete storage/recovery matrix across memory, JSONL, and SQLite; remove dead agent/storage declarations and compatibility comments; verify exports/declarations and `./node`; update changelogs and core documentation. - - Acceptance: all non-e2e tests and `npm run check` pass, no active harness operation remains scaffolded, `packages/coding-agent/**` is unchanged, and the worktree is clean. - -### Dependency, priority, and merge summary - -The serial storage lane is **R0 → J0 → J1 → J2 → J3 → J4 → J5**. The reducer lane is **R0 → R1 → R2 → R3**. The loop lane is **I0 → L1 → L2 → L3**. The effects lane is **R2 → I3 → I4 → I5**, with I4 also requiring I0, I1, and L3. Before H0, the convergence gate is **F0 + R3 + I2 + I5**. - -The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H6 → H7 → H8 → C1 → C2 → C3 → N1 → O1 → O2 → QA3 → O3 → O4**. J5 may land independently at any time before QA3. This ordering prevents concurrent rewrites of `agent-harness.ts`, assigns every public method, and ensures every live path lands only after its reducer, telemetry, interception, and effect boundaries exist. - -## 21. Required reading - -For a fresh implementation session, in this order. This document wins over older harness designs. - -1. `packages/agent/docs/harness-v2.md` — this document. -2. `packages/agent/src/harness/session/types.ts` — v4 entries, records, storage, and repository contracts. -3. `packages/agent/src/harness/session/session.ts` — session validation and lane-bound views. -4. `packages/agent/src/harness/session/memory.ts` — reference backend. -5. `packages/session-backends/sqlite-node/src/sqlite/repo.ts` — v4 SQLite repository, leases, and forks. -6. `packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts` — branch cache queries. -7. `packages/agent/src/harness/agent-harness.ts` — public harness API and runtime. -8. `packages/telemetry/src/index.ts` — canonical telemetry contract, schema machinery, typed starter, and public exports. -9. `packages/telemetry/src/noop.ts`, `memory.ts`, and `testing/` — no-op/reference contexts and reusable conformance cases. -10. `packages/agent/src/harness/telemetry.ts` — AI-request and harness schemas, combined schema tuple, and typed helpers. -11. `packages/agent/src/agent-loop.ts` — agent-loop implementation and section 14 building blocks. -12. `packages/agent/src/agent.ts` — queues, continuation, abort, settlement to preserve in spirit. -13. `packages/agent/src/harness/messages.ts` — message conversion (`toProviderMessages` default). -14. `packages/agent/src/harness/compaction/compaction.ts` — preparation and split-turn summaries. -15. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing. -16. `packages/coding-agent/src/core/agent-session.ts` — read-only behavioral reference; do not modify it. -17. `packages/coding-agent/src/core/extensions/runner.ts` — read-only error-isolation reference; do not modify it. -18. `packages/coding-agent/docs/session-format.md` — read-only v3 JSONL format reference. diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md new file mode 100644 index 00000000000..9e38c1fab7e --- /dev/null +++ b/packages/agent/docs/harness.md @@ -0,0 +1,2941 @@ +# AgentHarness — implementation specification + +- [Part 0 — Orientation](#part-0--orientation) + - [0.1 What this is](#01-what-this-is) + - [0.2 System model](#02-system-model) + - [0.3 The three stores](#03-the-three-stores) + - [0.4 Worked example — a Slack thread](#04-worked-example--a-slack-thread) + - [0.5 Worked example — a crash mid-tool](#05-worked-example--a-crash-mid-tool) + - [0.6 Non-goals](#06-non-goals) + - [0.7 Notation and source types](#07-notation-and-source-types) +- [Part 1 — Storage](#part-1--storage) + - [1.1 The model](#11-the-model) + - [1.2 Identity](#12-identity) + - [1.3 Register namespaces](#13-register-namespaces) + - [1.4 Transactions](#14-transactions) + - [1.5 Queries](#15-queries) + - [1.6 Usage ledger](#16-usage-ledger) + - [1.7 Backends](#17-backends) + - [1.8 Why write-once plus registers](#18-why-write-once-plus-registers) +- [Part 2 — The conversation tree](#part-2--the-conversation-tree) + - [2.1 Entries](#21-entries) + - [2.2 Placement](#22-placement) + - [2.3 Lanes](#23-lanes) + - [2.4 Facts](#24-facts) + - [2.5 Branch queries and context](#25-branch-queries-and-context) + - [2.6 The branch index](#26-the-branch-index) + - [2.7 Forks](#27-forks) + - [2.8 Session and repository boundary](#28-session-and-repository-boundary) + - [2.9 The precise rewrite](#29-the-precise-rewrite) +- [Part 3 — The operation state machine](#part-3--the-operation-state-machine) + - [3.1 Operations](#31-operations) + - [3.2 Operation state — the program counter](#32-operation-state--the-program-counter) + - [3.3 Lane state and current-state validity](#33-lane-state-and-current-state-validity) + - [3.4 The atomic transition rule](#34-the-atomic-transition-rule) + - [3.5 The graph](#35-the-graph) + - [3.6 Acceptance](#36-acceptance) + - [3.7 Assistant generation](#37-assistant-generation) + - [3.8 Tools](#38-tools) + - [3.9 Summary generation — compaction and navigation summaries](#39-summary-generation--compaction-and-navigation-summaries) + - [3.10 Navigation](#310-navigation) + - [3.11 Inbox, queues, deferred writes](#311-inbox-queues-deferred-writes) + - [3.12 The checkpoint procedure](#312-the-checkpoint-procedure) + - [3.13 Terminal transactions](#313-terminal-transactions) +- [Part 4 — Execution, recovery, abort, close](#part-4--execution-recovery-abort-close) + - [4.1 The interpreter](#41-the-interpreter) + - [4.2 The effects boundary](#42-the-effects-boundary) + - [4.3 The lane mutation line](#43-the-lane-mutation-line) + - [4.4 Restore](#44-restore) + - [4.5 Crash positions and recovery policy](#45-crash-positions-and-recovery-policy) + - [4.6 Abort](#46-abort) + - [4.7 Close — a controlled crash](#47-close--a-controlled-crash) + - [4.8 Faults](#48-faults) + - [4.9 External finalization](#49-external-finalization) +- [Part 5 — Public surface](#part-5--public-surface) + - [5.1 The lane surface](#51-the-lane-surface) + - [5.2 The harness](#52-the-harness) + - [5.3 SessionTree](#53-sessiontree) + - [5.4 Snapshots and subscription](#54-snapshots-and-subscription) + - [5.5 Events](#55-events) + - [5.6 Hooks](#56-hooks) + - [5.7 Agent-loop building blocks](#57-agent-loop-building-blocks) + - [5.8 Telemetry](#58-telemetry) +- [Part 6 — Future: partitioned retention (Postgres)](#part-6--future-partitioned-retention-postgres) +- [Part 7 — Schema evolution](#part-7--schema-evolution) + - [7.1 The problem](#71-the-problem) + - [7.2 Why this design shrinks the problem](#72-why-this-design-shrinks-the-problem) + - [7.3 The mechanism: storage version plus migrate-on-open](#73-the-mechanism-storage-version-plus-migrate-on-open) + - [7.4 Migrations are total](#74-migrations-are-total) + - [7.5 The three strata, restated as policy](#75-the-three-strata-restated-as-policy) +- [Part 8 — Build order](#part-8--build-order) +- [Part 9 — Invariants and tests](#part-9--invariants-and-tests) + - [9.1 Invariants](#91-invariants) + - [9.2 Race catalog](#92-race-catalog) + - [9.3 Test tiers](#93-test-tiers) +- [Appendix A — Glossary](#appendix-a--glossary) +- [Appendix B — Coding-agent v3-format compatibility](#appendix-b--coding-agent-v3-format-compatibility) +- [Appendix C — Open questions](#appendix-c--open-questions) +# Part 0 — Orientation + +## 0.1 What this is + +A durable runtime for agent conversations. It persists conversation and operation state so interrupted work can resume without repeating settled effects. + +## 0.2 System model + +### Session + +A session groups related work and has four parts: + +- **Entry tree.** An entry is a message, compaction, branch summary, or application-defined custom entry. Entries are immutable. Each branch is a conversational thread; the shared tree enables branching, compaction, forking, and parallel work while preserving history. + + ```text + a ── b ── c ── d + └── e ── f + ``` + +- **Facts.** Mutable, namespaced key-value state. Built-ins include the session name and entry labels; applications may store custom facts. +- **Lanes.** Named cursors into the tree. Every session has `main`. A lane owns its leaf, model configuration, queues, and at most one operation. Additional lanes support Slack threads, subagents, and other parallel work over shared history. +- **Usage ledger.** Append-only token and cost events for the session. + +### Harness and operations + +The session layer manages durable data and exposes typed tree views. The harness drives lanes: it accepts prompts, runs model and tool steps, manages queues, compacts or navigates the tree, and resumes interrupted work. It also owns harness-wide registries of available tools and prompt resources, hooks that intercept and transform execution, passive events that report activity and durable changes, and runtime configuration. + +An **operation** is one accepted unit of lane work: a run, compaction, or navigation. Its immutable metadata records its identity, intent, and starting point; its total current state records its phase, control, queues, and recovery data. Each durable transition replaces the current state. Completion removes the operation state and records the lane's result. + +### Storage + +Below the session and harness, `Storage` exposes atomic transactions and queries over three durable forms: immutable entries, mutable registers, and append-only usage rows. Registers form a mutable, namespaced key-value store. Facts live there; internal harness namespaces durably store pending content and lane and operation state needed for crash recovery. In particular, `op.meta` is written once with an operation's metadata, while `op.state` is replaced after each transition with its complete current state. The terminal transaction deletes both and writes `lane.lastResult`. No partial transaction is visible. + +## 0.3 The three stores + +Everything in Parts 1–5 follows from these. + +**1. Three stores, one invariant.** Everything durable is one of: + +```text +entries the conversation tree — write-once, append-only +registers current mutable state — namespaced typed cells, overwrite or delete +usage ledger cost history — append-only rows +``` + +*Every payload is in an entry, a register, or the ledger; there is no third place.* An entry is the complete conversation record — placement and payload in one row. A register holds its current typed value directly; overwriting discards the old value, and deletion removes the key. Content that durably exists before it has a place in the tree (queued input, deferred writes) waits in a `pending.entry` register and becomes an entry in the transaction that places it. Per-backend projections — branch index, full-text search, stats — are rebuildable from the three stores and carry no authority. + +**2. Atomic transactions.** A transaction is a set of entry inserts, usage inserts, and register writes (set or delete), committed all-or-none with strictly increasing sequence numbers. There is no crash state inside a transaction. This is the only write primitive. + +**3. The durable program counter.** After every step, the harness overwrites one register — `op.state/{operationId}` — with the *complete* current state of the operation. Recovery does not replay a journal or infer position from what is missing; it reads that register and switches on it. The state is *total* — it never depends on a previous state. Small captured values (configuration, stream options, retry policy) are inline; large stable payloads live in sibling `op.*` registers or are named by id. When the operation ends, the terminal transaction deletes its registers: a finished session holds exactly the conversation, the ledger, and a handful of lane and fact registers. There is no dead state to collect. + +**4. The effect sandwich.** Provider requests and real tool calls are wrapped in two commits: + +``` +commit: "about to do X; its output will use ids R and U" ← intent + do X ← the uncertain part +commit: output + usage + next state ← settlement +``` + +Hooks follow their replay contract instead: a result becomes durable in the transaction that consumes it, and a crash before that transaction may rerun the hook. Thus every external effect can still happen without durable settlement. Provider/tool intents make that uncertainty explicit where replay policy depends on it; idempotent hooks accept it as a non-goal. + +## 0.4 Worked example — a Slack thread + +A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current leaf. Entry ids are UUIDv7s (§1.2); examples abbreviate them. + +``` +harness.createLane("slack:1719432.0021", at: "0195c8d1-4a2e-7b31-…") +lane.prompt("what changed in auth last week?") +``` + +What happens, in order: + +1. **Acceptance.** The harness validates, runs the `before_run` hook, and commits one transaction: the user-message entry, the operation's `op.meta` register, and its first `op.state` — *"I am at a checkpoint, and I need an assistant response."* +2. **Intent.** After an internal ready-state commit, it commits the request intent: *"I am about to make a provider request. The response will be entry `0195c8d1-53a0-7c44-…` and the usage row will be `0195c8d1-53a0-7d18-…`."* Both ids are minted now; nothing has been sent yet. +3. **The request.** Streaming happens. This is the only part that is not durable. +4. **Settlement.** One transaction commits the response entry, its usage row, and the next state: *"the response has tool calls; here is the batch plan, with result ids already assigned."* +5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each. +6. When the model stops without tool calls, a terminal transaction deletes the operation's registers, records the outcome in `lane.lastResult`, and leaves the lane idle. + +As a trace (ids abbreviated; every `TX[...]` is one atomic commit): + +```text +TX[ insert entry n1 (user msg), upsert op.meta/O, upsert op.state/O = checkpoint, + upsert lane.leaf = n1, upsert lane.state = { currentOperationId: O } ] +TX[ upsert op.state/O = assistant ready (config snapshot) ] +TX[ upsert op.state/O = effect_pending (reserves response n2, usage u1) ] +… provider streams … ← the uncertain window +TX[ insert entry n2, insert usage u1, upsert lane.leaf = n2, + upsert op.state/O = tools (result id n3 reserved) ] +TX[ upsert op.tool_args/O:s1:0, upsert op.state/O = call 0 effect_pending ] +… tool runs … +TX[ insert entry n3, upsert lane.leaf = n3, upsert op.state/O = checkpoint ] +… second turn: ready · intent · stream · settle (n4, u2) … +TX[ delete op.meta/O, op.state/O, op.tool_args/O:*, + upsert lane.lastResult = { O, completed, n4 }, + upsert lane.state = { currentOperationId: null } ] +``` + +Kill the process between any two of those transactions and restart. The harness reads the lane's registers, sees exactly which of those sentences was the last one committed, and continues. If it died in step 3, it knows a request may have been billed and may or may not have produced output — that is the one genuinely uncertain window in the whole system, and there is a stated policy for it. + +Meanwhile a second thread in the same channel is running its own lane, over the same 400 entries of shared history, with no coordination between them. + +## 0.5 Worked example — a crash mid-tool + +``` +lane.prompt("delete the stale migrations and run the test suite") +``` + +The model returns two tool calls. The harness commits the batch plan, then commits `call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay`. The tool starts deleting files. The process is killed. + +```text +TX[ insert entry n2 (assistant, 2 calls), insert usage u1, upsert lane.leaf = n2, + upsert op.state/O = tools (result ids n3, n4 reserved) ] +TX[ upsert op.tool_args/O:s1:0, upsert op.state/O = call 0 effect_pending, + replay: "never" ] +… tool deletes files … ← CRASH +``` + +On restart the harness reads one register and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1: + +```text +TX[ insert entry n3 (synthetic "interrupted" result), upsert lane.leaf = n3, + upsert op.state/O = call 0 completed ] +``` + +The conversation stays coherent — every tool call has a result — and nothing ran twice. + +Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. + +## 0.6 Non-goals + +- **Exactly-once external effects.** See above. Hooks with their own side effects must be idempotent, keyed by operation id. +- **Provider stream resumption.** Partial streams are process-local, never persisted. A settled response is persisted *completely* before anything classifies it. +- **Multiple writers.** One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.7). Lanes cover the workload that looks like multi-writer. +- **Replication.** A session lives in one place. +- **Durable write history.** Registers hold only current values: an overwritten register is gone, and no API or table exposes write history. Order-of-write assertions in tests use an instrumented storage decorator around `commit()` (Part 9); production auditing belongs to the telemetry layer (§5.8). +- **Deletion as a runtime feature.** Entries and usage rows are never deleted: compaction changes provider context, not storage, and terminal cleanup deletes only registers. Note that `retainedTail` copies old messages forward into newer compaction entries and summaries derive from old content, so compaction is not erasure either. Compliance-grade "erase this" is the administrative precise rewrite (§2.9), the sole sanctioned exception. + +## 0.7 Notation and source types + +- `TX[ a, b, c ]` — one atomic commit containing writes `a`, `b`, `c` in that order. The write vocabulary is `insert entry`, `insert usage`, `upsert namespace/key = value`, and `delete namespace/key`. +- Ids are UUIDv7s (§1.2). Examples abbreviate them: short tags — `e_*` entry ids, `u_*` usage ids, `op_*` operation ids — stand in for full ids where the time prefix is irrelevant; where the prefix matters, examples show it (`0195c8d1-4a2e-7b31-…`). +- `S(next)` — overwrite the `op.state/{operationId}` register with the next total operation state. `L(next)` — the same for `lane.state/{lane}`. +- **must / must not** are normative. Everything else is explanation. + +Source type provenance: + +- `AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, and `ThinkingLevel`: `packages/agent/src/types.ts`. +- `AgentEventSink`: `packages/agent/src/agent-loop.ts`. +- `Skill`, `PromptTemplate`, `AgentHarnessResources` (`Resources` below), `AgentHarnessTool`, `AgentHarnessStreamOptions`, and `AgentHarnessStreamOptionsPatch`: `packages/agent/src/harness/types.ts`. +- `Model`, `Models`, `Usage`, `RetryPolicy`, `StopReason`, `AssistantMessage`, `ImageContent`, provider messages, stream options, and deferred handles: `packages/ai`. +- `CompactionSettings`, `CompactionPreparation`, `CompactResult`, `BranchPreparation`, and `BranchSummaryResult`: `packages/agent/src/harness/compaction/`. Existing preparation and split-turn algorithms remain the implementation starting point unless this document explicitly changes them. +- `TelemetryContext` and typed schema helpers: `packages/telemetry`; the agent-owned schemas remain in `packages/agent/src/harness/telemetry.ts`. +- `TSchema` for durable custom-message registration: `typebox`. + +The public `QueueMode` remains `"all" | "one-at-a-time"`. Public `RetryPolicy` remains the pi-ai shape `{ enabled, maxRetries, baseDelayMs }`; operation state stores its normalized `{ maxAttempts, baseDelayMs }` equivalent. `maxRetries` and `baseDelayMs` must be finite non-negative safe integers and `maxRetries + 1` must remain safe; disabled retry normalizes to one attempt. Exponential delay and `notBefore` arithmetic saturate at `Number.MAX_SAFE_INTEGER`. Public `CompactionSettings` remains `{ enabled, reserveTokens, keepRecentTokens }`; both token counts must be finite non-negative safe integers. Constructors and setters reject invalid settings before publication. This design adds `deferred?: boolean | { window?: "15m" | "1h" | "24h" }` to `AgentHarnessStreamOptions` and its patch type; structural requests always force it to false. + +```ts +type SettledAssistantMessage = AssistantMessage & { + stopReason: Exclude; +}; + +// Provider dispatch resolves the durable { provider, modelId } identity +// through Models at request time, which also applies auth. A missing or +// swapped registry entry fails the request in-band, like an unknown tool. +``` + +--- + +# Part 1 — Storage + +Storage knows nothing about agents, lanes, or conversations. It stores entries and usage rows, updates registers, and answers a small fixed set of queries. Parts 2–4 are built entirely on this. + +## 1.1 The model + +```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; + +/** Write-once. The complete conversation record: placement and payload in one + row. Created in exactly one transaction, never modified or deleted. The + four concrete entry types extending this base are defined in §2.1. */ +interface EntryBase { + id: string; // UUIDv7 (§1.2) + parentId: string | null; + seq: number; // storage-assigned at commit + timestamp: number; // Unix ms, storage-assigned at commit + type: EntryType; + customType?: string; // when type === "custom" + // ...payload fields per entry type (§2.1) +} + +type EntryType = "message" | "compaction" | "branch_summary" | "custom"; + +/** The only mutable store. A namespaced key holding its current typed value + directly. Overwrite replaces the value; delete removes the key. */ +interface Register { + namespace: N; + key: string; + value: RegisterValues[N]; + seq: number; // seq of the write that last set this register +} + +/** Append-only cost ledger row. Never modified, never deleted (§1.6). */ +interface UsageRow { + id: string; // UUIDv7 (§1.2) + seq: number; // storage-assigned at commit + usage: Usage; + entryId?: string; // the entry this cost belongs to, when there is one + adjustment: boolean; // true = caller-supplied reconciliation, not a provider report + details?: JsonValue; +} +``` + +## 1.2 Identity + +Every id — entry, usage, and every reserved id — is a **UUIDv7** from the session's id generator (§2.8); legacy imports re-mint to conform (Appendix B). The first 48 bits are the mint time, so every reference is self-describing and time-sortable. Cost accepted: ids leak creation time. (A future partitioned Postgres backend would build on this prefix — informative Part 6.) + +Minting rules: + +1. Ids are minted with `now()` **at reservation**. Direct appends place in the same transaction; assistant/tool ids trail placement by at most the request duration. +2. **Tool-result ids inherit their assistant id's timestamp** (`idGenerator.next(timestampMs?)`, fresh random tail), so a call-and-results group is time-cohesive under id order even across a midnight boundary. +3. Synthetic settlements write under already-reserved ids (§4.5) — no special case. + +**Opaque payloads** — custom entry `data`, `details`, `fact.custom` values, message text, hook `resumeData` — may embed entry ids. The harness never tracks those references and they may go stale; copy content, don't reference it. + +**Absolutes.** Within a session, entries and usage rows are never deleted — the precise rewrite (§2.9) is the sole exception. A missing parent is always corruption. + +## 1.3 Register namespaces + +```ts +interface RegisterValues { + "lane.leaf": string | null; // entry id; null = lane at the root + "lane.config": LaneConfiguration; // §2.3 + "lane.state": LaneState; // §3.3 + "lane.lastResult": LaneLastResult; // §3.13 + "op.meta": Operation; // §3.1 + "op.state": OperationState; // §3.2 — the program counter + "op.tool_args": Record; // effective tool arguments (§3.8) + "op.preparation": DurableStructuralPreparation; // §3.9 + "pending.entry": PendingEntry; // §2.2 + "fact.name": string; + "fact.label": string; + "fact.custom": JsonValue; // JSON null is a legal value +} +type RegisterNamespace = keyof RegisterValues; + +/** Unplaced content: current mutable state until the placement transaction + writes the complete entry and deletes this register (§2.2). */ +interface PendingEntry { + type: "message" | "custom"; + customType?: string; + payload?: JsonValue; // the content that becomes the entry's payload; + // absent = a custom entry with no data +} + +interface DurableFileOperations { + read: string[]; written: string[]; edited: string[]; +} +type DurableStructuralPreparation = + | { kind: "compaction"; messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; retainedTail: AgentMessage[]; + isSplitTurn: boolean; tokensBefore: number; previousSummary?: string; + fileOps: DurableFileOperations; settings: CompactionSettings } + | { kind: "branch_summary"; messages: AgentMessage[]; + fileOps: DurableFileOperations; totalTokens: number }; +``` + +| Namespace | Key | Value | Meaning | +|---|---|---|---| +| `lane.leaf` | lane name | entry id or `null` | where this lane appends next | +| `lane.config` | lane name | `LaneConfiguration` | total lane configuration | +| `lane.state` | lane name | `LaneState` (§3.3) | `currentOperationId`, `pendingNextRun` | +| `lane.lastResult` | lane name | `LaneLastResult` (§3.13) | terminal outcome of the lane's most recent operation | +| `op.meta` | operation id | `Operation` (§3.1) | acceptance data; written once, never overwritten | +| `op.state` | operation id | `OperationState` (§3.2) | total operation state — **the program counter** | +| `op.tool_args` | `{opId}:{stepId}:{sourceIndex}` | effective arguments | written once at tool clearance (§3.8) | +| `op.preparation` | `{opId}:{taskId}` | `DurableStructuralPreparation` | written once before the decision hook (§3.9) | +| `pending.entry` | reserved entry id | `PendingEntry` | queued content awaiting placement (§2.2) | +| `fact.name` | `""` | string | session name | +| `fact.label` | entry id | string | entry label | +| `fact.custom` | application key | `JsonValue` | application state | + +That is the complete set. Two lifetimes are visible in the key shape: + +```text +lane.* fact.* session-lived; facts are deleted only by explicit application action +op.* operation-lived; deleted by the terminal transaction (§3.13) +pending.entry lives until its content is placed or cancelled +``` + +- `op.meta` and `op.preparation` keys are written exactly once; `op.tool_args` keys are written once per key, keyed by the producing step so batches never collide. All are deleted no later than the terminal transaction; only `op.state` is overwritten during the operation. +- Operation-owned `pending.entry` registers still unconsumed at the end (remaining inbox items and abort-drained items) are deleted by the terminal transaction — a consumed item's register dies in its placement transaction; lane-owned ones (`pendingNextRun`) outlive operations and die when consumed or cancelled (§3.11). +- `lane.lastResult` is written only by terminal transactions and overwritten by the next one on its lane — one bounded register per lane, forever. Recovery never reads it; it exists so an application that accepted an operation, crashed, and reopened can still learn its outcome (§3.13). +- Deleting a fact removes its register. Storing JSON `null` in `fact.custom` is a different, legal state; there are no tombstones. +- Cancellations leave no trace: `cancelQueued` triages as pending → `cancelled`, entry exists → `already_consumed`, else → `not_found` (§3.11). A client retrying a lost cancel treats `not_found` as success. + +## 1.4 Transactions + +```ts +/** Mapped discriminated union: the namespace forces the value type. */ +type RegisterSetWrite = { + [N in RegisterNamespace]: { kind: "register"; op: "set"; namespace: N; + key: string; value: RegisterValues[N] } +}[RegisterNamespace]; + +type Write = + | { kind: "entry"; entry: Omit } + | { kind: "usage"; row: Omit } + | RegisterSetWrite + | { kind: "register"; op: "delete"; namespace: RegisterNamespace; key: string }; + +interface Transaction { writes: Write[] } + +interface CommitResult { firstSeq: number; seqs: number[]; timestamp: number } +``` + +Rules: + +1. A transaction commits **all-or-none**. There is no observable state in which some of its writes exist and others do not. +2. Writes receive **strictly increasing** `seq` values in the order given; gaps are legal, within and between transactions. `seq` is monotonic session-wide across all lanes and all write kinds. A register `set` stamps the register with its assigned `seq`. +3. Within a transaction, writes apply in order: an entry may name a parent created earlier in the same transaction; a register value may reference entry or usage ids created earlier in the same transaction. A placement transaction inserts the complete entry and deletes its `pending.entry` register together (§2.2) — there is never a moment where both exist. +4. Entry and usage ids share one session-wide id namespace. Writing either kind under any existing id is **corruption**, not an update. +5. A register `set` with the same `(namespace, key)` replaces the current value; `delete` removes the key; a later `set` recreates it. No history is retained. A `delete` naming an absent key is a no-op, so public deletions such as clearing an unset label stay legal. +6. Transactions on one session are **serialized**. There is one writer and one queue. + +Session validates the complete transaction, including JSON serialization and runtime schemas, before storage admission. A failed admitted commit **faults the harness**: all effects stop, all calls reject, and the process must be restarted. A partially applied transaction is not tolerated. + +## 1.5 Queries + +One `Storage` instance serves one session. Repository discovery and lifecycle are outside this interface (§2.8). + +```ts +interface Storage { + commit(tx: Transaction): Promise; + + getEntries(ids: string[]): Promise>; + + getRegister(namespace: N, key: string): + Promise | undefined>; + /** keyPrefix is an indexed prefix listing over (namespace, key); terminal + cleanup's op.* prefix scans use it (§3.13). */ + listRegisters(namespace: N, keyPrefix?: string): + Promise[]>; + + scanBranch(q: BranchScan): Promise; // §2.5 + scanBranchStructure(q: BranchScan): Promise; + scanEntries(q: EntryScan): Promise; // session-wide tree inventory + scanUsage(q: UsageScan): Promise; // seq-ranged ledger read (§1.6) + getStats(): Promise; // maintained projection (§1.6) + + close(): Promise; +} + +/** Placement metadata without payload fields. */ +type EntryStructure = Pick; + +interface EntryScan { + type?: EntryType; customType?: string; + fromSeq?: number; toSeq?: number; + order?: "asc" | "desc"; limit?: number; +} + +interface UsageScan { + fromSeq?: number; toSeq?: number; + order?: "asc" | "desc"; limit?: number; +} +``` + +There is deliberately no cross-namespace register scan and no durable write log. Restore, facts, forks, and execution follow exact ids and keys; entry inventory uses `scanEntries`; ledger reads use `scanUsage`; totals use the stats projection (§1.6); test-order assertions wrap `commit()` with the instrumented-storage decorator (Part 9); production auditing belongs to telemetry (§5.8). + +Recovery and execution reads must be index-driven and bounded. They may not infer state from an absent value, and there is no register history to fold. Exact dereference is allowed: one current state may name a bounded set of entries and registers, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their `limit`/pagination behavior is explicit at the `SessionTree` layer. + +`close()` is idempotent. It seals admission, rejects later reads/commits on that instance, drains commits admitted before the seal, then releases resources and the writer claim. Durable data is reopened through the repository. + +## 1.6 Usage ledger + +Every settled provider attempt writes one `UsageRow` — successful, failed, retried, and synthetic attempts alike, including attempts whose operation later aborts. Settlement transactions write the response entry and its usage row together (§3.7); synthetic settlements write zero usage under the reserved usage id. Rows are append-only: terminal cleanup deletes an operation's registers but never its ledger rows, so billing survives everything that can happen to orchestration state. + +```jsonc +{ "id": "u_7", "seq": 815, "entryId": "e_51", "adjustment": false, + "usage": { "input": 12000, "output": 431, "cost": { ... } } } +``` + +- `entryId` names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none. +- `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix B). +- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, hook-supplied compaction/navigation usage rows (§3.9, §3.10), and import aggregates mint their ids at commit; nothing reserves them. +- `getStats()` is a maintained projection over the ledger and the message-entry count — `messageCount` counts `message` entries only, not compactions, summaries, or custom entries. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). Individual rows reach the application through the `usage` event at commit time (§5.5), and `scanUsage` (§1.5) reads them back by seq range — a consumer that persists the greatest event `seq` it applied catches up after downtime with `scanUsage({ fromSeq })`. Recovery never reads the ledger. + +## 1.7 Backends + +Three encodings of one model ship now — Memory, JSONL, SQLite — and all three pass the same conformance suite (Part 9). Each backend records the session's `storageVersion` (Part 7): a JSONL header field, a SQLite catalog column. Memory sessions are always current. A possible fourth backend — partitioned Postgres — is sketched informatively in Part 6; nothing here depends on it. + +### Memory + +```ts +entries: Map +registers: Map // key: `${namespace}\u0000${key}` +usage: Map +children: Map // parentId → entry ids, for tree walks +``` + +One queue serializes commits. A commit validates and applies writes to temporary transactional state, then publishes the maps together. A register delete is a map delete. Reads are map lookups; `scanBranch` walks `parentId` and filters in RAM. There is no log: Memory holds exactly the live state and nothing else. + +### JSONL + +The file is not the state; it is the **replay recipe** for the Memory maps above. One physical line per `commit()`. Storage assigns sequence/timestamp fields first, then encodes one committed write as a JSON object line or several as one **array line**. + +```jsonl +{"v":4,"kind":"header","id":"s_1","storageVersion":1,"createdAt":1700000000000,"cwd":"..."} +[{"kind":"entry","seq":101,"timestamp":1700000000000,"id":"e_50","parentId":"e_41","type":"message","message":{"role":"user","content":[...]}}, + {"kind":"register","op":"set","seq":102,"namespace":"op.meta","key":"op_9","value":{...}}, + {"kind":"register","op":"set","seq":103,"namespace":"op.state","key":"op_9","value":{...}}, + {"kind":"register","op":"set","seq":104,"namespace":"lane.leaf","key":"main","value":"e_50"}, + {"kind":"register","op":"set","seq":105,"namespace":"lane.state","key":"main","value":{...}}] +{"kind":"usage","seq":110,"id":"u_7","entryId":"e_51","adjustment":false,"usage":{...}} +{"kind":"register","op":"delete","seq":131,"namespace":"op.state","key":"op_9"} +``` + +- This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix B). +- Open replays lines in order into the Memory maps: entries and usage rows accumulate; a later register `set` overwrites the key, `delete` removes it. That is *decoding*, not recovery logic. Open verifies persisted sequence monotonicity — strictly increasing, gaps legal (§1.4) — and timestamps, and never regenerates committed timestamps. All queries then run in RAM. +- **A torn final line is discarded whole**, including every element of an array, and is truncated before new writes are admitted. This is what makes "no crash prefix inside a transaction" true here. +- A malformed *interior* line, or a complete-but-invalid transaction, is corruption. The one exception: superseded old-shape register lines from before a schema migration decode leniently as keyed raw JSON during replay (Part 7); compaction retires them. +- Durability is process-crash level: a resolved `commit()` survives process death. No fsync promise. +- Optional: retain `(offset, length)` per entry and load payloads lazily, keeping only structure and registers resident. Do this only if profiling demands it. + +**Snapshot compaction.** In SQLite a register `set` is an in-place upsert — a 30-turn run leaves one `op.state` row and then zero. In JSONL every `set` appends, so the same run appends ~10 full `op.state` lines, all dead the moment the terminal `delete` line lands: the file grows with *write history* even though the logical state does not. The fix is rewriting the file as `header + current entries + current registers + usage rows`, via temp file + atomic rename; surviving lines keep their original `seq` values, and the gaps the dropped lines leave are legal (§1.4), so compaction needs no renumbering machinery. For a four-entry run: + +```text +before compaction: ~10 transaction lines, ~27 writes — op.state revisions, + tool args, pending payloads, all dead since the terminal line +after compaction: header + 4 entry lines + 2 usage lines + 4 lane register lines +``` + +When to compact: on open when the dead-bytes ratio crosses a threshold; optionally after terminal transactions; always after a schema migration (Part 7). Between compactions, normal operation is append-only and O(1) per commit. One consequence worth stating: deleted pending payloads and superseded state revisions **linger as bytes** until compaction — logical deletion is immediate, physical deletion is deferred. A deployment that needs prompt physical removal of sensitive cancelled content compacts eagerly at terminal boundaries. + +### SQLite + +**One database file per session.** The file is the session, exactly as a JSONL +file is. Corruption is confined to one session, deletion is unlinking a file, and +SQLite's one-writer-per-file rule coincides with the design's +one-writer-per-session rule by construction. + +```sql +entries(id TEXT PRIMARY KEY, parent_id TEXT, seq INTEGER, type TEXT, + custom_type TEXT, timestamp INTEGER, payload TEXT) WITHOUT ROWID; +CREATE INDEX ix_entry_parent ON entries(parent_id); +CREATE INDEX ix_entry_seq ON entries(seq, type); + +registers(namespace TEXT, key TEXT, seq INTEGER, value TEXT, + PRIMARY KEY (namespace, key)); + +usage_ledger(id TEXT PRIMARY KEY, seq INTEGER, entry_id TEXT, adjustment INTEGER, + usage TEXT, details TEXT) WITHOUT ROWID; +CREATE INDEX ix_usage_seq ON usage_ledger(seq); + +-- Private branch index (§2.6). Not registers; no equivalent in the other backends. +branch_entries(branch_id TEXT, entry_id TEXT, entry_seq INTEGER, entry_type TEXT, + PRIMARY KEY (branch_id, entry_id)) WITHOUT ROWID; +-- Ordered scans. entry_seq must follow branch_id directly or ORDER BY needs a +-- temp b-tree; entry_id and entry_type trail so the index covers id-only reads. +CREATE INDEX ix_be_seq ON branch_entries(branch_id, entry_seq, entry_id, entry_type); +-- Type-filtered scans. +CREATE INDEX ix_be_type ON branch_entries(branch_id, entry_type, entry_seq, entry_id); +CREATE INDEX ix_be_entry ON branch_entries(entry_id); +branch_meta(branch_id TEXT PRIMARY KEY, tip_entry_id TEXT, tip_seq INTEGER, + base_branch_id TEXT, base_seq INTEGER); +CREATE UNIQUE INDEX ix_bm_tip ON branch_meta(tip_entry_id); + +-- One row each: the file is the session. +session(created_at, parent_session_id, storage_version, metadata, + message_count, usage_payload, next_seq); +writer_lease(owner_id TEXT, fence INTEGER, expires_at_ms INTEGER); +``` + +One `commit()` is one SQL transaction: insert entries, insert ledger rows, upsert or delete registers, maintain the branch index, bump `session_stats`. Never an UPDATE or DELETE on an entry or ledger row; mutability is confined to registers, the branch index (`branch_meta` tips and bases), stats, sequences, the session catalog row, and leases. + +**Every transaction must open with `BEGIN IMMEDIATE`.** A deferred `BEGIN` that +reads before it writes takes a read snapshot and must later upgrade to the write +lock; if another writer committed in between, SQLite fails that upgrade — and +`busy_timeout` does **not** rescue it, because no amount of waiting can refresh a +stale snapshot. The only recovery is rollback and full retry. + +Every commit has this shape, not just a few. Allocating the sequence range reads +the session row's `next_seq` and then writes it, so a read precedes a write in every +transaction the system performs. Branch creation (§2.6) adds a second instance, +reading the newest compaction before inserting. `BEGIN IMMEDIATE` takes the write +lock up front and avoids an unrecoverable stale-snapshot upgrade, so there is no case +where a deferred `BEGIN` is the right choice here. + +**`writer_lease` enforces the single-writer rule.** WAL happily lets two +processes alternate writes to one file, which is exactly the interleaving the +design forbids — so per-session files do not remove the need for the lease. Expiring fenced ownership: +`open()` acquires the claim, storage renews it on appends and while idle, and close +stops renewal after the queue drains and deletes only its matching `(owner_id, +fence)` pair — so a stale owner cannot release the replacement that succeeded it. +This is what makes "one process owns one session" an enforced property rather than +a convention the serving layer is trusted to uphold. Memory and JSONL have no +equivalent and rely on process ownership; a JSONL session opened twice is corrupt +and undetected. + +Atomicity itself needs no special handling. A multi-write transaction is all-or-none +by the file format: WAL frames become visible only when the commit record lands, so a +concurrent reader observes either none of a transaction's writes or all of them. + +Each physical segment of `scanBranch` uses one JOIN; §2.6 combines segment ranges: + +```sql +SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload +FROM branch_entries b +CROSS JOIN entries e ON e.id = b.entry_id +WHERE b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ? +ORDER BY b.entry_seq; +``` + +`CROSS JOIN` is load-bearing: it forces `branch_entries` to be the outer loop. Left +to itself the planner may drive from `entries`, scan the table, and sort through a +temporary b-tree. Assert the plan in a test: + +``` +SEARCH b USING COVERING INDEX ix_be_seq (branch_id=? AND entry_seq>?) +SEARCH e USING PRIMARY KEY (id=?) +``` + +Any plan containing `USE TEMP B-TREE FOR ORDER BY` or a scan of `entries` is a +regression. + +`scanBranchStructure` is the same query without the payload column. `getEntries` is a primary-key lookup keyed by `e.id IN (...)`. + +Because the file is the session, the precise rewrite (§2.9) and forks are file operations: build a fresh database (`VACUUM INTO` or row copy over one read snapshot) and, for the rewrite, atomically swap it over the old path — the same shape JSONL uses. + +## 1.8 Why write-once plus registers + +- **Recovery is a read.** Five register point-lookups per lane, then exact-id dereference (§4.4). No reducer exists to have a bug. +- **Crash states are enumerable.** Between transactions, never inside one. +- **Cleanup is deletion, not collection.** A 30-turn run overwrites one `op.state` register ~30 times and then deletes it. What remains is exactly the conversation, the ledger, and a handful of lane and fact registers — no dead state values, no history rows, nothing to garbage-collect. (JSONL defers *physical* reclamation to snapshot compaction; the logical state is identical.) +- **No repair-by-rewrite.** Recovery appends entries and overwrites only the registers it owns, with the same transitions normal execution would commit; interrupt it and rerun it and you get the same result. +- **Concurrency is trivial.** Readers never see partial state; there is nothing to lock. +- **The one deliberate double-write.** Queued content is serialized twice: into its `pending.entry` register at enqueue and into its entry at placement. Only queued items pay it — assistant and tool settlements, the hot path, write their entries once. In exchange every queue item is one id, cancellation deletes content outright, and no payload ever exists without an owner. + +--- + +# Part 2 — The conversation tree + +## 2.1 Entries + +An **entry** is the complete stored row (§1.1): placement fields and payload together. What `getEntries` and the scans return is exactly what was committed — there is no materialization step and no join. + +```ts +interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; + terminate?: true } +interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; + retainedTail: AgentMessage[]; tokensBefore: number; + details?: JsonValue; usage?: Usage; fromHook: boolean } +/** fromId is the summarized branch's pre-navigation leaf: the producing + operation's sourceLeafId (§3.10). */ +interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; + summary: string; details?: JsonValue; + usage?: Usage; fromHook: boolean } +interface CustomEntry extends EntryBase { type: "custom"; customType: string; data?: JsonValue } + +type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; +``` + +Rules: + +- `type` and `customType` are structural fields: branch queries filter on them and the branch index denormalizes them (§2.6). `customType` is set exactly on custom entries; payload fields never drive structure. +- Assistant entries always contain a `SettledAssistantMessage`. Reject `pending` before writing. +- Tool-result entries carry `terminate?: true`. It is orchestration state that `ToolResultMessage` has no field for. +- Every compaction and branch summary carries `fromHook`: `true` for hook output, `false` for generated. +- Every compaction stores a complete `retainedTail` (`[]` when empty). **Context never reads past a compaction.** This is what makes a compaction a self-contained checkpoint rather than a pointer into history. +- A custom entry may carry no `data`. An entry either decodes against its type's runtime schema or is corruption. +- Payloads are inline, so two entries never share stored content; there is no deduplication layer. + +## 2.2 Placement + +The tree's central rule: + +> An **entry** is created, complete, when placement happens. Content that is durable *before* placement is current mutable state and waits in a `pending.entry` register; the placement transaction writes the entry and deletes the register. Neither is ever modified after that. + +Three cases, all mechanical: + +**Born placed** — assistant responses, tool results, direct appends to an idle lane. Content and placement arrive together; one transaction: + +``` +TX[ insert e_a4 = { parent: e_q1, type: "message", message: }, + upsert lane.leaf/main = "e_a4" ] +``` + +**Content first, placement later** — queued input (`steer`, `followUp`, `nextRun`) and deferred tree writes. The entry id is minted at enqueue and doubles as the register key; queue state references content by that one id. Two transactions, possibly far apart: + +``` +t0 TX[ upsert pending.entry/e_q1 = { type: "message", payload: <200KB message> }, + S(next){ ...inbox.steer += "e_q1" } ] + +t1 TX[ insert e_q1 = { parent: e_a3, type: "message", message: }, + delete pending.entry/e_q1, + upsert lane.leaf/main = "e_q1", + S(next){ ...inbox.steer -= "e_q1" } ] +``` + +The register dies in the transaction that places the entry. Crash before `t1`: the item is still queued. Crash after: it is placed and the register is gone. **There is no third state** — until placement or cancellation, exactly one of register and entry exists at every commit boundary, never both and never neither. Cancellation is the other exit: `cancelQueued` deletes the register, and the content is simply gone, never having touched the tree (§3.11). + +**Id reserved before content exists** — assistant responses and tool results. The reserved id is a plain minted string inside `op.state`; no register and no row exist until settlement inserts the complete entry. Reserving costs nothing. + +These are the **two reservation regimes**: settlement-family ids (responses, tool results, usage rows) are strings in operation state; queued-content ids are `pending.entry` registers. "A reserved id is just a string" is true only of the first family. + +Consequences to rely on: + +- A pending item is **invisible to tree queries** (no entry) but **visible in snapshots**: the owning state lists its id, and the payload is dereferenced from its register. +- "Has this been placed yet?" is answered by the owning queue list and the register's existence — never by the absence of an entry. +- The double write is the model's one deliberate redundancy (§1.8). SQLite and Postgres can implement placement as `INSERT … SELECT` from the register row inside the placement transaction; in JSONL both copies persist as bytes until snapshot compaction (§1.7). Only queued items pay it; settlement never does. + +## 2.3 Lanes + +A configured lane is three registers — plus `lane.lastResult` once its first operation has ended (§3.13). Fresh or normalized-v3 `main` may temporarily lack `lane.config` until first harness attachment: + +``` +lane.leaf/{name} = entry id or null +lane.config/{name} = LaneConfiguration // absent only for unconfigured main +lane.state/{name} = LaneState +``` + +```ts +interface LaneConfiguration { + model: { provider: string; modelId: string }; + thinkingLevel: ThinkingLevel; + activeToolNames: string[]; +} +``` + +- A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry). +- `LaneConfiguration` is **total**. A setter overwrites the whole register; it is never a patch and never a tree entry. +- Creating a lane copies no tree content, no history, and no configuration from its anchor: + +``` +TX[ upsert lane.config/{name} = , + upsert lane.leaf/{name} = anchorEntryId, + upsert lane.state/{name} = { currentOperationId: null, pendingNextRun: [] } ] +``` + +- Lanes are never deleted or renamed. Names are permanent application keys. +- `main` exists in every session. +- Two lanes at the same leaf simply diverge on their next append. + +## 2.4 Facts + +Session-scoped, latest-wins, not part of the tree. + +``` +fact.name/"" = string +fact.label/{entryId} = string +fact.custom/{key} = JsonValue +``` + +Setting a fact to `undefined` deletes its register — real deletion, not a tombstone; deleting an unset fact is a no-op (§1.4). JSON `null` is a legitimate custom value, stored directly, and is distinguishable from deletion because the register itself exists or does not. The built-in and custom namespaces never overlap. Fact writes commit immediately and never move a leaf. + +## 2.5 Branch queries and context + +```ts +interface BranchScan { + start?: string; // required at the Storage layer; the Session + // tree view defaults it to the view's lane leaf + stopAtType?: EntryType; // scan ends after the first match, inclusive + stopAtId?: string; + type?: EntryType; + customType?: string; + order?: "newestFirst" | "oldestFirst"; // default newestFirst + limit?: number; + cursor?: EntryCursor; +} +type EntryCursor = { seq: number }; +``` + +Semantics: take the path from `start` toward the root, order it (default `newestFirst`), stop **inclusively** at the first `stopAt` match, filter by `type`/`customType`, apply the exclusive cursor, then apply `limit`. For `newestFirst`, a cursor retains `seq < cursor.seq`; for `oldestFirst`, it retains `seq > cursor.seq`. A `stopAt` entry is returned only if it also passes the filter. + +**Context projection** — how a provider request is built: + +1. `scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" })`. +2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its `summary`, then its `retainedTail`, then every entry after it. **Nothing earlier is read.** +3. Drop assistant responses whose stop reason is `error`, `aborted`, or `deferred`. Retain genuine output-limit `length`. +4. Run custom entries through `entryProjectors`. An unprojected custom entry never enters context. +5. Run `transform_context`, then `toProviderMessages`. + +An overflow response needs no dedicated omission rule: it is committed with stop reason `error` (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream `transformMessages` that filters the same way. + +**Append-only context invariant.** Across the requests of one lane, provider context must only grow at the tail. An insertion before the previous request's tail invalidates the provider's KV cache and multiplies cost. This is *why* mid-run writes defer to checkpoints, where they append at the tail. Compaction is the one deliberate cache invalidation, and it trades that for a smaller context. + +## 2.6 The branch index + +Memory and JSONL walk parent pointers in RAM. SQLite maintains a private segmented branch cache so a diverging append does not copy an unbounded root prefix. + +`branch_entries` stores the entries physically present in one segment. `branch_meta` stores its tip and optional `{ baseBranchId, baseSeq }`. A segment logically contains its own rows above `baseSeq` plus the referenced base prefix through `baseSeq`. + +Append: + +1. If a branch tip equals the lane leaf, append one row and move that tip. +2. Otherwise resolve a branch that actually covers the leaf, find the newest compaction at or below the leaf through the complete segment chain, copy only rows after that compaction through the leaf, and set the older prefix as the new segment's base. +3. Append the new entry and make it the new segment tip. + +Read newest segment first. If the requested range crosses `baseSeq`, continue through the base chain with the upper bound capped at that boundary. Merge segment results into the requested order before filtering/limiting. + +Two correctness rules are mandatory: + +- The base branch must itself cover the leaf within its logical range; merely containing the leaf in an ancestor is insufficient. +- The newest compaction search must traverse the base chain; checking only the newest physical segment can miss it. + +The cache must preserve: + +- following a segment chain yields the exact root path with no gaps or duplicates; +- all chains containing an entry agree below it; +- runtime reads never fall back to a table scan or parent walk; +- stale branches remain valid cache history; +- only an explicit repair operation rebuilds the cache from entries. + +Tests assert these invariants and the required query plans. No wall-clock threshold is normative. + +## 2.7 Forks + +A fork is a repository operation over one coherent source-session snapshot. It copies selected entries, latest facts, lane leaves, and total configuration; it never copies `op.*`, `pending.entry`, or `lane.lastResult` registers or ledger rows — destination lanes start with a fresh empty `LaneState`. + +```ts +type ForkOptions = + | { scope?: "branch"; entryId?: string; position?: "before" | "at" } + | { scope: "tree" }; +``` + +- Memory and JSONL obtain the snapshot as one job on the source storage queue. SQLite uses one read transaction. +- Branch scope copies one path and creates only destination `main`. Tree scope copies the whole tree and every lane leaf/configuration. +- The destination is idle and its token/cost ledger starts at zero. Entry-local display usage remains on copied entries. +- Facts follow the selected scope: name/custom facts always copy; labels copy only when their target copies unless tree scope copies all targets. +- Any message may be the fork point. Request construction heals orphaned tool calls. +- Copied entries keep their ids. +- The destination metadata records `parentSessionId`. + +A source with only fresh/unconfigured `main`—new format 4 or read-only normalized v3—may have no configuration. Either fork scope then creates one unconfigured destination `main`, which first harness attachment seeds normally. Every configured format-4 lane copied by a fork keeps its current total configuration. + +## 2.8 Session and repository boundary + +`Storage` is deliberately one-session only. `Session` supplies typed validation, lane-bound views, and typed entry/register decoding. `SessionRepo` owns discovery and storage-instance lifecycle: + +```ts +interface SessionMetadata { + id: string; + createdAt: number; + /** Current storage schema version (Part 7). */ + storageVersion: number; // starts at 1 for new format-4 sessions + cwd?: string; // working directory, when the application records one + parentSessionId?: string; + /** Only when a v3 parent path cannot be resolved to an available header id. */ + legacyParentSessionPath?: string; +} + +interface SessionCodecOptions { + /** Built-in provider-message roles are registered by default. */ + customMessageSchemas?: Record; // keyed by custom `role` +} + +interface SessionRepo { + create(options: C): Promise>; + open(metadata: M): Promise>; + list(options?: L): Promise; + delete(metadata: M): Promise; + fork(source: M, options: ForkOptions & C): Promise>; +} + +interface Session extends SessionTree { + readonly metadata: M; + /** Mints UUIDv7 ids; a supplied timestamp mints a follower id (§1.2). */ + readonly idGenerator: { next(timestampMs?: number): string }; + view(lane: string): SessionTree; + + /** Package-internal harness storage surface; validates before delegating to Storage. */ + commit(tx: Transaction): Promise; + getEntries(ids: string[]): Promise>; + getRegister(namespace: N, key: string): + Promise | undefined>; + listRegisters(namespace: N, keyPrefix?: string): + Promise[]>; + + close(): Promise; +} +``` + +Repository constructors accept `SessionCodecOptions`. Every declaration-merged custom `AgentMessage` must have a string `role` and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. + +`open()` compares the stored `storageVersion` with the binary's: equal proceeds; older runs chained migrations under the writer lease before returning (Part 7); newer refuses to open. Old coding-agent v3 JSONL sessions open through the same repository and normalize on load (Appendix B — "v3" there names the legacy JSONL session format, not this document). + +Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read snapshot of the session's file. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. + +How a repository organizes its sessions is its own choice, constrained only by the storage backend: JSONL and SQLite storage are one file per session, so their repositories are file-based; a Postgres storage could hold every session in one database. + +### Search + +Search is a **standalone service over the repository**, with its own store. The dependency points one way: the service consumes `repo.list()` and read-only session opens; the repository knows nothing about search and exposes no search methods, and no conformance test covers any of this. An application that wants search constructs the service and queries it directly: + +```ts +const search = createSqliteSearchService({ repo, dbPath }); // reference impl +await search.sync(); // catch up cursors +events.on("entry_added", (e) => search.notify(e.sessionId)); // optional freshness + +const hits = await search.searchSessions({ text: "auth migration", limit: 10 }); +``` + +```ts +interface SessionSearchService { + /** Sessions ranked by best match. Required. */ + searchSessions(query: SearchQuery): Promise; + /** Entries ranked by match. Optional capability. */ + searchEntries?(query: SearchQuery): Promise; + + sync(): Promise; // enumerate sessions, catch up all cursors + notify(sessionId: string): void; // freshness hint; debounced single-session pull + remove(sessionId: string): Promise; + close(): Promise; +} + +interface SearchQuery { text: string; limit?: number } // limit counts the method's unit + +interface SessionSearchHit { + sessionId: string; + score?: number; + top?: { entryId: string; snippet?: string; timestamp: number }; // best match, for display +} + +interface EntrySearchHit { + sessionId: string; entryId: string; timestamp: number; + snippet?: string; score?: number; +} +``` + +The application owns the lifecycle: `sync()` at startup or on a schedule, `notify()` wired to its event stream when it wants freshness, `remove()` alongside `repo.delete()` (or left to the next `sync()`, which reconciles against `repo.list()`). Hits carry `sessionId`; callers join metadata through the repository they already hold. + +**Indexing is pull-based; events are only hints.** The service keeps a durable cursor per session — the highest entry `seq` it has indexed. `sync()` enumerates sessions via the repository (old, new, and files that arrived by copy alike), reads `scanEntries({ fromSeq: cursor + 1 })` on each, indexes message-entry text idempotently per `(sessionId, entryId)`, and advances the cursor. A crash mid-batch re-indexes a few rows into the same state; a service deployed against years of existing sessions starts empty and catches up with the same loop. `notify()` never carries content — it is a poke that triggers a debounced pull of one session; a lost poke is caught by the next sweep. The index is a rebuildable projection with zero authority: indexing failures never affect the harness or commits. + +Two mechanical notes. Reading a session another process is writing is legal — the writer lease gates writers, and WAL gives cross-process snapshot reads — but a sweep may skip lease-held sessions as an optimization, since `notify()` covers the hot ones. The precise rewrite (§2.9) swaps a session's store and may renumber seqs, so cursors key on `(sessionId, storeGeneration)`; the rewrite bumps a generation counter in metadata and a mismatch triggers a full re-index of that session. + +The reference implementation is one standalone SQLite database — an FTS5 table over `(session_id, entry_id, text)` plus the cursor table — and works unchanged over JSONL session files. Several processes may share it under the usual discipline (WAL, `busy_timeout`, `BEGIN IMMEDIATE`, idempotent rows, monotonic cursor updates); writers serialize. + +**Open question — metadata filtering.** Coding-agent's resume flow filters sessions by `cwd`; other repositories have no cwd concept at all. Repositories already model implementation-specific listing through their `L` options generic (`list(options?: L)`), but `SearchQuery` is deliberately generic — how does a repo-specific filter reach the index? Candidates, to be settled by the people who will fight over it: + +```ts +// (a) typed filter passthrough — service becomes generic over a filter type +await search.searchSessions({ text: "auth", filter: { cwd: "/repo" } }); + +// (b) pre-restrict via the repo's own listing; pass the candidate id set +const local = await repo.list({ cwd: "/repo" }); +await search.searchSessions({ text: "auth", within: local.map((m) => m.id) }); + +// (c) post-filter in the app — breaks ranking: limit applies before the filter +const all = await search.searchSessions({ text: "auth", limit: 10 }); +const hits = all.filter((h) => byId.get(h.sessionId)?.cwd === "/repo"); + +// (d) index chosen metadata fields at sync time; filter natively in the index +createSqliteSearchService({ repo, dbPath, metadataFields: ["cwd"] }); +await search.searchSessions({ text: "auth", where: { cwd: "/repo" } }); +``` + +(a) keeps one round trip but makes the service generic over each repo's filter vocabulary; (b) composes with any repo unchanged but ships a possibly huge id set into the query; (c) is unsound as shown — filtering after `limit` drops results; (d) is what the index does best but couples the service to the metadata fields chosen at sync time and needs re-`sync` when they change. + +## 2.9 The precise rewrite + +Entries and usage rows are never deleted (§1.2). The sole sanctioned exception is the **precise rewrite**: an administrative repository operation that copies the retained set — entries, usage rows, facts, lane registers — into a fresh session store over a coherent snapshot, exactly as a fork does (§2.8), then atomically swaps it for the old store. Its keep-predicate can express what no runtime mechanism may: compliance-grade erasure (including content copied forward into `retainedTail`s and summaries), pruning abandoned branches, and re-minting legacy-format ids (Appendix B). It is tooling above the harness — no harness surface exposes it, and no core rule depends on it. + +# Part 3 — The operation state machine + +## 3.1 Operations + +```ts +interface Operation { + operationId: string; + lane: string; + sourceLeafId: string | null; + startedAt: number; + intent: + | { kind: "run"; promptEntryIds: string[]; + systemPromptOverride?: string; resumeData?: Record } + | { kind: "compaction"; customInstructions?: string } + | { kind: "navigation"; targetId: string | null; summarize: boolean; + label?: string; customInstructions?: string }; +} +``` + +Acceptance data lives in the `op.meta/{operationId}` register: written once at acceptance, never overwritten, and deleted by the terminal transaction (§3.13). `sourceLeafId` is the lane's leaf *before* the operation; entries the operation itself appends come after it. `promptEntryIds` name the caller's normalized prompt entries, born placed in the acceptance transaction (§3.6). + +## 3.2 Operation state — the program counter + +`op.state/{operationId}` holds one total `OperationState` directly. Every transition overwrites the whole register; the terminal transaction deletes it (§3.13). There is no finished member of the union — an ended operation has no state at all, and its outcome lives in `lane.lastResult`. + +```ts +type OperationState = RunState | CompactionState | NavigationState; + +type Control = + | { status: "running" } + | { status: "cancel_requested"; requestedAt: number; + /** Drained queue ids. Their pending.entry registers survive the drain + and are deleted only by the terminal transaction (§3.11, §3.13). */ + drainedSteer: string[]; drainedFollowUp: string[] }; + +interface RunState { + kind: "run"; + control: Control; + /** Captured atomically at acceptance; setters affect later operations. */ + settings: { + compaction: CompactionSettings; + steeringMode: QueueMode; + followUpMode: QueueMode; + toolExecution: "sequential" | "parallel"; + }; + phase: RunPhase; + inbox: Inbox; + /** Newest durable assistant generation/fetch response in this operation. */ + latestAssistantEntryId: string | null; +} + +interface CheckpointPhase { + kind: "checkpoint"; + continuation: Continuation; + /** Durable correlation source for the next generation step. */ + triggerEntryId: string; + /** Threshold compaction is attempted at most once per trigger boundary. */ + thresholdCheckedTriggerEntryId?: string; + /** Generate before draining another queued input after one-at-a-time drain. */ + skipInboxOnce?: boolean; +} + +type RunPhase = + | CheckpointPhase + | { kind: "assistant"; generation: Generation } + | { kind: "tools"; batch: ToolBatch } + | { kind: "compaction"; reason: "threshold" | "overflow"; + structural: StructuralDecision; resumeAfter: CheckpointPhase } + | { kind: "deferred"; deferred: Deferred } + | { kind: "failure_drain"; error: OperationError; provenance: + | { kind: "response"; entryId: string } + | { kind: "structural"; taskId: string } }; + +type Continuation = + | { kind: "need_assistant"; overflowRecoveryUsed: boolean } + | { kind: "may_finish"; includeFinalAssistant: boolean }; + +interface Inbox { + /** Reserved entry ids. Payloads — and, for writes, the entry type and + customType — live in each id's pending.entry register (§1.3, §2.2). */ + steer: string[]; + followUp: string[]; + writes: string[]; +} + +interface OperationError { code: string; message: string; details?: JsonValue } +``` + +A queue item is one entry id; everything else about it — payload, write type, `customType` — is dereferenced from its `pending.entry` register. + +`latestAssistantEntryId` updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active. + +Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with `need_assistant(false)` and the appended entry as `triggerEntryId`. A `may_finish` checkpoint sets `triggerEntryId` to the entry that caused the boundary: the settled response for a `stop`/genuine-`length` settlement (§3.7), the newest result entry for an all-terminating tool batch (§3.8) — so threshold dedup (§3.12) and restore validation (§3.3) always name an existing entry. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to `resumeAfter` with `thresholdCheckedTriggerEntryId = triggerEntryId`; decline, empty preparation, success, and crash therefore cannot recheck the same boundary. + +### Generation + +```ts +interface NormalizedRetryPolicy { maxAttempts: number; baseDelayMs: number } + +interface GenerationContext { + stepId: string; + triggerEntryId: string; + /** Inline snapshot of the lane configuration at step start. */ + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + /** Copied from the producing checkpoint's need_assistant continuation so a + settlement classified after crash-restore still knows whether overflow + recovery was already spent (§3.7, §3.9). */ + overflowRecoveryUsed: boolean; +} + +type Generation = + | { status: "ready"; context: GenerationContext; nextAttempt: number } + | { status: "effect_pending"; context: GenerationContext; attempt: number; + responseEntryId: string; usageId: string; + intendedOutputLimit: number; contextWindow: number } + | { status: "retry_wait"; context: GenerationContext; nextAttempt: number; + notBefore: number; errorMessage: string }; +``` + +The context snapshots configuration, stream options, and retry policy **inline**; `LaneConfiguration` is small. Recovery can therefore report exactly what is missing without resolving anything (§4.4). For each attempt, `before_request` runs from generation `ready` (an elapsed retry wait first returns to `ready`). Its curated patch is composed with the context's captured base stream options, then `intendedOutputLimit` and `contextWindow` are calculated and persisted in the `effect_pending` intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned `before_payload`/`after_response` callbacks are mounted only after intent and cannot be replaced through stream options. + +### Tool batch + +```ts +interface ToolBatch { + assistantEntryId: string; + /** Producing generation/fetch snapshot; active tool names come from here. */ + configuration: LaneConfiguration; + /** The assistant generation step id; recovered tool events use it as turnId. */ + turnId: string; + calls: ToolCall[]; +} + +type ToolCall = + | { status: "planned"; sourceIndex: number; resultEntryId: string } + | { status: "effect_pending"; sourceIndex: number; resultEntryId: string; + replay: "never" | "safe" } + | { status: "completed"; sourceIndex: number; resultEntryId: string; + terminate: boolean }; +``` + +The source call comes from `assistantEntryId` plus `sourceIndex`; large effective arguments live once in the `op.tool_args/{operationId}:{stepId}:{sourceIndex}` register — the producing generation's `stepId` disambiguates batches across turns — written at clearance (§3.8) and located by that deterministic key — the state carries no per-call argument reference. Persist them unconditionally because `prepareArguments`, not only `before_tool`, may change them. Parallel calls may be effect-pending together; result entries commit in source order. + +### Deferred + +```ts +type Deferred = + | { status: "suspended"; stepId: string; sourceEntryId: string; poll: number; + configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions } + | { status: "effect_pending"; stepId: string; sourceEntryId: string; poll: number; + responseEntryId: string; usageId: string; + configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions }; +``` + +One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A pending response must have a completely equal handle and becomes the next source. A mismatched pending handle is normalized to a durable `error` response explaining the mismatch; response, usage, `latestAssistantEntryId`, and response-provenance `failure_drain` commit atomically. + +The complete transition table — every row is one `commit()`; classification order (§3.7) applies to every poll settlement, cancellation first: + +| From | Trigger | Transaction | To | +|---|---|---|---| +| assistant `effect_pending` | settlement classifies `deferred` with a valid handle | §3.7's deferred row | suspended, `poll: 0`, `sourceEntryId: R` | +| suspended, poll *k* | `resume()`: the poll's `before_request` settlement commits its intent, consuming the invocation's single poll permit | mint fresh R′ and U′, then `TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]` | effect_pending, poll *k*+1 | +| effect_pending, poll *k*+1 | fetch returns **pending** with a completely equal handle | `TX[ insert response entry R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, deferred{suspended, sourceEntryId R′, poll k+1}) ]` — the pending response becomes the next source and the operation re-suspends; no second poll this invocation | suspended, poll *k*+1 | +| effect_pending | fetch returns **pending** with a mismatched handle | normalize to a durable `error` response explaining the mismatch: `TX[ insert normalized response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` | failure_drain | +| effect_pending | fetch returns **ready** with tool calls | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, tools{plan with reserved result ids}) ]` — result ids minted as followers of R′ (§1.2) | tools | +| effect_pending | fetch returns **ready** without tool calls | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | fetch settles as a provider `error` | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` — polls have no retry path | failure_drain | +| effect_pending, restored, running control | crash left the poll's outcome unknown; the next `resume()` replaces it | mint fresh R″/U″ and commit a fresh intent at the **same** poll number — an unknown-outcome poll never completed, so `poll` does not increment; the old reserved id strings are abandoned, never materialized | effect_pending, poll *k*+1 | +| effect_pending, cancelled control | reconciliation, live or restored (§4.5, §4.6) | synthetic settlement under the **existing** reserved ids: `TX[ insert synthetic aborted response R′, upsert lane.leaf = R′, insert zero usage U′, S(latestAssistantEntryId=R′, cancelled checkpoint{may_finish}) ]` | cancelled checkpoint → aborted finish | +| suspended, cancelled control | reconciliation | no fetch starts; best-effort `cancel_deferred` targets the newest source (§4.6), and the operation finishes through the aborted terminal transaction | terminal | + +### Structural work + +```ts +type StructuralDecision = { taskId: string } & ( + | { status: "deciding" } + | { status: "generating"; generation: SummaryGeneration } +); + +interface SummaryContext { + taskId: string; + resultEntryId: string; + kind: "compaction" | "branch_summary"; + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + reason?: "manual" | "threshold" | "overflow"; +} + +type SummaryGeneration = + | { status: "ready"; context: SummaryContext; nextAttempt: number } + | { status: "effect_pending"; context: SummaryContext; attempt: number; + /** Current nested request intent; absent between requests. */ + request?: { index: number; usageId: string }; + usageIds: string[] } + | { status: "retry_wait"; context: SummaryContext; nextAttempt: number; + notBefore: number; errorMessage: string }; + +interface CompactionState { + kind: "compaction"; + control: Control; + customInstructions?: string; + structural: StructuralDecision; +} + +type NavigationState = + | { kind: "navigation"; control: Control; targetId: string | null; label?: string; + summarize: false; phase: { kind: "ready_to_commit" } } + | { kind: "navigation"; control: Control; targetId: string; label?: string; + customInstructions?: string; summarize: true; + phase: { kind: "summary"; structural: StructuralDecision } }; +``` + +Structural preparation is built from the reserved source leaf and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once to the `op.preparation/{operationId}:{taskId}` register before the decision hook, in the same transaction as the `deciding` state (§3.9). State carries only `taskId`; the deterministic key locates the register, and hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. + +One structural attempt may make one or two provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageId}`, then performs that provider request through a nested Effects action, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any restored `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. A durable `generating` decision prevents its decision hook from rerunning. + +## 3.3 Lane state and current-state validity + +```ts +interface LaneState { + currentOperationId: string | null; + /** Reserved entry ids; payloads in pending.entry registers (§2.2). */ + pendingNextRun: string[]; +} +``` + +Restore validates only the current lane and operation registers and the entries/registers they directly name; there is no history to audit and none exists. Required checks: + +- `lane.state/{lane}` holds a `LaneState`; when it names operation O, `op.meta/O` holds an `Operation` for that lane, and `op.state/O` holds an `OperationState` compatible with O's intent kind; +- every entry id the current state or `op.meta` names — trigger, latest assistant, batch assistant, deferred source, completed results, prompt entries, a non-null `sourceLeafId`, a navigation intent's non-null `targetId`, the lane leaf — resolves to an existing entry of the expected type; +- reserved response/result/usage ids, if materialized, contain the intended kind and identity; an unmaterialized reserved id resolves to nothing, which is the expected pre-settlement condition, never an error; +- every id in `inbox.*`, `control.drained*`, and `pendingNextRun` has a `pending.entry` register with a valid payload; every effect-pending call has its `op.tool_args` register; every structural decision has its `op.preparation` register; +- tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result entries match their source calls; +- cancellation, navigation source/target, and structural-source combinations satisfy the state discriminants. + +Runtime schemas validate every decoded register value before publication. `lane.lastResult` is validated on its public read path — outcome/error/`runCompletion` combinations must be legal for the operation kind, and a completed run omits its final assistant only with `runCompletion: "terminated_tools"` — but it is never a recovery input (§3.13). These bounded checks reject corrupted/imported state that TypeScript transition functions could not have produced. + +## 3.4 The atomic transition rule + +> Compute the next total state in memory, then atomically commit every entry insert, usage insert, and register write that makes that state true. + +A transaction writing total `LaneState` rereads the latest register value inside the lane mutation line and changes only the fields owned by that transition. In particular, the terminal transaction clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. Conditional transitions identify the state they extend by register `seq` — the `op.state` seq, the `lane.state` seq, and, where a transition snapshots configuration, the expected `lane.config` seq (§4.1) — never by a value id; the CAS token changed, the linearization did not. Every edge below is exactly one `commit()`. + +## 3.5 The graph + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> checkpoint : prompt() accepted + + checkpoint --> assistant : continuation = need_assistant + checkpoint --> compaction : context threshold + checkpoint --> checkpoint : apply write / consume steer / consume follow-up + checkpoint --> terminal : may_finish + empty inbox + + assistant --> assistant : retryable error (retry_wait) + assistant --> tools : toolUse + assistant --> compaction : overflow (first time) + assistant --> deferred : stopReason deferred + assistant --> checkpoint : stop / genuine length + assistant --> failure_drain : terminal error / retries exhausted / 2nd overflow + + tools --> tools : per-call intent + settlement + tools --> checkpoint : batch complete + + compaction --> checkpoint : resumeAfter restored + compaction --> failure_drain : overflow declined; threshold/overflow generation failed + + deferred --> deferred : poll returns pending + deferred --> tools : ready response with calls + deferred --> checkpoint : ready response without calls + deferred --> failure_drain : provider error + + failure_drain --> checkpoint : new user-context input applied + failure_drain --> terminal : inbox drained (failed) + + checkpoint --> terminal : abort reconciled (aborted) + compaction --> terminal : abort before structural commit (aborted) + failure_drain --> terminal : abort reconciled after writes drain (aborted) + terminal --> [*] +``` + +`terminal` is not a state. It is the terminal transaction (§3.13): after it commits, the operation has no `op.state` register at all. + +Standalone operations: + +``` +compaction: deciding ──hook declines───────────→ terminal TX (declined) + ──hook supplies result────→ terminal TX (completed) + ──hook selects generation─→ generating ──→ terminal TX (completed|failed) + +navigation: ready_to_commit ───────────────────→ terminal TX (completed) + summary.deciding ──hook declines───→ terminal TX (declined; no move) + ──→ generating ───→ terminal TX (completed|failed) +``` + +A declined summarized navigation moves nothing: the leaf stays at the source, and the terminal transaction records outcome `declined`. Abort before any structural commit finishes `aborted`, likewise without a move (§4.6). + +## 3.6 Acceptance + +| From | Trigger | Transaction | +|---|---|---| +| idle lane | `prompt()` after `before_run` | `TX[ insert entries for captured nextRun items (payloads from their pending.entry registers) and the new messages (caller prompt, hook injections) in order, delete the captured pending.entry registers, upsert lane.leaf = newest entry, upsert op.meta/O, S(run{captured settings, checkpoint need_assistant(false), trigger = newest entry, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured ids removed from pendingNextRun}) ]` | +| reserved idle lane | `compact()` with non-empty preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(compaction{deciding, taskId}), L({currentOperationId: O}) ]` | +| idle lane | unsummarized `navigateTree()` after validation | `TX[ upsert op.meta/O, S(navigation{ready_to_commit}), L ]` | +| reserved idle lane | summarized `navigateTree()` with preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(navigation{summary.deciding, taskId}), L ]` | + +Captured `nextRun` items already have their payloads in `pending.entry` registers; acceptance inserts their entries from those payloads, deletes the registers, and removes the ids from `pendingNextRun` — the placement half of the one deliberate double write (§1.8). A late-captured item keeps its enqueue-minted id (§1.2). + +Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. + +Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve), and `InvalidMessage` when acceptance would append zero entries — an empty normalized prompt with no hook injections and no captured `nextRun` items leaves no newest entry to anchor the checkpoint's trigger. Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. + +**Acceptance must observe `currentOperationId === null`.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. + +## 3.7 Assistant generation + +| From | Trigger | Transaction | To | +|---|---|---|---| +| checkpoint `need_assistant` | drive | conditionally snapshot current lane config, stream options, and normalized retry policy inline into the context in `TX[ S(assistant{ready, nextAttempt:1}) ]` | ready | +| assistant `ready` | `before_request` aggregate completes | mint R and U, then `TX[ S(assistant{effect_pending, attempt=nextAttempt, responseEntryId R, usageId U, intendedOutputLimit, contextWindow}) ]` | effect_pending | +| effect_pending | settles with tool calls | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, tools{plan with reserved result ids}) ]` | tools | +| effect_pending | retryable error, attempts remain | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | +| effect_pending | first overflow, preparation non-empty | `TX[ insert response entry R **normalized to error**, upsert lane.leaf = R, insert usage U, upsert op.preparation/O:{taskId} = P, S(latestAssistantEntryId=R, compaction{reason:overflow, structural:{deciding, taskId}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | +| effect_pending | first overflow, preparation empty | `TX[ insert normalized response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| effect_pending | `stopReason: "deferred"` | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, deferred{suspended, sourceEntryId R, poll 0, configuration/options copied}) ]` | deferred | +| effect_pending | `stop` or genuine `length` | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| retry_wait | `notBefore` elapsed | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | + +**There is never a durable "response without usage" or "response and usage without a decision."** All three land together or none do. `R` and `U` are minted at intent and exist only as strings in the state until settlement inserts the complete rows (§2.2). A settlement that plans tools mints each `resultEntryId` as a follower of `R`, inheriting its 48-bit timestamp (§1.2), so the assistant and its results form one id-cohesive group by construction. + +### Classification order + +Pure, computed in memory before the settlement transaction. First match wins. + +| Condition | Result | +|---|---| +| `control.status === "cancel_requested"` | normalize stop reason to `aborted`; commit `checkpoint{may_finish, includeFinalAssistant:true}` under cancelled control, then reconcile writes/finish | +| overflow: adapter-reported, or `error` whose message matches the context-limit patterns, or `length` with output below `intendedOutputLimit` | **normalize stop reason to `error`**; compact (first time) or `failure_drain` (second) | +| `deferred` with a valid handle | deferred suspended | +| retryable `error`, attempts remain / otherwise | retry_wait / failure_drain | +| `toolUse`, or an accepted response carrying calls | tools | +| `stop` or genuine output-limit `length` | checkpoint `may_finish` | + +Two normalizations happen at commit, and both are deliberate. A cancelled response commits as `aborted`. An overflow-classified response commits as `error`. In both cases the original stop reason is overwritten and the reason is preserved in human-readable form in `errorMessage`. + +Because the committed response is `error`, §2.5 rule 3 drops it from context automatically — the compaction and the operation state carry no reference to it, and no dedicated omission rule exists. The response stays in the tree as durable history, because a provider request happened and was billed. + +**Overflow detection is a heuristic and must be labelled as one.** Three sources, in decreasing reliability: + +1. **Adapter-reported.** A provider adapter that can compute `usage.input + usage.cacheRead > contextWindow` at settlement sets `stopReason: "error"` with a message matching the context-limit patterns. This requires no new stop reason and no change to any adapter's stop-reason mapping, which matters because those mappings typically throw on unknown values. An adapter doing this should also require negligible output, so a substantive answer that merely trips a counter is not discarded. +2. **Error-message matching.** Providers usually return a context-limit failure as an HTTP error, which arrives as `error` with a message. Matching it is string matching, and it is brittle wherever it lives. +3. **`length` below `intendedOutputLimit`.** Harness-side only. An adapter must not apply this rule, because it cannot distinguish an oversized request from a response truncated mid-thinking — and those need opposite treatment, since a genuine truncation must stay in context. + +Overflow is checked before retryable error, so an oversized request compacts rather than retrying unchanged. + +**`aborted` is not a classification input.** It means the harness's own abort signal fired (§4.6), and `abort()` commits `control` before signalling — so a settled `aborted` response always has `control.status === "cancel_requested"` and is caught by the first row. An `aborted` response with `control.status === "running"` is unreachable and is corruption (Part 9). + +An overflow classification never produces a tool plan. A *genuine* `length` that carries tool calls does produce the full plan, executes nothing, and appends one `isError: true` result per call explaining that truncation may have corrupted the arguments — those results then require another assistant turn. + +## 3.8 Tools + +| From | Trigger | Transaction | To | +|---|---|---|---| +| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ upsert op.tool_args/O:{stepId}:{i} = effective args, S(call i = effect_pending, replay) ]` | dispatch | +| call *i* `effect_pending` | effect settled, `after_tool` applied | `TX[ insert result entry, upsert lane.leaf, insert tool usage row (if reported), S(call i = completed, terminate) ]` | tools or checkpoint | +| call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | `TX[ insert synthetic error result entry, upsert lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]` | tools | +| all calls completed | — | folded into the last settlement, which also deletes the batch's `op.tool_args/{O}:{stepId}:*` registers | checkpoint | + +The batch's completion transition is: + +- **every** completed call set `terminate: true` → `checkpoint{may_finish, includeFinalAssistant: false}` +- otherwise → `checkpoint{need_assistant(overflowRecoveryUsed: false)}` + +`terminate` exists so a tool can end the run without another provider turn. The motivating case is a "submit final result" tool used in place of structured output: the model calls it, the harness commits the result, and the run finishes with those tool results as its final entries — `run_end` then carries no `finalMessage`. Without this, every such run would pay for one more model turn whose only job is to stop. + +Modes: + +- **Sequential** (option, or any called tool declares `executionMode: "sequential"`): clear → intent → execute → finalize → commit, one call at a time. +- **Parallel** (default): clearance and intent commits happen in source order; dispatch does not await earlier calls; effects settle concurrently; phase 3, result-message lifecycle, and result commits are awaited and finalized in source order. + +Blocked and invalid calls skip the intent commit and the effect, but still commit a result at their source position. Their `op.tool_args` register is never written. + +Calls are tracked internally by `sourceIndex`. Hooks, events, and tool context see the provider `toolCallId` and tool name — never the index. + +## 3.9 Summary generation — compaction and navigation summaries + +Both operations generate a summary through the same `deciding → generating → result` machinery, which is why they are specified together. The axes: + +| | compaction | navigation | +|---|---|---| +| **standalone operation** | `lane.compact()` — reason `manual` | `lane.navigateTree(target)` | +| **phase inside a run** | reasons `threshold`, `overflow` | — | + +| reason | who asked | on hook decline | +|---|---|---| +| `manual` | the caller | operation finishes `declined` | +| `threshold` | context-size check at a checkpoint | back to the stored `resumeAfter` | +| `overflow` | a request that did not fit | `failure_drain` | + +"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and the transition into `deciding` commit together (`upsert op.preparation/O:{taskId}` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. + +| From | Trigger | Transaction | +|---|---|---| +| deciding | hook declines | standalone: the terminal transaction (§3.13) with outcome `declined` · threshold: `TX[ S(restore marked resumeAfter) ]` · overflow: `TX[ S(failure_drain{error, provenance:structural taskId}) ]` | +| deciding | hook supplies compaction | standalone: `TX[ insert hook usage row?, insert compaction entry, upsert lane.leaf, terminal writes (§3.13) ]`; in-run: same result-publication writes plus `S(resumeAfter)` | +| deciding | hook supplies navigation summary | use §3.10's final transaction with the hook usage/result | +| deciding | hook selects generation | conditionally snapshot current config/policy inline in `TX[ S(generating{ready}) ]` — **the decision hook will never run again** | +| generating ready / retry elapsed | drive | `TX[ S(effect_pending, attempt k) ]` | +| generating effect_pending | one nested request returns | `TX[ insert usage row under request.usageId, S(effect_pending, request cleared, usageIds += id) ]`; commit another request intent before request two | +| generating effect_pending | retryable attempt outcome | usage is already durable; `TX[ S(retry_wait) ]` | +| generating effect_pending | terminal or attempts exhausted | standalone: the terminal transaction (§3.13) with outcome `failed` · in-run: `TX[ S(failure_drain{provenance:structural taskId}) ]` | +| generating effect_pending | compaction succeeded | standalone: `TX[ insert result entry, upsert lane.leaf, terminal writes (§3.13) ]`; in-run: result-publication writes plus `S(resumeAfter)` | + +Structural provider streams are internal: they emit **no** public assistant-message lifecycle. The existing summary generator is retained, but its one/two request callback uses the nested request intent/effect/usage boundaries from §3.2 and §4.2. Intermediate content is not persisted; a crash before the final transaction makes the whole attempt unknown, and a later numbered attempt starts only under the captured retry policy. Failed-attempt usage stays in the ledger regardless — terminal cleanup deletes registers, never ledger rows (§1.6). + +### Worked example — overflow + +`e_40` is a tool result awaiting an assistant turn. The request does not fit. + +``` +… e_38 ── e_39 ── e_40 phase: assistant, effect_pending + continuation was need_assistant(false) +``` + +**1. Settlement.** Classification says overflow. Preparation is built against the would-be branch; because the known response is normalized to `error`, ordinary projection excludes it. Response and preparation then commit together: + +``` +TX[ insert e_41 = { …assistant response, stopReason: "error", + errorMessage: "context window exceeded: …" }, + upsert lane.leaf/main = "e_41", insert usage u_41, + upsert op.preparation/op_9:t_1 = , + S(compaction{ reason: overflow, + structural: { deciding, taskId: "t_1" }, + resumeAfter: { checkpoint, triggerEntryId: "e_40", + continuation: need_assistant(true) } }) ] + +… e_38 ── e_39 ── e_40 ── e_41 +``` + +**2. Compaction.** The durable preparation was built by the ordinary rules in §2.5. `e_41` is an `error` response, so rule 3 dropped it — from the summary input and from `retainedTail` alike, with no special case: + +``` +… e_40 ── e_41 ── e_42 (compaction) + retainedTail: [e_39, e_40] ← e_41 absent by rule 3 +``` + +The tail ends on `e_40`, a tool result, which is the correct shape for a request that is about to ask for an assistant turn. + +**3. Resume.** `resumeAfter` restores `need_assistant(overflowRecoveryUsed: true)`. Context is now summary + tail + anything after `e_42`, which is small: + +``` +… e_41 ── e_42 ── e_43 the answer to e_40 + ✗ (error, out of context) +``` + +`e_41` remains in the tree forever as durable history — a request was made and billed. If the retry overflows *again*, `overflowRecoveryUsed` is already `true` and the run goes to `failure_drain` rather than compacting in a loop. Consuming new user input appends to the tree and resets the flag to `false`. + +## 3.10 Navigation + +Unsummarized and summarized both finish in **one** transaction — navigation's terminal transaction (§3.13) with its result-publication writes inline: + +``` +TX[ insert hook-reported usage row (only for a hook-supplied summary), + upsert lane.leaf = target, + insert summary entry with its display usage snapshot (when summarize; + parent is the target; fromId = the operation's sourceLeafId — the + pre-navigation source leaf), + upsert lane.leaf = summary entry (when summarize), + upsert fact.label (when a label is present), + delete the operation's op.* registers, + upsert lane.lastResult = { kind: "navigation", outcome: "completed", leafId }, + L({ currentOperationId: null }) ] +``` + +Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary entry explicitly names the target as parent, and the following register write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction ends in an aborted terminal transaction with no entry appended; abort after it means the operation completed. + +## 3.11 Inbox, queues, deferred writes + +Every queued admission mints the item's entry id (§1.2) and writes its payload once into `pending.entry/{id}`; queue lists carry only the id. + +| Public input | Admitted when | Transaction | +|---|---|---| +| `nextRun(msg)` | any state, including idle | `TX[ upsert pending.entry/{id} = payload, L(pendingNextRun += id) ]` — never starts a run | +| `steer(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pending.entry/{id} = payload, S(inbox.steer += id) ]` | +| `followUp(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pending.entry/{id} = payload, S(inbox.followUp += id) ]` | +| tree write, run active | including suspended and cancelling | `TX[ upsert pending.entry/{id} = payload, S(inbox.writes += id) ]` — survives abort | +| tree write, lane idle | idle | `TX[ insert entry, upsert lane.leaf ]` | +| tree write, structural op open | — | wait for the operation to end, then re-evaluate | +| `cancelQueued(id)` | item still pending | `TX[ S or L with the id removed, delete pending.entry/{id} ]` | +| checkpoint consumes input | eligible | `TX[ insert entries from the register payloads, delete their pending.entry registers, upsert lane.leaf, S(ids removed, continuation → need_assistant(false), triggerEntryId = newest entry, skipInboxOnce = true) ]` | +| first `abort()` | run active | `TX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied) ]` — drained pending.entry registers are **not** deleted | +| finish | inbox empty, no required continuation | the terminal transaction (§3.13) | + +`cancelQueued` triage, in order: the id is still pending in a queue list → remove it and delete its `pending.entry` register in one transaction; the content is gone, never having touched the tree, and the call returns `cancelled`. An entry under that id exists → `already_consumed`. Neither → `not_found` — previously cancelled, cleared by abort, or never existed. A client retrying a lost cancel treats `not_found` as success. There are no disposition registers, and nothing here is ever a recovery input. + +The first `abort()` moves steer/follow-up ids into `control.drainedSteer`/`control.drainedFollowUp` but deletes none of their `pending.entry` registers: `AbortResult` and a post-crash `SuspendedOperation.aborting` dereference the drained payloads from those registers. They die in the terminal transaction (§3.13), never earlier. Deferred writes stay in `inbox.writes` and are applied during reconciliation. + +Because acceptance, cancellation, consumption, abort, and finish all serialize on the lane mutation line, every race has exactly two possible histories, and **no item can be both pending and applied** in durable state: at every commit boundary a queued id has its register (pending or drained), its entry (consumed), or neither (cancelled) — never both. + +## 3.12 The checkpoint procedure + +Order matters. At each queue drain point, `"all"` consumes every currently eligible item in acceptance order; `"one-at-a-time"` consumes only the oldest and leaves the rest pending. Any projecting drain sets durable `skipInboxOnce`; on that next pass the planner skips steps 1–2, starts generation, and clears the flag in the ready-state transition. Thus a crash cannot turn one-at-a-time into an all-item drain. + +1. Unless `skipInboxOnce`, atomically apply accepted deferred writes. +2. Unless `skipInboxOnce`, atomically consume eligible steering, per the steering mode. +3. Run threshold compaction only when `thresholdCheckedTriggerEntryId !== triggerEntryId`, preserving the marked checkpoint in `resumeAfter`. +4. If the continuation is `need_assistant`, start generation and clear `skipInboxOnce`. +5. Once assistant and tool continuation are exhausted, atomically consume eligible follow-up. +6. If the continuation is `may_finish` and the inbox is empty, invoke `before_run_end`. +7. Conditionally finish — the terminal transaction (§3.13). + +Consumed steer/follow-up and projecting message writes enter `need_assistant(false)`, set `triggerEntryId` to the newest appended entry, and set `skipInboxOnce`. Tool results do the same unless every result terminates. An unprojected custom write is appended and removed from the inbox but preserves the prior continuation, failure provenance, and overflow flag. Under cancelled control, every deferred write is appended and removed without changing phase/continuation or starting work; reconciliation ends in an aborted terminal transaction after writes drain. + +`before_run_end` may return a follow-up. It commits **only** if control is still running and the operation is still at the same finish boundary; otherwise the stale hook result is dropped. The follow-up is born placed — its entry and the `need_assistant` state commit together, with no pending register. + +`failure_drain` applies accepted writes, then eligible steer and follow-up input in the same order. Projecting user-context input atomically enters `checkpoint{need_assistant(false)}` and clears the failure. Unprojected custom writes do not. With no such input, it finishes failed without `before_run_end` or another provider request. + +## 3.13 Terminal transactions + +There is no finished state. An operation ends by ceasing to exist: one **terminal transaction** deletes every register the operation owns, records the outcome in `lane.lastResult`, and clears the lane's `currentOperationId`. After it commits, the operation's only durable footprint is the conversation entries and ledger rows it produced. + +The result is computed in memory, pre-commit, from the final operation state — the same value the caller's promise resolves with. What lands durably is its register form: + +```ts +type LaneLastResult = { + operationId: string; + kind: "run" | "compaction" | "navigation"; + leafId: string | null; + /** Newest settled assistant, when the outcome includes one (runs only). */ + finalAssistantEntryId?: string; +} & ( + | { outcome: "failed"; error: OperationError; runCompletion?: never } + | { outcome: "completed"; error?: never; + runCompletion?: "assistant" | "terminated_tools" } + | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never } +); +``` + +A normal run finish copies `RunState.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Only terminal transitions construct a `LaneLastResult`. + +Every terminal transaction, for every operation kind and outcome, has one shape: + +``` +TX[ , + delete op.meta/{O}, + delete op.state/{O}, + delete op.tool_args/{O}:* defensive prefix scan — listRegisters with + keyPrefix (§1.5); batch completion already + deletes these atomically (§3.8), + delete op.preparation/{O}:* prefix scan; in-run compactions leave their + preparation after resume, + delete pending.entry/{id} for every operation-owned pending id, + upsert lane.lastResult/{lane} = , + L({ currentOperationId: null }) ] +``` + +Operation-owned pending ids are the remaining `inbox.steer ∪ inbox.followUp ∪ inbox.writes` plus `control.drainedSteer ∪ control.drainedFollowUp` — registers that survived an abort drain die here (§3.11). **Never `lane.state.pendingNextRun`**: those registers are lane-owned, outlive operations, and die only when consumed or cancelled. Ledger rows are never deleted (§1.6). The `L` write rereads the latest `LaneState` on the lane mutation line and clears only `currentOperationId`, preserving concurrently accepted `pendingNextRun` (§3.4). + +For the completed run of §0.4's shape — prompt `e_50`, tool call `e_51`/`e_52`, final answer `e_53`: + +``` +TX[ delete op.meta/op_9, + delete op.state/op_9, + delete op.tool_args/op_9:s_1:0, ← usually already gone at batch completion + upsert lane.lastResult/main = { operationId: "op_9", kind: "run", + outcome: "completed", leafId: "e_53", + finalAssistantEntryId: "e_53", + runCompletion: "assistant" }, + upsert lane.state/main = { currentOperationId: null, pendingNextRun: [] } ] +``` + +After it, the session holds exactly the conversation entries, the ledger rows, and the lane's registers (`lane.leaf`, `lane.config`, `lane.state`, `lane.lastResult`). The run's ~10 `op.state` revisions, its tool-args register, and any pending payloads existed only as register overwrites and are gone — nothing to collect (§1.8). + +**The observation contract.** A terminal outcome is observable once through the live caller's promise (and the corresponding `run_end`/`compaction_end`/`navigation_end` event), which carries the full in-memory result, and thereafter through `lane.lastResult` until the next terminal transaction on the same lane overwrites it. `lane.lastResult` is written only by terminal transactions — one bounded register per lane, forever. Recovery never reads it: restore treats a lane with `currentOperationId: null` as idle regardless of the register's content. It exists so an application that accepted an operation, lost its process, and reopened can still answer "what happened to `op_9`?" — including outcomes the tree alone cannot reconstruct: a structural failure's error, `declined`, and the `aborted`-versus-`completed` ambiguity of a leaf that moved. + +The invariant this section carries (restated in Part 9): `op.*` registers and operation-owned `pending.entry` registers exist **iff** their operation is open, because the terminal transaction deletes them atomically with clearing `currentOperationId`. There is no partial-cleanup state to observe or repair. + +# Part 4 — Execution, recovery, abort, close + +## 4.1 The interpreter + +The runtime plans from total durable state plus a small process-local scheduler. Entries and stable register values named by the state are batch-loaded before planning. The driver also snapshots the current settings revision into `RuntimeSnapshot`; this performs no provider request. Providers and tools are resolved from their registries **at dispatch time** by the durable identities captured in state — a missing or replaced entry fails that dispatch in-band (synthetic error settlement), exactly like an unknown tool. When a tool batch first becomes current, the driver resolves `toolContext` once and retains it in `DriveState.toolBatches` for every sequential/parallel call in that batch. `nextAction` is then pure over those inputs. + +```ts +interface CurrentOperation { + operation: Operation; + state: OperationState; + /** Register seqs at load time; conditional commits compare these (§3.4). */ + operationStateSeq: number; + laneState: LaneState; + laneStateSeq: number; + leafId: string | null; + configuration: LaneConfiguration; + configurationSeq: number; +} + +type EffectKey = string; // deterministic from durable step/attempt or assistant/sourceIndex + +interface LiveEffect { plan: EffectPlan; promise: Promise } + +interface DriveState { + deferredPollsRemaining: 0 | 1; + running: Map; + /** One context/tool-definition snapshot per live or restored batch. */ + /** toolContext resolved once per batch; key: assistantEntryId. */ + toolBatches: Map; + /** Process-local best-effort attempts; reopen may attempt again. */ + deferredCancellations: Set; +} + +type EffectPlan = { telemetryContext: TelemetryContext } & ( + | { kind: "assistant"; key: EffectKey; + generation: Extract; + streamOptions: AgentHarnessStreamOptions } + | { kind: "summary"; key: EffectKey; + generation: Extract } + | { kind: "tool"; key: EffectKey; assistantEntryId: string; + sourceIndex: number; + /** Full op.tool_args register key: {opId}:{stepId}:{sourceIndex} (§3.8). */ + argsKey: string } + | { kind: "deferred"; key: EffectKey; + deferred: Extract; + streamOptions: AgentHarnessStreamOptions } + | { kind: "cancel_deferred"; key: EffectKey; sourceEntryId: string; + handle: DeferredHandle } + | { kind: "hook"; key: EffectKey; name: keyof HookMap; event: unknown } +); + +type SummaryAttemptOutcome = + | { kind: "success"; result: CompactResult | BranchSummaryResult } + | { kind: "retry" | "failure"; error: OperationError }; + +type EffectOutput = + | { kind: "not_started"; key: EffectKey } + | { kind: "assistant" | "deferred"; key: EffectKey; + message: SettledAssistantMessage } + | { kind: "summary"; key: EffectKey; outcome: SummaryAttemptOutcome } + | { kind: "tool_raw"; key: EffectKey; + result: AgentToolResult; isError: boolean } + | { kind: "hook"; key: EffectKey; result: unknown } + | { kind: "cancel_deferred"; key: EffectKey }; + +type SettlementOutput = Exclude | + { kind: "tool"; key: EffectKey; result: AgentToolResult; + isError: boolean; terminate: boolean }; + +interface SettlementResult { + current: CurrentOperation; + /** Immediate live dispatch prepared by a successful pre-intent hook. */ + dispatch?: EffectPlan; + /** Identity resolution failed while durable state was still safely dispatchable. */ + suspend?: OperationResult; + /** Poll intent committed; consume this resume invocation's sole permit. */ + consumeDeferredPoll?: true; +} + +interface RuntimeSnapshot { + settingsRevision: number; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; +} + +type PlannerInputs = { + /** Exact process-local plans; never reconstruct a live plan from durable ids. */ + running: ReadonlyMap; + deferredPollsRemaining: 0 | 1; + deferredCancellations: ReadonlySet; + /** Entries plus loaded op.tool_args/op.preparation/pending.entry register + values — written once per key or stable until consumed, so safe as + immutable planner inputs. Keyed by entry id or register key. */ + loaded: ReadonlyMap; + runtime: RuntimeSnapshot; + context?: AgentMessage[]; + now: number; +}; + +type OperationResult = RunOutcome | CompactionOutcome | NavigationOutcome; + +type Action = + | { kind: "transition"; next: OperationState; telemetryContext: TelemetryContext; + /** Required when this transition snapshots current mutable request state. */ + expectedConfigurationSeq?: number; + expectedSettingsRevision?: number } + | { kind: "dispatch"; intent?: OperationState; effect: EffectPlan; + consumeDeferredPoll?: true } + | { kind: "await_effect"; key: EffectKey } + | { kind: "wait"; until: number; telemetryContext: TelemetryContext } + | { kind: "suspend"; result: OperationResult } + | { kind: "finish"; result: OperationResult }; + +async function drive(current: CurrentOperation, live: DriveState): Promise { + while (true) { + const inputs = await loadPlannerInputs(current, live); // bounded entry/register reads + const action = nextAction(current.state, inputs); // pure and exhaustive + + switch (action.kind) { + case "transition": { + const committed = await commitTransitionIfCurrent( + current, action.next, action.telemetryContext, + action.expectedConfigurationSeq, action.expectedSettingsRevision); + current = committed ?? await reloadCurrent(current.operation.operationId); + break; + } + + case "dispatch": { + if (action.intent) { + const committed = await commitTransitionIfCurrent( + current, action.intent, action.effect.telemetryContext); + if (!committed) { + current = await reloadCurrent(current.operation.operationId); + break; // a lane mutation won; do not dispatch + } + current = committed; + } + if (action.consumeDeferredPoll) live.deferredPollsRemaining = 0; + if (action.effect.kind === "cancel_deferred") + live.deferredCancellations.add(action.effect.sourceEntryId); + live.running.set(action.effect.key, + { plan: action.effect, promise: fx.run(action.effect) }); + break; // permits source-ordered parallel dispatch + } + + case "await_effect": { + const liveEffect = live.running.get(action.key); + if (!liveEffect) throw new Error("planned effect is not running"); + const { plan } = liveEffect; + const output = await liveEffect.promise; + live.running.delete(action.key); + if (plan.kind === "cancel_deferred") { + current = await reloadCurrent(current.operation.operationId); // no durable write + break; + } + let settlement: SettlementOutput; + if (output.kind === "tool_raw") { + if (plan.kind !== "tool") throw new Error("tool output/plan mismatch"); + settlement = await fx.finalizeTool(plan, output); // source-ordered after_tool + } else { + settlement = output; // not_started settles synthetically without hooks + } + const settled = await commitEffectSettlement( + current, plan, settlement, plan.telemetryContext); + current = settled.current; + if (settled.suspend) return settled.suspend; + if (settled.consumeDeferredPoll) live.deferredPollsRemaining = 0; + if (settled.dispatch) + live.running.set(settled.dispatch.key, + { plan: settled.dispatch, promise: fx.run(settled.dispatch) }); + break; + } + + case "wait": + await fx.sleep( + Math.max(0, action.until - Date.now()), action.telemetryContext); + current = await reloadCurrent(current.operation.operationId); + break; + + case "finish": + current = await fx.commitTerminal(current, action.result) ?? current; + return action.result; + + case "suspend": + return action.result; + } + } +} +``` + +An intent/ordinary transition requires the `op.state` register still to carry its expected `operationStateSeq`; otherwise it returns `undefined` and the loop replans without dispatch. If a conditional commit or `reloadCurrent` instead finds the operation's registers gone — it is no longer the lane's current operation — the drive stops through external finalization (§4.9). A successful `before_request`/`before_tool` hook settlement atomically commits the effect intent (and the effective `op.tool_args` register) and returns the complete process-local dispatch plan; the drive installs that promise immediately. A crash in the remaining process-only gap is conservatively the ordinary unknown-effect case. A transition that creates a generation/summary `ready` state also supplies the `lane.config` register seq and harness-settings revision it read; the settings/lane commit requires both still match, giving setter-first or step-start-first ordering. The resulting context durably captures the inline configuration, normalized retry policy, and base stream options. Immediately before ordinary external execution, `fx.run` enters the lane mutation line once more: cancellation-first returns `not_started`, while start-first registers the live effect/controller so a later abort signals it. Dispatch then resolves the provider or tool from its registry by the captured durable identity; resolution failure settles in-band. Thus no effect starts in the gap after intent without belonging to one of the two serialized orders. Settlement reloads latest total state, verifies the same effect key remains pending, merges the output into that state, and applies current cancellation control. Thus steer/write acceptance, abort, and other parallel-tool intents cannot erase a live result or overwrite newer inbox/control state. + +Parallel tool calls dispatch phase two in source order into `DriveState.running`. The planner may dispatch later calls while earlier promises run, but it emits `await_effect` only for the first incomplete source position. That raw result then crosses source-ordered `fx.finalizeTool`/`after_tool` before settlement. A later settled raw promise remains process-local until its turn. After restart `running` is empty, so durable `effect_pending` follows recovery policy rather than being mistaken for a live effect. + +Recovery rules: + +- `not_started` under cancelled control settles assistant/fetch under reserved ids as `aborted`, settles a tool with its planned aborted result without `after_tool`, drops an uncommitted hook decision, discards structural work before finishing aborted, and drops a stale deferred-cancel action without settlement; +- ready generation/summary and cleared tools commit `effect_pending` before `dispatch`; +- restored generation/summary pending with no live key advances under captured retry policy or settles synthetically at the cap; +- restored tools replay only when persisted and current declarations are `safe`, otherwise settle interrupted; +- restored deferred pending normally suspends until an application `resume()` replaces it with one fresh poll intent; cancelled control instead settles the existing reserved response/usage ids synthetically as `aborted` before finishing; +- committing a deferred intent through its `before_request` settlement returns `consumeDeferredPoll:true`; the drive clears the invocation's sole permit before installing dispatch, so a pending response re-suspends rather than polling again; +- retry wait crosses `fx.sleep`, which is visible to manual drive and reloads cancellation afterward; +- structural decision hooks run from `deciding`; their consumer transaction either finishes the structure or records `generating`, so only a pre-commit crash reruns them. + +A fresh operation drive starts with zero deferred permits; `resume()` starts with one. Repairs and non-poll work do not consume it. + +## 4.2 The effects boundary + +Every operation-procedure commit, provider request, tool invocation, hook call, and timer crosses exactly one injected `Effects` (`fx`) method. Procedures receive `fx`, their telemetry context, and a read-only runtime view — never `Session`, `Models`, the tool registry, or the hook runner directly. Ungated lane-surface commits—acceptance, queue/configuration calls, facts, lane creation, and idle writes—use the same lane mutation line and typed `Session` transaction API directly. + +```ts +type SummaryRequestOutput = + | { kind: "response"; message: SettledAssistantMessage } + | { kind: "not_started" }; + +interface Effects { + commitTransition(current: CurrentOperation, next: OperationState, + telemetry: TelemetryContext, + expectedConfigurationSeq?: number, + expectedSettingsRevision?: number): + Promise; + commitEffectSettlement(current: CurrentOperation, plan: EffectPlan, + output: SettlementOutput, telemetry: TelemetryContext): + Promise; + /** The terminal transaction (§3.13): register deletes, lane.lastResult, + lane.state clear — plus any final entry/label writes the outcome carries + (§3.10). Conditional on op.state still being present at its expected seq; + undefined = externally finalized first (§4.9). Transition commits derive + their entry/usage writes from the state diff the same way. */ + commitTerminal(current: CurrentOperation, result: OperationResult): + Promise; + /** Runs after_tool for the raw phase-two result selected in source order. */ + finalizeTool(plan: Extract, + output: Extract): + Promise>; + /** Composite summary plans use this reentrantly for each provider request. */ + runSummaryRequest(plan: { taskId: string; attempt: number; requestIndex: number; + usageId: string; configuration: LaneConfiguration; + messages: AgentMessage[]; + telemetryContext: TelemetryContext }): + Promise; + settleSummaryRequest(current: CurrentOperation, + plan: { taskId: string; attempt: number; requestIndex: number; + usageId: string }, + response: SettledAssistantMessage, + telemetry: TelemetryContext): Promise; + /** Revalidates/registers effect start on the lane mutation line before execution. */ + run(plan: EffectPlan): Promise; + sleep(delayMs: number, telemetry: TelemetryContext): Promise; +} +``` + +The commit helpers shown in §4.1 delegate to these methods. Expected provider, tool, structural, and deferred-cancel failures return in-band `EffectOutput` variants; `run` rejects only for close, harness fault, or invariant defects. `cancel_deferred` is the explicit exception to ordinary start/settlement: its start check requires the same open cancelled operation and the process-local source target registered by `abort()` (the durable phase may already have advanced), uses a close-only signal rather than the already-pulled operation signal, and its awaited output bypasses `commitEffectSettlement` with no durable write. Automatic effects execute directly; manual effects gate the same calls. Passive event-listener delivery is observation, not an interpreter effect: it is isolated and telemetry-wrapped after publication but never parked by manual drive. `sleep` resolves early when the harness signal is pulled, after which the loop reloads cancellation control. For split-turn summary work, request-intent `commitTransition`, `runSummaryRequest`, and usage/state `settleSummaryRequest` are three distinct nested gated actions. `runSummaryRequest` performs the same serialized start check as `run`; abort-first returns `not_started`, leaves no usage, and makes the outer summary plan return its own `not_started` settlement, which discards structural work under cancelled control. The outer summary orchestration action is only process-local composition; manual drive and crash tests still stop between each nested boundary. These methods are the complete procedure crash-site catalog; ungated public mutations are the race boundaries in Part 9. + +**The provider signal is harness-owned.** `fx` supplies the `AbortSignal` passed to every provider request. No caller can supply one: `signal` is absent from the options type at every public surface (§5.2), and the harness strips any signal from a `streamOptions` patch before dispatch. Only `abort()` and `close()` can pull it. This is what makes §4.6's guarantee hold. + +**Manual drive.** With `drive: "manual"` the harness parks before each effect and exposes one JSON-safe action at a time: + +```ts +peekAction(): Promise; // stable, side-effect free +executeAction(): Promise; // release exactly one +runToCompletion(): Promise; +``` + +Lane-surface calls—including operation acceptance, `steer`, `abort`, config setters, and tree writes—stay **ungated**, so a test can drive both orders of any race. In manual mode a `before_run` handler parks before acceptance; with no handler, acceptance commits immediately and the first parked action is the run's first procedure transition. The gate is reentrant: nested `fx` calls (notably request hooks inside a stream) park independently, and the driver releases them before their parent continues. Closing while an action is parked rejects it unexecuted; durable state is exactly the committed prefix. + +Enforced by construction and by a test: an operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. + +## 4.3 The lane mutation line + +Every state-dependent mutation on a lane is linearized: validate, at most one atomic commit, and the in-memory update complete before the next mutation starts. Provider, tool, hook, and retry work never occupies the line. + +What serializes here: operation acceptance, queue enqueue and cancel, queue consumption, deferred-write acceptance and application, abort, lane-configuration setters, finish, lane creation. Harness-global stream/retry/compaction/queue settings use a second mutation line with a monotonically increasing process revision. Operation acceptance and generation/summary starts snapshot settings by taking the settings line before the lane line and conditionally committing both expected tokens; global setters take only the settings line. No code acquires them in the reverse order. + +Consequence: every race between two public calls has exactly **two** possible durable histories, and both must be tested (Part 9). + +## 4.4 Restore + +Recovery is point lookups against registers. No history, no folding, no journal replay, no tree walk. Per lane: + +```ts +async function restore(lane: string): Promise< + { kind: "idle"; lane: string } | { kind: "suspended"; current: CurrentOperation } +> { + const config = await storage.getRegister("lane.config", lane); + const state = await storage.getRegister("lane.state", lane); + const leaf = await storage.getRegister("lane.leaf", lane); + + const opId = state.value.currentOperationId; + const meta = opId ? await storage.getRegister("op.meta", opId) : undefined; + const opState = opId ? await storage.getRegister("op.state", opId) : undefined; + + // Idle lanes are validated too: leaf existence and every pendingNextRun + // id's pending.entry register (§3.3). Only the operation checks are + // conditional on an open operation. + const entryIds = directEntryIds(opState?.value, meta?.value, state.value, leaf.value); + const registerKeys = directRegisterKeys(opState?.value, state.value); + const [entries, registers] = await Promise.all([ + storage.getEntries(entryIds), getRegisters(registerKeys), + ]); + validateCurrent({ config, state, leaf, meta, opState }, entries, registers); // §3.3 + + if (!opId) { + // lane.lastResult is there if the application wants to reconcile a + // pre-crash outcome; restore itself never reads it. + return { kind: "idle", lane }; + } + + return { kind: "suspended", current: { + operation: meta.value, state: opState.value, + operationStateSeq: opState.seq, + laneState: state.value, laneStateSeq: state.seq, + leafId: leaf.value, + configuration: config.value, configurationSeq: config.seq, + } }; +} +``` + +Five register point-lookups: three lane registers, then — only when an operation is open — `op.meta` and `op.state`. `op.state` **is** the program counter: everything the interpreter needs to pick the next action is either in it or reachable from it by exact entry id or deterministic register key. + +**Bounded hydration and validation.** From the loaded state, collect what it names directly and fetch it in one batch: + +- **entries:** `triggerEntryId`, `latestAssistantEntryId`, `batch.assistantEntryId`, deferred `sourceEntryId`, completed `resultEntryId`s, the lane leaf, and from `op.meta` — `meta.value` is a hydration input, not merely presence-checked — `promptEntryIds`, a non-null `sourceLeafId`, and a navigation intent's non-null `targetId`; +- **registers:** `op.tool_args/…` for effect-pending calls, `op.preparation/…` for structural work, `pending.entry/…` for every `inbox.*`, `control.drained*`, and `pendingNextRun` id. + +Then §3.3's bounded validation over exactly that set: every named thing exists and has the right shape; reserved ids that *are* materialized contain what the intent promised; tool call indices are complete and unique. Configuration, stream options, and retry policy need no lookups at all — they are inline in the state itself. + +What restore never does: read register history (none exists), fold anything, scan tables, build provider context, probe for missing planned entries, audit completed operations, or infer state from what is absent. + +Restore already fetched the directly named entries and registers for validation. The driver reuses/caches them and lazily builds only derived provider context or additional branch projections needed by the next action; `nextAction` itself switches on scalars and the supplied loaded map (§4.1). + +### Worked example — crash in the uncertain window + +The process died mid-stream after an assistant intent (§3.7's `effect_pending` row; the §0.4 run). Reopen: + +``` +lane.state/main -> { currentOperationId: "op_9" } +op.meta/op_9 -> { intent: run, sourceLeafId: "e_41" } +op.state/op_9 -> { phase: assistant effect_pending, attempt: 1, + responseEntryId: "e_51", usageId: "u_7", + context: { configuration: { model: {...}, ... }, + retryPolicy: { maxAttempts: 3, ... } } } + +getEntries(["e_50"]) -> exists ✓ the placed prompt +getEntries(["e_51"]) -> absent reserved, unsettled — expected +``` + +The harness restores without starting any effect and reports the operation as suspended. When the application calls `resume()`, the interpreter sees `effect_pending` with no live key (the process-local `running` map died with the process) and applies the §4.5 uncertain-window policy — from the captured state itself: + +- attempt 1 < `maxAttempts` 3 → a fresh attempt 2 under the **captured** configuration and policy, even if the user changed the model yesterday; +- at the cap → synthesize an error response: insert entry `e_51` `{ stopReason: "error", … }`, insert zero usage `u_7`, enter failure drain — using exactly the ids reserved in the intent; +- control was `cancel_requested` → synthesize `aborted` under `e_51` instead, and never retry. + +Same shape for tools (replay only if the captured **and** current declarations say `safe`, else a synthetic interrupted result under the reserved result id) and deferred (wait for the application's next `resume()`; each poll reserves fresh ids). + +### Per backend + +- **Memory:** the maps are the state; nothing to do. +- **JSONL:** replay the file into the entry/register/usage maps — that is *decoding*, not recovery logic (§1.7); a torn final line is discarded whole. After decoding, restore is the same register reads. +- **SQLite** (and future Postgres): literally the point lookups above. + +### Missing identities + +Admission resolves configured identities and returns `Err(MissingIdentities)` before writing when any are absent. After that, dispatch trusts the environment: providers and tools are looked up by their captured durable identities at use time, and a lookup that fails settles in-band as an error — the same contract as an unknown tool. If resolution fails while state is still safely dispatchable (`ready`, `planned`, or between summary requests), the accepted call resolves `Ok({kind:"suspended", reason:"missing_identities", ...})` instead of burning an attempt; state is unchanged and the operation stays open. A later `resume()` precheck returns `Err(MissingIdentities)` on the same condition. Registering missing pieces does not auto-drive. Because the captured configuration is inline, restore reports exactly what is missing without resolving anything. Restored `effect_pending` follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities. + +## 4.5 Crash positions and recovery policy + +Atomic transactions have no internal prefix, so for any repeat-sensitive effect there are exactly these durable positions: + +| Crash point | What is durable | Recovery | +|---|---|---| +| before the intent commit | the previous state | plan the effect normally, as if nothing happened | +| after intent, before dispatch | `effect_pending`; the effect did not run, or you cannot tell | apply the policy below | +| during or after the effect, before settlement | `effect_pending`; the outcome is unknown | same | +| after the settlement commit | output + usage + next state | continue; never re-settle | +| before / after a queue-application commit | the item is fully pending / the entry exists and its register is gone | apply later / never apply twice | +| before the final structural commit | source leaf intact, generated work uncommitted | recompute per the current state and policy | +| after the final structural commit | move + summary entry + label + usage + terminal cleanup | done | +| after the first abort commit | cancellation and drained ids durable; drained payloads still in their pending registers | start no new ordinary effects; reconcile | +| after the terminal commit | op registers deleted, `lane.lastResult` written, `currentOperationId` null | the lane is idle | + +**The one uncertain interval in the entire system is: intent durable, settlement absent.** Three policies cover it: + +| Restored state | Policy | +|---|---| +| generation `effect_pending` | start a later numbered attempt only if the **captured** retry policy allows. Otherwise persist a synthetic error under the already-reserved response id. If cancellation is durable, persist synthetic `aborted` under that id instead, and never retry. | +| tool `effect_pending` | re-execute the persisted `op.tool_args` arguments only if the stored declaration **and** the current tool declaration both say `safe`. Otherwise append a synthetic `interrupted` error under the reserved result id. | +| deferred `effect_pending` | with running control, wait for the application's next `resume()`, which reserves fresh poll/response/usage ids; with cancelled control, synthetically settle the existing reserved response/usage ids as `aborted`. No cap. | + +## 4.6 Abort + +Abort is not a phase. It is `control`. + +- **First `abort()`**: one commit sets `control = cancel_requested`, records `requestedAt`, moves the exact drained steer and follow-up ids into `control.drained*`, and leaves `phase` untouched. The drained items' `pending.entry` registers are **not** deleted: `AbortResult` and a post-crash `SuspendedOperation.aborting` dereference the exact payloads from them, and they survive until the terminal transaction (§3.11, §3.13). After the commit, the harness pulls the signal and cancels unreleased gated effects. The call resolves once the marker is durable; reconciliation runs in the background (automatic drive) or parks at its next action (manual drive). +- **Later `abort()`** while the operation is open: appends nothing, signals nothing, returns the same drained payloads. After the terminal state: `NoActiveOperation`. +- **Still allowed after cancellation**: settling effects that were already intended, writing their usage, applying accepted deferred writes, committing configuration changes, and completing the cancellation. +- **Forbidden**: starting any new provider request, tool, decision hook, or retry. +- **Post-effect hooks**: abort and a not-yet-started `after_response`/`after_tool` serialize on the effect-start check. Abort-first skips the hook; assistant/fetch settlement uses the raw response then normalizes it to `aborted`, while a live tool keeps its raw result with `terminate:false`. Hook-first lets it finish and uses its transformed value. A hook already running is not forcibly interrupted. +- **Per-output reconciliation**: planned tool calls get an aborted error result; restored started calls get `interrupted`; live started calls keep their finalized or raw result as above; an assistant or fetch settlement after cancellation is stored under the reserved response id with stop reason `aborted` and moves to cancelled checkpoint state. + +**Signal ownership makes `aborted` unambiguous.** Provider implementations must set `stopReason: "aborted"` if and only if the signal they were given was pulled, and the harness owns that signal exclusively (§4.2). Since `abort()` commits `control` before pulling it, a settled `aborted` response always has cancellation already durable. Timeouts, transport failures, malformed streams, and provider-side refusals all settle as `error` and take the ordinary retry path — which is correct, because those should retry and a user abort should not. An `aborted` response with `control.status === "running"` is unreachable; if one exists, the session is corrupt (Part 9). + +On a deferred source, the `abort()` lane job registers the newest persisted handle as a process-local cancellation target and immediately installs `EffectPlan{kind:"cancel_deferred"}` in `DriveState.running`, even when the drive is awaiting a live fetch. It is the one external action permitted to start under cancelled control, remains valid if fetch settlement advances the durable phase, crosses normal manual gating and `pi.ai.request`, calls `Models.cancelDeferred` with the captured identity, converts success/failure to an in-band output, and never writes operation state. Cancellation reconciliation awaits/removes that live plan before terminal finish. Failure is telemetry only and never blocks finish. `deferredCancellations` prevents repetition in one process; crash/reopen during reconciliation may retry. Missing provider identity skips cancellation but not durable reconciliation. + +There is no universal assistant closure. The harness never starts a request or appends an assistant message solely to manufacture one. An abort between steps, during tool work, or while suspended can therefore produce no abort-specific assistant event at all. + +For structural operations the commit point decides the race: a marker committed first discards in-memory generated work and finishes `aborted`; if the structural commit won, the procedure completes that already-committed compaction or navigation and finishes `completed`. + +## 4.7 Close — a controlled crash + +**Close is not abort.** Close writes nothing: no cancellation, no terminal state, no settlement. + +``` +close() + → stop admitting new work + → pull the signal, so in-flight provider requests and cooperative tools stop + → reject parked manual actions and unresolved local promises + → let commits already accepted by storage drain + → close storage, release the writer lease (§1.7) +``` + +A harness-wide admission barrier linearizes close against every operation and surface commit. A commit that acquires admission first is allowed to finish and close waits for it; close that seals admission first prevents the commit from entering storage. A stream cut after sealing settles locally as `aborted`, but its settlement transaction is never admitted. Durable state therefore stops at `effect_pending`, exactly as after process death. + +So close needs no recovery machinery of its own: reopening finds `effect_pending` and applies the §4.5 policy — a later numbered attempt under the captured retry policy, or a synthetic error at the cap. Open operations remain open and resumable. + +This also keeps the aborted-implies-cancelled invariant (Part 9) true. Close pulls the same signal as abort, but the sealed admission barrier prevents that locally aborted response from committing with running control. + +## 4.8 Faults + +A failed storage commit faults the whole harness. A faulted harness stops all effects and rejects pending and future calls with `HarnessFault`; it is never an `Err` result. `faulted: true` appears in snapshots obtained before the fault closes observation. After the cause is fixed, reopening restores each lane from its registers. Close likewise rejects already-accepted local operation promises with `HarnessClosed`; calls not yet accepted return `Err(Closed)`. Surfaces without a `Result` channel — configuration and fact setters returning `Promise`, `SessionTree` appends returning an id string — reject with `HarnessClosed` on and after close. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) is an application defect and faults the harness; it never escapes as an undeclared operation error. `AgentTool.prepareArguments` is the deliberate exception handled by the tool pipeline as a synthetic tool error. + +## 4.9 External finalization + +An operation can end from outside its own drive: administrative force-kill tooling — or any future repairer (Part 6) — may commit the terminal transaction (§3.13), with or without synthetic settlements under the reserved ids, while a live drive still holds the operation in memory. The drive discovers this in exactly one way: a conditional commit or `reloadCurrent` finds the operation is no longer the lane's current operation — its registers are absent. + +The rule: **the drive stops.** It pulls the operation signal so in-flight effects cancel, discards every in-memory result without writing — no register remains to own a settlement — emits the operation's end events, and resolves the live caller's promise from `lane.lastResult`, which the finalizing transaction wrote (dereferencing `finalAssistantEntryId` to reconstruct `finalMessage` when present). + +On the shipping backends a finalizer is either in-process — an admin surface committing on the lane mutation line like any other job — or a separate process that first takes over the writer lease after close/crash. Every terminal transaction, the drive's own included, is conditional on `op.state` still existing at its expected seq, which is what makes invariant 21 (at most one terminal transaction per operation) hold under the race. It never re-creates registers, never commits a competing terminal transaction, and never treats the absence as corruption: absent `op.*` registers with a cleared `currentOperationId` is the ordinary post-terminal shape (§3.13). + +A suspended operation needs no drive to stop. The finalizer's terminal transaction leaves the lane idle; a later `resume()` finds `currentOperationId: null` and returns `NothingToResume`, and the application reads the outcome from `getLastResult()` (§5.1) — the same reconciliation path as any post-crash outcome. + +--- + +# Part 5 — Public surface + +## 5.1 The lane surface + +Expected rejection returns `Result.err`. Accepted operations return `Result.ok`, including failed, aborted, and suspended outcomes. Storage faults, close during accepted work, and invariant defects reject the promise. + +```ts +interface AgentLane { + readonly name: string; + getLeafId(): Promise; + /** The lane's most recent terminal outcome (§3.13); undefined before the + first terminal transaction. Never consulted by recovery. */ + getLastResult(): Promise; + + prompt(text: string, images?: ImageContent[]): Promise; + prompt(message: AgentMessage | AgentMessage[]): Promise; + skill(name: string, additionalInstructions?: string): Promise; + promptFromTemplate(name: string, args?: string[]): Promise; + compact(options?: { customInstructions?: string }): Promise; + navigateTree(targetId: string | null, options?: NavigateOptions): Promise; + resume(): Promise; + abort(): Promise; + + steer(message: string | AgentMessage, images?: ImageContent[]): Promise; + followUp(message: string | AgentMessage, images?: ImageContent[]): Promise; + nextRun(message: string | AgentMessage, images?: ImageContent[]): Promise; + cancelQueued(entryId: string): Promise; + + recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): + Promise; + waitForIdle(): Promise; + runWhenIdle(callback: () => void | Promise): Promise; + + peekAction(): Promise; + executeAction(): Promise; + runToCompletion(): Promise; + + /** Undefined when the durable provider/model identity is not registered. */ + getModel(): Promise; + setModel(model: Model): Promise; + getThinkingLevel(): Promise; setThinkingLevel(l: ThinkingLevel): Promise; + getActiveTools(): Promise; setActiveTools(names: string[]): Promise; + + session: SessionTree; + watch(): Promise>; +} + +interface NavigateOptions { summarize?: boolean; label?: string; customInstructions?: string } +interface ActionInfo { kind: string; description: string; details?: JsonValue } +interface WatchHandle { snapshot: T; start(listener: EventListener): void; unsubscribe(): void } +``` + +Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured `nextRun` and hook injections. + +`getLastResult()` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads the `lane.lastResult` register for the outcome its promise never delivered (§3.13). It is also how a caller learns the outcome of an operation finalized externally (§4.9). + +`waitForIdle()` registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local operation/admission reservation is held. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. + +`runWhenIdle(callback)` waits by the same rule, then takes a process-local lane admission reservation for the callback. The reservation is released on return or throw; callback rejection propagates. The callback must not invoke a state-mutating method on the same lane, which would deadlock behind its own reservation. Close rejects callbacks not yet started and waits for an already-running callback, which cannot be forcibly interrupted. + +### Results and errors + +```ts +type Result = { ok: true; value: T } | { ok: false; error: E }; +type Tagged> = + Error & { readonly _tag: Tag } & Readonly

; + +type OptionalFinalAssistant = + | { finalEntryId: string; finalMessage: AssistantMessage } + | { finalEntryId?: never; finalMessage?: never }; + +type MissingIdentitySuspension = { + kind: "suspended"; reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; +}; + +type RunOutcome = + | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) + | { kind: "suspended"; reason: "deferred"; leafId: string; + finalEntryId: string; deferred: DeferredHandle } + | (MissingIdentitySuspension & { leafId: string }); + +type CompactionOutcome = + | { kind: "completed"; leafId: string; entry: CompactionEntry } + | { kind: "declined" | "aborted"; leafId: string } + | { kind: "failed"; leafId: string; error: OperationError } + | (MissingIdentitySuspension & { leafId: string }); + +type NavigationOutcome = + | { kind: "completed"; oldLeafId: string | null; newLeafId: string | null; + summaryEntry?: BranchSummaryEntry } + | { kind: "declined" | "aborted"; leafId: string | null } + | { kind: "failed"; leafId: string | null; error: OperationError } + | (MissingIdentitySuspension & { leafId: string | null }); + +type ResumeOutcome = + | ({ operation: "run"; runId: string } & RunOutcome) + | ({ operation: "compaction"; runId: string } & CompactionOutcome) + | ({ operation: "navigation"; runId: string } & NavigationOutcome); +``` + +A completed run may omit final assistant fields when every finalized tool result terminates. The two fields are always both present or both absent. + +Expected errors use the existing `TaggedError` implementation in `harness/result.ts`: + +| tag | fields beyond `message` | +|---|---| +| `LaneBusy` | `lane`, `operationId`, `operationKind` | +| `MissingIdentities` | `lane`, `tools`, `models` | +| `NoActiveRun`, `NoActiveOperation`, `NothingToResume`, `NothingToCompact` | `lane` | +| `InvalidMessage`, `InvalidNavigation` | `lane`, `reason` | +| `UnknownSkill`, `UnknownTemplate` | `name` | +| `UnknownTarget` | `targetId` | +| `LaneExists`, `InvalidLane` | `lane` (`InvalidLane` also has `reason`) | +| `Closed` | none | + +```ts +type RunResult = Result<{ runId: string } & RunOutcome, + LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate | Closed>; +type CompactionResult = Result<{ runId: string } & CompactionOutcome, + LaneBusy | MissingIdentities | NothingToCompact | Closed>; +type NavigationResult = Result<{ runId: string } & NavigationOutcome, + LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed>; +type ResumeResult = Result; +type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>; +type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>; +type CancelQueuedResult = Result< + { kind: "cancelled" | "already_consumed" | "not_found" }, Closed>; +type AbortResult = Result<{ runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, + NoActiveOperation | Closed>; +type RecordUsageResult = Result<{ usageId: string }, Closed>; + +class HarnessFault extends Error { + readonly cause: unknown; + constructor(message: string, cause: unknown) { super(message); this.cause = cause; } +} +class HarnessClosed extends Error {} +``` + +`cancelQueued` has no unknown-item error: an id that is neither pending nor materialized returns `not_found` (§3.11) — previously cancelled, cleared by abort, or never existed — and a client retrying a lost cancel treats it as success. `AbortResult`'s steer/follow-up payloads are dereferenced from the drained items' surviving `pending.entry` registers (§4.6). `recordUsage` mints its ledger row id at commit (§1.6) and returns it. + +`runId` is the operation's durable `operationId`; the public name remains for compatibility. `HarnessFault` and `HarnessClosed` reject promises; they are not tagged expected errors and not members of these unions. + +## 5.2 The harness + +```ts +class AgentHarness + implements AgentLane { + /** Initializes an unconfigured main when needed, then restores every lane + without starting provider, tool, hook, or timer effects. One suspension + descriptor per lane with an open operation. */ + static create(options: AgentHarnessOptions): Promise<{ + harness: AgentHarness; + suspended: SuspendedOperation[]; + }>; + + lane(name: string): Promise; // lookup, never creates + createLane(name: string, at: string | null): Promise>; + lanes(): Promise; // always includes "main" + + // Harness-global. Tool implementations are code and cannot persist; active + // names live in each lane's configuration. setTools replaces only the registry. + getTools(): Promise[]>; + setTools(t: AgentHarnessTool[]): Promise; + getResources(): Promise; setResources(r: Resources): Promise; + getStreamOptions(): Promise; + setStreamOptions(o: AgentHarnessStreamOptions): Promise; + getRetryPolicy(): Promise; setRetryPolicy(p: RetryPolicy): Promise; + getCompactionSettings(): Promise; + setCompactionSettings(s: CompactionSettings): Promise; + getSteeringMode(): Promise; setSteeringMode(m: QueueMode): Promise; + getFollowUpMode(): Promise; setFollowUpMode(m: QueueMode): Promise; + + watchSession(): Promise<{ snapshot: SessionSnapshot; + start: (l: EventListener) => void; unsubscribe: () => void }>; + + hooks: Hooks; + events: Events; + + /** Detach cleanly (§4.7). Open operations stay resumable. */ + close(): Promise; +} + +interface LaneInfo { + name: string; + leafId: string | null; + operation: null | { id: string; kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting" }; +} + +interface SuspendedOperation { + lane: string; operationId: string; + kind: "run" | "compaction" | "navigation"; + reason: "crash" | "deferred" | "missing_identities"; + startedAt: number; + prompt?: AgentMessage[]; + deferred?: DeferredHandle; + /** Payloads dereferenced from the drained items' surviving pending.entry + registers (§4.6). */ + aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; + missing: { tools: string[]; models: string[] }; +} + +// QueueMode, RetryPolicy, and CompactionSettings use the source types named in §0.7. +``` + +### Options + +```ts +/** AgentHarnessStreamOptions is the curated source type from §0.7. It excludes + signal and provider lifecycle callbacks, which the harness owns. */ +interface AgentHarnessOptions { + session: Session; + models: Models; + + // Immutable lane seed captured at create(). Initializes main when the session + // is first attached, and every lane later created by this harness. Never a + // fallback for a lane that already has a configuration. + model: Model; + thinkingLevel?: ThinkingLevel; // default "off" + activeToolNames?: string[]; // default: initial tool names + + tools?: AgentHarnessTool[]; + toolContext?: TContext | (() => TContext | Promise); + systemPrompt?: string | ((ctx: TContext) => string | Promise); // per request + resources?: Resources; // skills, prompt templates + + streamOptions?: AgentHarnessStreamOptions; + retry?: RetryPolicy; + compaction?: CompactionSettings; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + toolExecution?: "sequential" | "parallel"; // default parallel + drive?: "automatic" | "manual"; // default automatic + + toProviderMessages?: (m: AgentMessage[]) => Message[] | Promise; + entryProjectors?: Record; + /** Existing typed telemetry contract; defaults to no-op. */ + telemetryContext?: TelemetryContext; +} + +type Resources = AgentHarnessResources; +type EntryProjector = (entry: CustomEntry) => + AgentMessage[] | undefined | Promise; +``` + +`create()` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before restore, it commits that seed as the first `lane.config` for a fresh or normalized-v3 `main`. Existing lanes use only their current config; the seed never overrides them. A configuration-less lane in a format-4 session is corrupt. + +`createLane(name, at)` atomically writes its registers and the original captured seed, regardless of later changes. Setters replace only their lane's register value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... })` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. + +Initial, replacement, and hook-patched stream options are normalized to detached JSON-safe values before publication because ready states persist them. Functions, symbols, bigint values, cycles, non-finite numbers, and unsupported prototypes in metadata reject construction/the setter without changing settings; an invalid hook patch is isolated as `handler_error` and ignored without changing operation state. Patch deletion semantics are applied before this validation. + +`systemPrompt`, `toolContext`, `toProviderMessages`, and `entryProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. + +## 5.3 SessionTree + +```ts +interface SessionTree { + getLeafId(): Promise; + getEntry(id: string): Promise; + getStats(): Promise; + + // Global facts. Latest wins; not branch-scoped. undefined deletes the + // register; JSON null is a legitimate custom value. Custom keys cannot + // collide with name or labels. + getName(): Promise; + setName(name: string | undefined): Promise; + getLabel(targetId: string): Promise; + setLabel(targetId: string, label: string | undefined): Promise; + getCustomFact(key: string): Promise; + setCustomFact(key: string, value: JsonValue | undefined): Promise; + + /** Session-wide, all branches, sequence order. */ + findEntries(query?: EntryQuery): Promise; + findEntry(query?: EntryQuery): Promise; + + /** Branch-scoped: the path from start toward root (§2.5). */ + findEntriesOnBranch(query?: BranchScan): Promise; + findEntryOnBranch(query?: BranchScan): Promise; + + // Writes resolve on durable acceptance; the returned id is the entry id, + // reserved when the write defers. + appendMessage(message: AgentMessage): Promise; + appendCustomEntry(customType: string, data?: JsonValue): Promise; +} + +interface EntryQuery { type?: EntryType; customType?: string; + order?: "asc" | "desc"; limit?: number; cursor?: EntryCursor } +interface SessionStats { messageCount: number; usage: Usage } +``` + +Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. + +Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. + +`SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getEntry` return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. + +## 5.4 Snapshots and subscription + +```ts +const { snapshot, start, unsubscribe } = await lane.watch(); +await send(client, { kind: "snapshot", snapshot }); // snapshot on the wire first +start((event) => send(client, event)); // flush buffer in order, then live +``` + +`watch()` atomically snapshots and begins buffering. `start(listener)` flushes in order, then delivers live; each event arrives once, in order, without sequence numbers or registration races. `unsubscribe()` drops the watcher and its buffer. A never-started watcher buffers without bound. + +```ts +interface QueuedItem { entryId: string; message: AgentMessage } + +interface LaneSnapshot { + lane: string; + transcript: Entry[]; // this lane's context window plus its compaction entry + leafId: string | null; + + operation: null | { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + startedAt: number; + suspended?: SuspendedOperation; + streamingMessage?: AssistantMessage; // message_start until entry commit + runningTools: { toolCallId: string; toolName: string; args: unknown; + partialResult?: AgentToolResult }[]; + retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; + }; + + queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; + pendingWrites: { entryId: string; type: EntryType; customType?: string; + message?: AgentMessage; data?: JsonValue }[]; + faulted: boolean; +} + +interface SessionSnapshot { + lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; + faulted: boolean; +} +``` + +`operation.status` derives from durable state plus a process-local suspension marker: `suspended` for deferred, restored, or missing-identity suspension; `aborting` when `control.status === "cancel_requested"`; otherwise `running`. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful resume attempt or abort in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced from each id's `pending.entry` register; abort-drained items are exposed only through `AbortResult` and `SuspendedOperation.aborting`, never as still-queued. `streamingMessage` and `runningTools` are process-local extras layered on top. + +Rules: + +- Configuration is **not** in snapshots. Getters return current values; `config_update` events tell a UI when to re-read. One source of truth. +- `streamingMessage` is not part of `transcript`. `message_end` replaces it with the final post-hook value but does not clear it; the matching `entry_added` confirms the append, adds the entry to `transcript`, and clears the draft. +- Direct messages and finalized tool results use the same immediate `message_start` → `message_end` lifecycle and enter `transcript` only on `entry_added`. They never populate `streamingMessage`. +- An `aborting` snapshot reports only state that actually exists. It never synthesizes a streaming assistant message. +- Reconnect means a new `watch()`. Only process death loses stream state; a restored harness shows the suspended operation instead. Every entry in the durable transcript is complete — a lost draft was never an entry. +- A lane watcher receives events whose `lane` matches, plus events with no lane. The harness-global `usage` event is the explicit exception: it carries its originating lane but reaches every watcher, because its totals are session-wide. + +## 5.5 Events + +One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot mutate execution, payloads are isolated from procedure state, and a throw produces `handler_error` plus telemetry without affecting execution. Only hooks intercept. + +Durable-fact events fire **after** commit — `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: `message_end` precedes the entry insert. + +```ts +type HarnessEventPayload = + // Run lifecycle + | { type: "run_start"; runId: string } + | { type: "run_resume"; runId: string } + | { type: "run_suspend"; runId: string; reason: "deferred"; + deferred: DeferredHandle } + | { type: "run_suspend"; runId: string; reason: "missing_identities"; + missing: { tools: string[]; models: string[] } } + | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } + | ({ type: "run_end"; runId: string; leafId: string | null } & ( + | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) + | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant))) + | { type: "fault"; code: string; message: string } + | ({ type: "handler_error"; error: string; stack?: string } & + ({ kind: "hook"; hook: string } | { kind: "event"; event: string })) + + // Steps and retries. First-try success emits no retry events. + | { type: "turn_start"; runId: string; turnId: string } + | { type: "turn_end"; runId: string; turnId: string; + message: AssistantMessage; toolResults: ToolResultMessage[] } + | { type: "retry_scheduled"; runId: string; step: string; attempt: number; + maxAttempts: number; delayMs: number; errorMessage: string } + | { type: "retry_start"; runId: string; step: string; attempt: number } + | { type: "retry_end"; runId: string; step: string; attempt: number; + success: boolean; finalError?: string } + + // Messages + | { type: "message_start"; runId?: string; message: AgentMessage } + | { type: "message_update"; runId: string; message: AgentMessage; + event: AssistantMessageEvent } + | { type: "message_end"; runId?: string; message: AgentMessage; entryId?: string } + + // Tools + | { type: "tool_start"; runId: string; turnId: string; toolCallId: string; + toolName: string; args: unknown } + | { type: "tool_update"; runId: string; turnId: string; toolCallId: string; + toolName: string; partialResult: AgentToolResult } + | { type: "tool_end"; runId: string; turnId: string; toolCallId: string; + toolName: string; result: AgentToolResult; isError: boolean; terminate: boolean } + + // Tree, queues, facts + | { type: "entry_added"; entry: Entry } + | { type: "write_pending"; runId: string; entryId: string; entryType: EntryType } + | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; + nextRun: QueuedItem[] } + | ({ type: "fact_update" } & ( + | { fact: "name"; name: string | undefined } + | { fact: "label"; targetId: string; label: string | undefined } + | { fact: "custom"; key: string; value: JsonValue | undefined })) + + // Configuration + | ({ type: "config_update" } & ( + | { property: "model"; value: { provider: string; modelId: string }; previous: unknown } + | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } + | { property: "activeTools"; value: string[]; previous: string[] } + | { property: "tools" | "resources" | "streamOptions" | "retryPolicy" + | "compactionSettings" | "steeringMode" | "followUpMode" })) + + // Structural + | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } + | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( + | { outcome: "completed"; entry: CompactionEntry; fromHook: boolean } + | { outcome: "declined" | "aborted" } + | { outcome: "failed"; error: OperationError })) + | { type: "navigation_start"; runId: string; targetId: string | null } + | ({ type: "navigation_end"; runId: string; + oldLeafId: string | null; newLeafId: string | null } & ( + | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } + | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } + | { outcome: "failed"; error: OperationError; summaryEntry?: never })) + + // Lanes and cost + | { type: "lane_created"; at: string | null } + | { type: "usage"; lane: string; row: UsageRow; totals: Usage }; + +type SpecialEventPayload = Extract; +type LaneEventPayload = Exclude; +type ConfigEventPayload = Extract; +type LaneConfigEventPayload = Extract; +type GlobalConfigEventPayload = Exclude; +type HandlerErrorPayload = Extract; + +type HarnessEvent = + | (LaneEventPayload & { lane: string; recovery?: true }) + | (LaneConfigEventPayload & { lane: string; recovery?: true }) + | (Extract & + { lane?: never; recovery?: never }) + | (Extract & { recovery?: never }) + | (GlobalConfigEventPayload & { lane?: never; recovery?: never }) + | (HandlerErrorPayload & ( + | { lane: string; recovery?: true } + | { lane?: never; recovery?: never } + )); + +type HarnessEventType = HarnessEvent["type"]; +type EventListener = + (event: E) => void | Promise; + +interface Events { + on( + type: T, + listener: EventListener>, + ): () => void; +} +``` + +`lane` is required on run/turn/retry/message/tool, entry/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries the origin lane and the complete ledger row, including its durable `seq` (§1.6). `recovery: true` appears on process-local lifecycle re-emitted by `resume()`, never on events for already-existing durable entries. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `row.seq` it has applied, preventing a late older event from regressing totals. + +Ordering for a streamed assistant response, asserted exactly by the conformance tests: + +``` +message_start → message_update* → after_response hook → message_end (final value, +optional reserved id) → atomic response + usage + classified-state commit +→ entry_added → usage +``` + +Only `entry_added` proves durability. Classification is computed before the transaction and becomes durable with it; it is not a separate event. Abort and overflow classification may normalize the committed response after `message_end`, so `entry_added` is authoritative for those two cases. A synthetic settlement performs no provider effect, update, or response hook: `message_start → message_end → atomic commit → entry_added → usage`. + +Nesting: + +``` +run_start + message_start / message_end / entry_added consumed prompt and queue messages + turn_start + message_start / message_update* / message_end assistant stream finished + entry_added response committed + tool_start / tool_update* / tool_end per real call + message_start / message_end tool results, source order + entry_added each result committed + turn_end + compaction_start … entry_added … compaction_end auto, at a checkpoint + turn_start … turn_end until nothing is pending +run_end +``` + +Deferred and recovery brackets are deterministic: + +- initial assistant generation uses `turnId = stepId`; a durable deferred response ends that turn, then emits `run_suspend`; +- every application `resume()` emits `run_resume`; `recovery:true` is present only when this harness restored the operation after process loss, not for same-process deferred resume; +- one deferred poll opens a turn whose durable id is `${stepId}:poll:${poll}`. Pending/error/ready settlement and any ready tool batch complete inside that turn, followed by `turn_end` and then suspend/failure/checkpoint; +- restored unresolved tools re-open their persisted `ToolBatch.turnId` with `recovery:true`, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/entry events are never replayed; +- resumed structural work re-emits its structural start with `recovery:true`; structural streams emit no message lifecycle and their typed result alone emits `entry_added`. + +Deferred polls emit no retry lifecycle. Events may contain sensitive conversation and tool content. Serving layers own authorization and redaction. Event payloads are isolated from mutable procedure state. Telemetry alone is content- and secret-free by default. + +## 5.6 Hooks + +Hooks are awaited interception points. Registration is harness-global; every payload carries `lane`. + +```ts +type BeforeResumePrepared = + | { kind: "run"; prompt: AgentMessage[]; systemPromptOverride?: string } + | { kind: "compaction"; sourceLeafId: string | null; + customInstructions?: string } + | { kind: "navigation"; sourceLeafId: string | null; targetId: string | null; + summarize: boolean; label?: string; customInstructions?: string }; + +interface HookMap { + before_run: { + event: { prompt: AgentMessage[]; systemPrompt: string; resources: Resources }; + result: { messages?: AgentMessage[]; systemPrompt?: string; resumeData?: JsonValue } | undefined; + }; + before_resume: { + event: BeforeResumePrepared & { resumeData?: JsonValue }; + result: void; + }; + before_run_end: { + event: { runId: string; messages: AgentMessage[] }; + result: { followUp?: string } | undefined; + }; + transform_context: { + event: { messages: AgentMessage[] }; + result: { messages: AgentMessage[] } | undefined; + }; + before_request: { + event: { model: Model; + step: "assistant" | "deferred" | "compaction" | "branch_summary"; + attempt: number; streamOptions: AgentHarnessStreamOptions }; + result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined; + }; + before_payload: { + event: { model: Model; payload: unknown }; + result: { payload: unknown } | undefined; + }; + after_response: { + event: { status?: number; headers?: Record; + message: SettledAssistantMessage }; + result: { message?: SettledAssistantMessage } | undefined; + }; + before_tool: { + event: { toolCallId: string; toolName: string; args: Record }; + result: { args?: Record; + block?: { reason: string; terminate?: boolean } } | undefined; + }; + after_tool: { + event: { toolCallId: string; toolName: string; args: Record; + content: AgentToolResult["content"]; details?: JsonValue; + isError: boolean; usage?: Usage }; + result: { content?: AgentToolResult["content"]; details?: JsonValue; + isError?: boolean; usage?: Usage; terminate?: boolean } | undefined; + }; + before_compaction: { + event: { reason: "manual" | "threshold" | "overflow"; + preparation: CompactionPreparation; customInstructions?: string }; + result: { decline?: boolean; compaction?: CompactResult } | undefined; + }; + before_navigation: { + event: { targetId: string; preparation: BranchPreparation; + customInstructions?: string }; + result: { decline?: boolean; summary?: BranchSummaryResult } | undefined; + }; +} + +type HookName = keyof HookMap; +type HookInvocation = HookMap[K]["event"] & { + lane: string; + /** Durable operation id, provisional for pre-acceptance before_run. */ + runId: string; +}; +type HookHandler = + (event: HookInvocation) => Promise | HookMap[K]["result"]; + +interface Hooks { + on(name: K, handler: HookHandler, + options?: { id?: string }): () => void; +} +``` + +Uniform semantics: + +- `before_run` and `before_resume` require a stable `id`, unique within each hook name; duplicates reject synchronously. An extension reuses its id across both hooks and across restarts; the runner stores `resumeData` by id and gives each resume handler only its own value. +- Handlers run in registration order, each seeing the prior output. `messages` append; `systemPrompt` replaces. +- A throw emits `handler_error`, skips that handler, and lets the rest continue. **`before_tool` instead fails closed and blocks the tool.** +- Durable hook outputs commit before execution continues. A return alone is not durable; a pre-commit crash may rerun the hook. +- Events expose post-hook values. Passive listeners cannot transform them. + +One `EffectPlan{kind:"hook"}` runs the complete registered pipeline for that hook name and returns its final aggregate; individual handlers are not separate durable/manual actions. The runner still isolates and telemetry-wraps each handler internally. Aggregation is deterministic: + +- `before_run` appends messages and lets the latest defined system prompt replace the prior one; resume data is stored under each handler id. +- context/request/payload/response and `after_tool` transformations run in registration order, each seeing the prior transformed value; option/result patches merge field by field. +- `before_tool` argument replacements chain and are revalidated; the first block is terminal and later handlers do not run. +- `before_compaction`/`before_navigation` stop at the first decline or supplied result; if all handlers return neither, generation is selected. Returning decline plus a result is a handler error and is ignored like a throw. +- `before_run_end` uses the latest defined follow-up. + +| Hook | When | Event | Result | +|---|---|---|---| +| `before_run` | once, before acceptance, outside the mutation line | `{ prompt, systemPrompt, resources }` | `{ messages?, systemPrompt?, resumeData? }` | +| `before_resume` | on `resume()`, before any effect; must be idempotent | `BeforeResumePrepared + { lane, runId, resumeData? }` | `void` | +| `before_run_end` | at a normal finish boundary | `{ runId, messages }` | `{ followUp? }` | +| `transform_context` | per request, `AgentMessage` level, before `toProviderMessages` | `{ messages }` | `{ messages }` | +| `before_request` | per request, provider-neutral options | `{ model, step, attempt, streamOptions }` | `{ streamOptions? }` | +| `before_payload` | per request, provider-specific wire payload | `{ model, payload }` | `{ payload }` | +| `after_response` | per response, after streaming settles, before `message_end` and the commit | `{ status, headers, message }` | `{ message? }` (must keep role) | +| `before_tool` | after validation, before execution | `{ toolCallId, toolName, args }` | `{ args?, block?: { reason: string; terminate?: boolean } }` | +| `after_tool` | after execution, before the result commits; patch semantics | `{ toolCallId, toolName, args, content, details, isError, usage? }` | `{ content?, details?, isError?, usage?, terminate? }` | +| `before_compaction` | in `deciding` | `{ reason, preparation, customInstructions? }` | `{ decline?, compaction? }` | +| `before_navigation` | in `deciding` | `{ targetId, preparation, customInstructions? }` | `{ decline?, summary? }` | + +`before_request` receives `AgentHarnessStreamOptions` and returns `AgentHarnessStreamOptionsPatch`; neither can contain a signal or provider lifecycle callback. `after_response` must preserve the assistant role and may return `aborted` only when the harness signal is already aborted. `before_navigation` runs only for summarized navigation; unsummarized navigation cannot decline. + +Replay across retry and resume: + +| Hook | fresh | retry | resume | +|---|---|---|---| +| `before_run` | once | no | no (persisted in `Operation`) | +| `before_resume` | no | no | yes, idempotent | +| `transform_context`, `before_request`, `before_payload` | per request | yes | yes | +| `after_response` | per response unless abort wins before it starts | per response | same rule | +| `before_tool` | per call | — | not when the call is already `effect_pending` | +| `after_tool` | per executed result unless abort wins before it starts | — | on safe replay only, with the same abort rule | +| `before_compaction`, `before_navigation` | once, until a structural source commits | no | never once `generating` is durable | +| `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | + +`before_run_end` may fire again after a crash at the same boundary. Handlers that must not double-fire keep their own durable marker. This is the exactly-once non-goal (§0.6) surfacing in the hook layer. + +## 5.7 Agent-loop building blocks + +The existing `agent-loop.ts` remains behavior-compatible and is refactored into these exported phases. Existing fields on `AgentTool`, `AgentToolResult`, and provider messages are retained. Add recovery declaration `replay?: "never" | "safe"` to `AgentTool`; omission means `"never"`. `AgentHarnessTool` inherits it. The `AgentEventSink` below is the existing agent-loop sink, not the harness event listener; the harness adapts agent events into §5.5 events. + +```ts +interface StreamAssistantConfig { + model: Model; + thinkingLevel: ThinkingLevel; + systemPrompt?: string; + tools?: AgentTool[]; + transformContext?: (messages: AgentMessage[], signal: AbortSignal) => + Promise; + toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; + models: Models; // resolves identity + auth per request + streamOptions?: AgentHarnessStreamOptions; + /** Harness-owned before_payload adapter; undefined keeps the payload. */ + transformPayload?: (payload: unknown, model: Model) => + unknown | undefined | Promise; + /** Final settled-message transform used by after_response, before message_end. */ + transformResponse?: (message: SettledAssistantMessage, + metadata: { status?: number; headers?: Record }) => + Promise; + telemetryContext: TelemetryContext; + signal: AbortSignal; +} + +function streamAssistant(messages: AgentMessage[], config: StreamAssistantConfig, + emit: AgentEventSink): Promise; +// The implementation converts curated streamOptions to provider options and +// installs harness-owned payload/response callbacks; callers cannot replace them. +// Existing summary helpers keep their Models-based request path. + +type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; + tool: AgentTool; args: Record }; +type ImmediateOutcome = { kind: "immediate"; result: AgentToolResult; + isError: true; terminate: boolean }; +type FinalizedToolCall = { toolCall: AgentToolCall; result: AgentToolResult; + isError: boolean; terminate: boolean }; + +interface ToolCallbacks { + beforeToolCall?(call: AgentToolCall, args: Record): + Promise; + afterToolCall?(call: AgentToolCall, args: Record, + result: AgentToolResult, isError: boolean): + Promise; + executeTool?(call: PreparedToolCall): + Promise<{ result: AgentToolResult; isError: boolean }>; + onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; + onToolResult?(call: AgentToolCall, message: ToolResultMessage, + terminate: boolean): Promise; +} + +function prepareToolCall(call: AgentToolCall, tools: AgentTool[], callbacks: ToolCallbacks, + telemetry: TelemetryContext, signal: AbortSignal): + Promise; +function executeToolCall(call: PreparedToolCall, emit: AgentEventSink, + telemetry: TelemetryContext, signal: AbortSignal): + Promise<{ result: AgentToolResult; isError: boolean }>; +function finalizeToolCall(call: PreparedToolCall, + executed: { result: AgentToolResult; isError: boolean }, + callbacks: ToolCallbacks, telemetry: TelemetryContext, + signal: AbortSignal): Promise; +``` + +External output that violates durable JSON/schema contracts is converted before settlement: an invalid provider message becomes a synthetic assistant `error` under the reserved response id; an invalid tool result becomes a synthetic error under its planned result id. Valid reported usage is retained when it can be validated independently, otherwise the synthetic entry reports zero. Invalid hook output is handled like a throwing handler (`before_tool` still fails closed); invalid caller input returns `InvalidMessage` before acceptance. No invalid payload reaches `Storage.commit()`. + +`AgentTool.prepareArguments` is deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. `ToolCallbacks` contains the existing before/after callbacks plus `executeTool`, `onToolStart`, and `onToolResult` durability callbacks described in §3.8. `onToolStart` receives effective arguments after `prepareArguments`, validation, and `before_tool`; `onToolResult` receives the finalized message and terminate decision. Blocked calls may terminate when `before_tool.block.terminate` is true. Replacement arguments are validated again. + +For each live tool batch, the harness resolves `toolContext` exactly once, caches bound `AgentHarnessTool` adapters in `DriveState.toolBatches`, and passes that same context as the fifth execute argument for every call. Safe replay after restart creates one new batch snapshot; context is environmental and never persisted. + +`executeToolBatch` (the exported successor of the source's private `executeToolCalls`) preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and `terminate: true` only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events. + +## 5.8 Telemetry + +Use the existing callback-based `TelemetryContext`, no-op/reference implementations, typed schema machinery, and agent-owned schemas. Do not invent a second contract. Context is passed explicitly; no core `AsyncLocalStorage` or global active span. + +Required spans remain: + +```text +pi.harness.run | compaction | navigation +pi.harness.checkpoint | turn | step | tool | hook | sleep | event_handler +pi.session.write +pi.ai.request +``` + +Operation, step, tool, hook, event, and write parents follow the actual interpreter/effect nesting. Sleep spans permit run, compaction, navigation, turn, and checkpoint parents. `stepId`/`taskId` correlate retries and recovery. Every provider request/fetch/cancel uses `pi.ai.request`; each real or safely replayed phase-two tool effect uses one tool span. + +Every storage transaction uses one `pi.session.write`. Its start attributes include `pi.session.item_count` and `pi.session.item_kinds` (`entry`, `usage`, `register`). A calling procedure may supply its lane/operation ids; storage never infers them from payloads. End attributes include first and last committed sequence. Update the existing schema from old single-mutation vocabulary to this transaction shape; no span is emitted for a conditional no-write result. Synthetic settlements and blocked/invalid tools emit no provider/tool-effect span. + +Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas. + +# Part 6 — Future: partitioned retention (Postgres) + +**This part is informative.** Nothing in it binds the shipping backends: Memory, JSONL, and SQLite never partition and never delete entries or usage rows (§1.2), and no core rule references this part for its correctness. It exists to show that the identity choices in §1.2 are sufficient for the one backend that would eventually retire old data — a possible Postgres deployment with TTL retention. It is a bridge we cross when we get there; this sketch is the current best guess, not a contract. + +- **The id is the partition key.** UUIDv7 sorts bytewise in time order, so the bulk tables — entries, usage ledger — use `PARTITION BY RANGE (id)` on the uuid id column, with period-boundary UUIDs (zeroed tails) as bounds. No partition column exists anywhere; §1.2's time prefix is the whole mechanism. Registers, `branch_meta`, stats, leases, and sessions stay in a hot unpartitioned catalog. `branch_entries` partitions by `entry_id` with the same bounds, so dropping a period cleans the branch index for free; `branch_meta` stays hot, and base pointers dangling into a dropped period are trimmed lazily on first access. +- **Pre-pass repair.** Before a period P is dropped, an online repairer makes live state stop referencing it: reparent edges crossing into P onto the nearest retained ancestor, found by an indexed uuid-range query; null any dormant `lane.leaf` decoding into P via a register-seq CAS; force-expire open operations still referencing P register-only — the terminal transaction of §3.13 writing `lane.lastResult`, no synthetic entries, with any live drive stopping through external finalization (§4.9); delete `fact.label` registers whose keys decode into P with one uuid-range delete. +- **The commit barrier.** Repair races ordinary commits, so the final step is atomic against all of them: `BEGIN; LOCK entries, registers IN ACCESS EXCLUSIVE MODE; ; ALTER TABLE … DETACH PARTITION p; COMMIT;` — plain `DETACH`, not `CONCURRENTLY`, precisely because it is transactional under the lock; the `DROP TABLE` happens later, unhurried. The barrier makes repair-plus-detach one linearization point: every commit sees either the fully attached period or a fully repaired store without it. +- **The default partition.** A `DEFAULT` partition absorbs stray inserts whose ids predate every attached partition — an ancient `pendingNextRun` item consumed years after its mint still places under its reserved id and simply lands there. Nothing errors and nothing is lost; the default partition stays small and is never dropped. +- **Register access under an external repairer.** A backend that admits an external repairer must perform register reads and CAS checks inside the commit transaction itself, so a repairer holding the barrier cannot interleave between a harness's read and its dependent write. The shipping backends need no such rule: single-writer sessions have no external repairer. + +Everything else a real deployment would need — retention policy, per-session versus per-deployment periods, operational partition-count limits — is deliberately unspecified until the backend is real. + +# Part 7 — Schema evolution + +## 7.1 The problem + +Full durability means snapshotting in-flight state, and in-flight state has the shape of *today's* state machine. Ship a new version with a different machine and the durable state written by the old one still exists — mid-run, mid-batch, mid-drain. Most durable-execution systems answer this badly or not at all. This design cannot: sessions are long-lived by intent. + +## 7.2 Why this design shrinks the problem + +Migration cost is proportional to what must be converted, and this design keeps the convertible surface small (§1.8): + +```text +what exists at upgrade time migration burden +──────────────────────────── ──────────────── +entries, usage rows (years) cannot rewrite — must stay read-compatible +lane/fact registers (a few per lane) trivial: a for-loop at open +op.* registers only for OPEN operations — usually zero +pending.entry registers open-operation inbox items plus + lane-owned queued nextRun items +``` + +Because no history is retained, the entire mutable surface is a few dozen current registers — which is what makes migrate-on-open tractable at all. And the fenced single-writer lease (§1.7) means the opening process owns the session exclusively — migration has no concurrency story to solve. + +## 7.3 The mechanism: storage version plus migrate-on-open + +One session-level `storageVersion` lives in the catalog or header (§1.7, §2.8). A version number is preferable to versioned namespace suffixes (`lane.state.v2`): one number to check, chained `v1→v2→v3` migrations, no probing of historical namespace names, and register keys stay stable for point lookups. + +```text +open session: + version == current → proceed + version < current → run migrations in order, each one transaction: + convert lane/fact/pending register values + handle open operations (§7.4) + bump the version + version > current → refuse to open (older binary, newer session) +``` + +Chained migrations run under the writer lease before `open()` returns (§2.8). Each step commits its conversions and version bump atomically, so a crash mid-chain resumes at the recorded version; conversions must be idempotent over already-converted values, which field mappings are by construction. + +JSONL has one wrinkle in each direction. Replay must decode superseded old-shape register lines leniently — as keyed raw JSON, overwrite-by-key only — because pre-migration bytes remain in the file (§1.7). And a migration must trigger snapshot compaction, whose temp-file-and-rename both persists the new header version atomically and retires the old-shape bytes. Between crash and compaction, lenient replay plus idempotent conversion make the intermediate state harmless. + +Legacy coding-agent format 3 predates `storageVersion` entirely; it normalizes through Appendix B on load and receives the current version with its first format-4 write. + +## 7.4 Migrations are total + +Register conversion is a field mapping; a state-machine shape change is more. If the next version removes `failure_drain`, or restructures the tool-batch lifecycle, an old `op.state` sitting mid-`failure_drain` has no field-by-field equivalent in the new machine. The rule: **migrations are total.** A vN→vN+1 migration translates every register value — lane and fact registers, `pending.entry` payloads, and open operations' `op.meta` and `op.state` included. The author of a state-machine change writes the mapping that carries every reachable old state into a well-defined new one, in the same change, reviewed and tested with it. A state with no natural successor maps to an explicit choice — typically the nearest safe pre-intent state, from which ordinary recovery (§4.5) proceeds. There is no force-settle path and no partial escape hatch. + +This is tractable for the same reason migrate-on-open is tractable at all (§7.2): the entire mutable surface is a few dozen current registers, and migration runs at open under the writer lease, so it sees **quiescent** registers — no drive is running, no effect is in flight, and every `op.state` is exactly the total state some transaction committed. A migration is a pure function over a small, fully enumerable, fully typed set of values. + +## 7.5 The three strata, restated as policy + +```text +entries + usage the stability budget goes HERE. Payloads are provider-shaped + messages plus three simple structural types; changes must be + read-compatible forever, because years of entries cannot + be rewritten at open time — the precise rewrite (§2.9) + exists, but it is administrative, not an open-time step. Custom + entry payloads are the application's contract. + +lane / fact migrate on open, mechanically. A few registers per lane, +registers cheap forever. + +op.* / pending.* ephemeral by construction and few in number. Every + state-machine change ships the total register mapping for + its own states (§7.4). This is where the machine is allowed + to churn between versions, because the mapping cost is + bounded by open operations — usually zero. +``` + +The design conclusion: the volatile part of the system — orchestration — was made ephemeral, and the durable part — the conversation — was made structurally boring. Schema evolution is exactly as hard as the boring part, which is the best available outcome. + +# Part 8 — Build order + +One shared slice lands the complete type surface; everything after it splits into two independent tracks. **Track S** (storage, search, dev TUI) parallelizes across owners — its slices depend only on slices 1–2 and never on each other. **Track R** (runtime) is sequential, runs entirely against the Memory backend, and never waits on Track S. The tracks cannot block each other. + +Each slice implements its named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and `npm run check` is its acceptance criterion. If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it for review — do not silently improvise a new durable contract inside a slice. + +| # | Slice | Implement | Required focused tests | +|---|---|---|---| +| 1 | **Types** | The complete shared type surface, behavior-free: `Entry`/`Register`/`UsageRow` and `RegisterValues` including the full Part 3 state tree, `Write`/`Transaction`/`Storage`/`Session`/`SessionTree`/`SessionRepo`, scans, the id-generator and `SessionSearchService` interfaces, `storageVersion`, and the Part 5 surface types (results, errors, events, snapshots, hooks). Delete `packages/agent/src/harness/**` and its tests outright; patch remaining consumers. The repo may not compile mid-slice; it compiles again — `npm run check` clean — at the end. | Type-level only; no behavior. | +| 2 | **Session layer, Memory, conformance** | Entry materialization with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`/views, codec plus runtime entry/register/custom-message schemas, UUIDv7 generator with follower minting, stats projection, the Memory backend with repository lifecycle/forks and the `storageVersion` gate at open, the backend conformance suite, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, placement, divergence, filters/cursors/stops, custom entries with and without data, context projection, fork before first attachment, configured fork snapshots/facts/zero ledger, close. | +| S1 | **JSONL** | Format 4: single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), the file-based repository, format-3 read normalization and first-write temp/rename conversion with id re-minting (Appendix B). Replace the unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule including id re-minting and reference remapping, resolved/unresolved parent paths, aggregate imported usage adjustment. | +| S2 | **SQLite** | One database file per session: entries/registers/usage-ledger tables, one-row session/lease rows, transactions, `storageVersion`, the file-based repository, segmented branch cache, `VACUUM INTO`-based rewrite/fork, and explicit repair. No values table, no `slot_history`, no `getLog`, no search projection, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, forks/stats/repair. | +| S3 | **Search** | The standalone `SessionSearchService` (§2.8): durable per-session cursors, `sync()` enumeration and catch-up, debounced `notify()`, `remove()`/reconciliation, `(sessionId, storeGeneration)` cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository. | Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline. | +| S4 | **Dev TUI and Client** | A minimal `AgentClient` over one lane — `LaneSnapshot` plus `watch()` events, `prompt`/`steer`/`followUp`/`abort`/`resume`/`cancelQueued`, `lane.lastResult` read — and a throwaway alt-screen TUI on `packages/tui`: transcript from snapshot and events, input box, status/queue display, abort key. Built first against a scripted fake client on the slice-1 types; binds to the real harness as Track R lands. Not final. | Compiles; fake-client smoke test. No durability obligations. | +| R1 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), dispatch-time identity resolution, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | +| R2 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | +| R3 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until R9. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | +| R4 | **Tools** | Refactor the existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, ordering, termination, abort-ready states. | +| R5 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation answering `not_found`, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | +| R6 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers, and the external-finalization stop on absent operation registers (§4.9). | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, an externally finalized operation stopping the drive without writes and resolving from `lastResult`, failure revived only by projecting input. | +| R7 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, per-poll request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of R6 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | +| R8 | **Manual compaction** | Reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | +| R9 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | +| R10 | **Navigation** | Validation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | +| R11 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry with total register mappings — open operations' `op.meta`/`op.state` included (§7.4), JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, an open-operation state mapped across a state-machine change and resuming correctly, lenient replay of superseded shapes, compaction retiring old bytes. | +| R12 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code — including the S4 fake client. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | + +Existing source guidance: + +- `packages/agent/src/harness/**` and all of its tests are **deletable outright** in slice 1 — no obligation to adapt anything. Salvaging pieces (the compaction preparation/split-turn algorithms for R8–R9, session/codec fragments) is optional and never required. +- `packages/agent/src/agent-loop.ts`: preserve behavior; R4 extracts its phases. +- `packages/session-backends/sqlite-node`: S2 may keep the working transaction and lease primitives or start clean. +- Telemetry contracts (`packages/telemetry`, the agent-owned schemas) remain authoritative. +- Existing tests are evidence, not authority. Keep those that assert unchanged behavior; delete the rest with the code they tested. + +# Part 9 — Invariants and tests + +## 9.1 Invariants + +Storage: + +1. Entries and usage rows are **write-once** and share one session-wide id namespace. Writing either kind under any existing id is corruption. +2. Transactions are all-or-none, with strictly increasing `seq` in write order; gaps are legal. `seq` is monotonic session-wide. +3. Registers are the only mutable state. A register delete removes the key; there are no tombstones, and JSON `null` is a legal value only where a namespace's type permits it. +4. **Every payload lives in exactly one place**: an entry, a register, or the ledger. There is no third place data can hide. +5. No read on a hot path may fold history or infer state from an absent value — no history exists to fold. Execution, recovery, and branch hot paths must be index-driven; inventory and debugging APIs page through indexes. + +Tree: + +6. An entry's parent chain never changes. Branches share prefixes; nothing is copied. +7. An entry either decodes against its type's runtime schema or is corruption. Only a custom entry may omit payload data. +8. Configuration and orchestration never enter the tree. Deleting every `op.*` and `pending.entry` register must leave a complete, valid conversation and ledger. +9. A lane's leaf moves only by append or navigation. +10. A branch segment chain, followed to its end, yields the full root path (§2.6). +11. A missing parent is corruption — always (§1.2). + +Operations: + +12. `lane.state/{lane}` confers lane ownership, and `op.state/{operationId}` confers operation-state ownership. An open lane names operation O, `op.meta/O` holds that lane's compatible `Operation`, and `op.state/O` holds an `OperationState` compatible with O's intent kind; state values carry no duplicate owner metadata. +13. `op.*` registers and operation-owned `pending.entry` registers exist **iff** their operation is open: the terminal transaction deletes them atomically with clearing `currentOperationId` (§3.13). Lane-owned `pendingNextRun` registers are never deleted by it. +14. Acceptance must observe `currentOperationId === null`. +15. A reserved id may exist only with the content its intent named. There are exactly two reservation regimes (§2.2): settlement-family ids are strings in `op.state`; queued-content ids are `pending.entry` registers — until placement or cancellation, exactly one of register and entry exists. +16. Only terminal transitions construct a `LaneLastResult`. A terminal outcome is observable once through the live promise and thereafter through `lane.lastResult` until the next terminal transaction on that lane; recovery never reads it. +17. At most one operation is open per lane. Two is corruption. +18. `overflowRecoveryUsed` is `true` only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes `false`; an unprojected custom write preserves it. +19. **The settlement transaction that commits a response with `stopReason: "aborted"` must, in that same transaction, write an operation state with `control.status === "cancel_requested"`.** The invariant is scoped to the committing transaction — later terminal cleanup or forks may remove the state without violating it. Providers must comply with the harness-owned signal contract; violation is corruption. +20. Current-state validation (§3.3) runs on every decoded latest lane/operation state before execution — idle lanes included (§4.4). `lane.lastResult` never determines an open operation's next action. +21. At most one terminal transaction ever commits per operation. A drive whose conditional commit or reload finds its operation's registers absent stops without writing and resolves from `lane.lastResult` (§4.9). + +## 9.2 Race catalog + +Each race has exactly two durable histories. Test both, in manual drive, in both orders. + +| Race | Orders | +|---|---| +| `prompt` vs `prompt` on one lane | one accepts, one gets `LaneBusy` | +| `abort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | +| `abort` vs tool result commit | planned result synthesized; or the real result stands | +| `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | +| `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | +| `setModel` vs generation step start | old snapshot used; or new snapshot used | +| `abort` vs structural commit | `aborted` with no entry; or `completed` | +| `nextRun` vs acceptance | captured by this run; or stays for the next | +| manual-compaction reservation vs idle tree write | reservation first → write waits; write first → preparation uses the new leaf | +| deferred write vs abort | write survives abort either way | +| `close` vs parked manual action | action rejected unexecuted; durable state is the committed prefix | +| `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | + +## 9.3 Test tiers + +**Tier A — state and resume.** For every state in Part 3, construct it durably, close, reopen, and assert the next action. Coverage must include: restore with no branch walk and no configuration dereference; assistant intent with no settlement, below and at the retry cap; settlement followed by each classification branch; every settled stop reason surviving except the two deliberate normalizations; a self-contained deferred step with copied configuration, consecutive polls, repeated equal-handle pending responses, ready and terminal responses, and handle-mismatch normalization into durable failure; every tool state including planned, effect_pending safe and unsafe, and completed; a batch where every call sets `terminate` finishing the run with no further request; genuine-`length` batches proving no execution and one explanatory result per call; every overflow crash position, including that the compacted `retainedTail` omits the normalized-`error` response by the ordinary projection rule; every navigation state with no post-move generation; abort at every position; missing identities on accept and on resume; every terminal transaction proving complete register deletion (including tool-args prefix-scan cleanup of crash-leaked keys), `lane.lastResult` correctness, and preserved `pendingNextRun`; register/entry exclusivity for every queued id at every crash boundary; and every half-completed recovery prefix. + +For each recovery prefix: close, reopen, resume, and compare against uninterrupted recovery. Invoking recovery twice from the initial prefix is **not** sufficient. + +One corruption assertion constructs an `aborted` response with running control directly and requires load rejection. Provider conformance separately proves implementations emit `aborted` only for the supplied signal. + +**Tier B — writer conformance.** Run the public harness against the instrumented-storage decorator: a spy wrapping `Storage.commit()` that records every transaction's writes in order. Assert exact write order and content against the Part 3 transaction tables and the §5.5 ordering rules. There is no durable log to compare against; the decorator is the oracle. Faux provider/tool/hook spies interleave their start events with the decorator's commit record, so effect timing is observable. This tier catches the critical regression classes: an effect starting before its intent commit, a response omitted for one stop reason, classification starting before usage is durable, a result id reserved after clearance began, or a terminal transaction leaking a register. + +**Tier C — deterministic interleavings.** Every race in §9.2, both orders, manual drive. + +**Cross-cutting:** + +- **Backend conformance.** One suite, three backends, identical results — identical query results, register states, and stats after every scenario, including register set/delete/recreate semantics and torn-transaction handling. Write-order assertions use the instrumented decorator, never a durable log. +- **Drive equivalence.** The same scenario in automatic and manual drive must produce byte-identical durable state. +- **Signal ownership.** No public surface accepts a signal; a `before_request` patch carrying one has it stripped. Assert by type and by test. +- **Ledger completeness.** Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. `getStats()` equals the ledger sum after every commit. A fork starts at zero. +- **Query-plan guards.** `EXPLAIN QUERY PLAN` for `scanBranch` matches §1.7 exactly — no `entries` scan or temporary ordering b-tree. Segment tests assert copied rows are bounded by the newest compaction interval. +- **Transaction discipline.** Assert every SQLite transaction opens with `BEGIN IMMEDIATE`. Add a regression test that reads, lets a second connection commit, then writes — it must succeed, and would fail with `database is locked` under a deferred `BEGIN`. +- **Segment chain soundness.** Build a chain by alternating branch-and-append across several compactions, then assert that a full-to-root scan through the chain returns exactly the entries a flat branch would, with no duplicates and no gaps. Both §2.6 rules — resolve-through-base coverage and the chain-searched newest compaction — fail this test when violated, and fail silently without it. + +--- + +# Appendix A — Glossary + +| Term | Meaning | +|---|---| +| **Entry** | Write-once conversation record: placement and payload in one row. Its id is the public entry id. | +| **Register** | Namespaced mutable cell holding its current typed value directly. Overwrite replaces; delete removes the key. | +| **Usage row** | Append-only cost ledger row. Never modified, never deleted. | +| **Pending entry** | Unplaced content in a `pending.entry` register keyed by its reserved entry id, until placement or cancellation. | +| **Session** | One conversation: tree, facts, ledger, lanes. | +| **Lane** | Named cursor into the tree with its own config, queues, and one operation. | +| **Operation** | One accepted unit of work: run, compaction, or navigation. | +| **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | +| **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | +| **Operation state** | The complete state of one operation at one moment — the `op.state` register, the program counter. | +| **Reserved id** | An id minted before its content exists: a string in `op.state` (settlement family) or a `pending.entry` key (queued content). | +| **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares one time prefix (§1.2). | +| **Lane mutation line** | Per-lane serialization point where all state-dependent mutations queue. | +| **Control** | Orthogonal cancellation flag: `running` or `cancel_requested`. | +| **Checkpoint** | The state between turns where queues, writes, and finishing are decided. | +| **Continuation** | Durable answer to "does this run still owe an assistant turn?" | +| **Terminal transaction** | The commit that deletes an operation's registers, writes `lane.lastResult`, and clears `currentOperationId`. | +| **Segment** | A branch-index range that references an older branch instead of copying it. | +| **External finalization** | A terminal transaction committed from outside the live drive; the drive detects absent registers, stops without writing, and resolves from `lane.lastResult` (§4.9). | +| **Precise rewrite** | The administrative copy-retained-and-swap rebuild of a session store — the sole sanctioned path that removes entries or usage rows (§2.9). | + +# Appendix B — Coding-agent v3-format compatibility + +"v3" in this appendix names the legacy coding-agent JSONL session format, not this document. Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalization on load: + +- `custom_message` becomes a custom agent message. +- `label` and `session_info` become facts (latest by file position wins) and leave the tree. A label targets its nearest retained parent. +- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` nodes disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed. +- Each retained child of a discarded node is reparented to its nearest retained ancestor. +- `main`'s leaf is the final physical node resolved through discarded nodes to its nearest retained ancestor. +- An old compaction resolves its legacy `firstKeptEntryId` field against its own branch and materializes that range as `retainedTail`. Format 4 never exposes or persists that field. +- Existing `details`, `usage`, and `fromHook` are preserved; an absent `fromHook` normalizes to `false`. +- v3 ISO timestamps convert to Unix milliseconds. +- A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. +- On first format-4 write, append one aggregate adjustment usage row with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged. +- Legacy v3 ids are re-minted at import: each entry gets a UUIDv7 whose prefix is the legacy entry's own timestamp (random tail for uniqueness), preserving time order and §1.2's every-id-is-time-prefixed property. All references the format knows are remapped — parent chains, `main`'s leaf, label keys, `fromId`, usage `entryId`. Ids embedded in opaque payloads (custom entry data, `details`, message text) are not rewritten; the opaque-payload contract (§1.2) already covers them. + +Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. + +# Appendix C — Open questions + +1. **Repairing a missing model captured inside an open operation.** Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by `setModel`. +2. **Overflow detection remains heuristic.** The normalization specified in §3.7 is authoritative. Preserve the original reason in `errorMessage` for diagnosis. +3. **Pending-payload write amplification.** The deliberate double write (§1.8) is paid only by queued items; measure it for pathological payloads before optimizing (`INSERT … SELECT` placement exists on SQL backends, eager compaction on JSONL). diff --git a/packages/agent/docs/search.md b/packages/agent/docs/search.md new file mode 100644 index 00000000000..97f94770f39 --- /dev/null +++ b/packages/agent/docs/search.md @@ -0,0 +1,276 @@ +# Session Search + +Pi search is a small query interface over committed session entries. The shared contract returns only stable hit identity; implementations may extend hits with backend-specific display data. + +## Core API + +```ts +export interface SessionSearchHit { + /** Logical identifier of the session that owns the entry. */ + readonly sessionId: string; + + /** Logical identifier of the entry within that session. */ + readonly entryId: string; +} + +export interface SessionSearchOptions { + /** Restrict results to specific canonical entry types. */ + readonly entryTypes?: readonly Entry["type"][]; + + /** Maximum number of hits to return. Backends may return fewer, not more. */ + readonly limit?: number; + + /** Abort signal for cancellation, e.g. search-as-you-type. */ + readonly signal?: AbortSignal; +} + +export interface SessionSearch { + search(text: string, options?: SessionSearchOptions): AsyncIterable; +} +``` + +The base hit is intentionally minimal: `(sessionId, entryId)` is the portable identity across JSONL, memory, SQLite FTS, and remote indexes. Snippets, timestamps, scores, metadata, offsets, and ranking semantics belong to concrete implementations. + +## Why async iterable + +`AsyncIterable` lets consumers render early results, stop iteration when they have enough, and cancel in-flight work with `AbortSignal`. Debouncing remains a UI/caller concern; the API only provides the cancellation primitive. + +```ts +let currentAbortController: AbortController | undefined; + +async function updateResults(query: string) { + currentAbortController?.abort(); + const controller = new AbortController(); + currentAbortController = controller; + + try { + for await (const hit of search.search(query, { limit: 10, signal: controller.signal })) { + render(hit); + } + } catch (error) { + if (!(error instanceof Error) || error.name !== "AbortError") throw error; + } +} +``` + +## Default implementations + +### Scanning search + +The reusable scanner adapts session-like readables (`getMetadata`, `findEntries`, and `getLabel`) into projected entries: + +```ts +export interface SessionSearchCandidate { + readonly entryId: string; + readonly seq: number; + readonly type: Entry["type"]; + readonly timestamp: number; + readonly text: string; + readonly fields?: Record; +} + +export interface ScanningSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; +} +``` + +`SessionSearchCandidate` is pre-match scanner input: it contains searchable text, type, sequence, and optional projected fields. The scanner turns matching candidates into public hits. + +Already-open sessions or storages can be scanned directly: + +```ts +const search = createScanningSessionSearch(sessions); + +for await (const hit of search.search("authentication", { limit: 10 })) { + const session = sessionsById.get(hit.sessionId)!; + const entry = await session.getEntry(hit.entryId); + console.log(entry); +} +``` + +JSONL does not need a separate public search adapter. JSONL-backed code can keep discovery/loading local, then pass the loaded storages to the same scanner: + +```ts +async function* jsonlReadables(jsonl: JsonlSessionRepoOptions, query: JsonlSessionListOptions = {}) { + for (const metadata of await listJsonlSessionMetadata(jsonl, query)) { + yield loadJsonlSessionStorage(jsonl, metadata); + } +} + +const search = createScanningSessionSearch((query) => jsonlReadables(jsonl, query)); +``` + +A scanning source must not call `SessionRepo.open()` on a harness-owned session if that operation may claim a writer lease. JSONL should use read-only loading helpers; already-open sessions/storages can be scanned directly. + +### SQLite FTS + +SQLite search exposes an extended hit: + +```ts +export interface SqliteSessionSearchHit extends SessionSearchHit { + readonly metadata: SqliteSessionMetadata; + readonly timestamp: number; + readonly score: number; +} +``` + +```ts +const search = createSqliteSessionSearch({ env, sqlite, databasePath }); + +for await (const hit of search.search("auth", { + entryTypes: ["message", "compaction"], + limit: 20, +})) { + console.log(hit.sessionId, hit.entryId, hit.score); +} +``` + +The FTS table and triggers are created lazily on first non-blank search. When FTS is first created, SQLite performs a one-time rebuild from canonical `entries`; after that, SQLite triggers keep FTS in sync with canonical entry inserts, deletes, and payload updates. This makes SQLite search fresh after commit, but it also means FTS trigger failures can roll back canonical SQLite writes while search is enabled for that database. + +## Indexed backends + +Search indexing is backend-owned derived state. The shared package only exports the query API; applications or backend packages may define their own writer/feed contracts when they need explicit index maintenance. + +### JSONL sessions with Elasticsearch + +This is application-owned glue. Core provides the query contract and JSONL session discovery; the Elastic writer contract is local to this adapter. + +```ts +import { Client } from "@elastic/elasticsearch"; +import { + scanningEntries, + type JsonlSessionMetadata, + type JsonlSessionRepoOptions, + type SessionSearch, + type SessionSearchHit, + type SessionSearchOptions, +} from "@earendil-works/pi-agent-core"; + +// JSONL-backed code can provide this locally from existing JSONL list/load helpers. +async function* jsonlReadables(jsonl: JsonlSessionRepoOptions, options: { cwd?: string } = {}) { + for (const metadata of await listJsonlSessionMetadata(jsonl, options)) { + yield loadJsonlSessionStorage(jsonl, metadata); + } +} + +interface SearchIndexWriter { + apply(items: TItem[]): Promise; + flush?(): Promise; +} + +interface IndexedSessionSearch + extends SessionSearch, SearchIndexWriter {} + +type ElasticSessionFeedItem = + | { type: "upsert"; id: string; body: ElasticSessionDoc } + | { type: "delete"; id: string }; + +interface ElasticSessionDoc { + sessionId: string; + entryId: string; + seq: number; + timestamp: number; + cwd: string; + text: string; + metadata: JsonlSessionMetadata; + fields?: Record; +} + +interface ElasticSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; + readonly score?: number; +} + +class ElasticSessionSearch + implements IndexedSessionSearch +{ + constructor( + private readonly client: Client, + private readonly index: string, + ) {} + + async apply(items: ElasticSessionFeedItem[]): Promise { + const operations = items.flatMap((item) => { + if (item.type === "delete") { + return [{ delete: { _index: this.index, _id: item.id } }]; + } + return [{ index: { _index: this.index, _id: item.id } }, item.body]; + }); + + if (operations.length > 0) await this.client.bulk({ operations }); + } + + async flush(): Promise { + await this.client.indices.refresh({ index: this.index }); + } + + async *search( + text: string, + options: SessionSearchOptions = {}, + ): AsyncIterable { + const result = await this.client.search({ + index: this.index, + size: options.limit ?? 20, + query: { + bool: { + must: [{ match: { text } }], + }, + }, + }); + + for (const hit of result.hits.hits) { + if (!hit._source) continue; + if (options.signal?.aborted) throw options.signal.reason; + yield { + sessionId: hit._source.sessionId, + entryId: hit._source.entryId, + timestamp: hit._source.timestamp, + snippet: hit._source.text, + score: hit._score ?? undefined, + }; + } + } +} +``` + +A catch-up/rebuild job can feed JSONL projections into Elasticsearch without taking a writer lease: + +```ts +async function indexJsonlSessionsIntoElastic( + jsonl: JsonlSessionRepoOptions, + elastic: ElasticSessionSearch, + options: { cwd?: string } = {}, +): Promise { + for await (const session of jsonlReadables(jsonl, { cwd: options.cwd })) { + const metadata = await session.getMetadata(); + for await (const candidate of scanningEntries(session)) { + await elastic.apply([{ + type: "upsert", + id: `${metadata.id}:${candidate.entryId}`, + body: { + sessionId: metadata.id, + entryId: candidate.entryId, + seq: candidate.seq, + timestamp: candidate.timestamp, + cwd: metadata.cwd, + text: candidate.text, + metadata, + fields: candidate.fields, + }, + }]); + } + } + + await elastic.flush(); +} +``` + +## Correctness and failure boundaries + +Search indexes are derived state for the shared API: applications can retry, rebuild, or mark search stale. Backend-specific choices may make different tradeoffs; SQLite FTS uses co-located triggers, so FTS failures can roll back canonical SQLite writes after search has initialized the triggers. + +Scanning sources should fail fast if they yield duplicate `sessionId` values, because base hit identity is `(sessionId, entryId)`. Indexed backends usually enforce uniqueness in their storage/index layer. + +Search opt-in still needs a sync/indexing layer. A follow-up should add a no-op-by-default search index sink (for example `NOOP_SEARCH_INDEX_SINK`) so canonical write sites can emit indexing events unconditionally, similar to how telemetry uses no-op implementations when telemetry is disabled. diff --git a/packages/agent/package.json b/packages/agent/package.json index 844141ea2a3..e472eaac5cb 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.84.0", + "version": "0.84.3", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -35,8 +35,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -60,7 +60,7 @@ "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", "vitest": "4.1.9" diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 3a12506cb2c..a251fede0a9 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -634,9 +634,13 @@ async function prepareToolCall( }; } if (beforeResult?.block) { + const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked"); + if (beforeResult.terminate === true) { + result.terminate = true; + } return { kind: "immediate", - result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), + result, isError: true, }; } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 6f9d6a3000b..0de7edd8302 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -331,6 +331,10 @@ export class Agent { /** Clear transcript state, runtime state, and queued messages. */ reset(): void { + if (this.activeRun) { + throw new Error("Agent is already processing. Wait for completion before resetting."); + } + this._state.messages = []; this._state.isStreaming = false; this._state.streamingMessage = undefined; diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts new file mode 100644 index 00000000000..a457b2b014c --- /dev/null +++ b/packages/agent/src/harness/events.ts @@ -0,0 +1,102 @@ +export interface RunStartEvent { + type: "run_start"; + lane: string; + runId: string; +} + +export interface RunEndEvent { + type: "run_end"; + lane: string; + runId: string; + outcome: "completed" | "aborted" | "failed"; + leafId: string; +} + +export type HarnessEvent = RunStartEvent | RunEndEvent; +export type HarnessEventType = HarnessEvent["type"]; +export type HarnessEventOfType = Extract; +export type HarnessEventListener = (event: TEvent) => void | Promise; + +export interface Events { + /** + * Register a passive listener for future events and return its unsubscribe function. + * Earlier events are not replayed and no current-state snapshot is provided; use a lane or session watch for both. + */ + on( + type: TType, + listener: HarnessEventListener>, + ): () => void; +} + +export interface WatchHandle { + snapshot: TSnapshot; + start(listener: HarnessEventListener): void; + unsubscribe(): void; +} + +export class HarnessEventBus implements Events { + private readonly listeners = new Map>(); + private readonly watchListeners = new Set<(event: HarnessEvent) => void>(); + + /** + * Register a listener for future events of one type and return its unsubscribe function. + * Earlier events are not replayed, and no snapshot or event buffer is provided. + */ + on( + type: TType, + listener: HarnessEventListener>, + ): () => void { + // Reuse this event type's listener set, or create its first set. + const listeners = this.listeners.get(type) ?? new Set(); + this.listeners.set(type, listeners); + + // Wrap this event-specific callback so it can be stored as a general HarnessEvent listener. + // Keep the wrapper reference so unsubscribe can remove that exact function from the set. + const receive: HarnessEventListener = (event) => { + if (event.type === type) return listener(event as HarnessEventOfType); + }; + listeners.add(receive); + return () => { + listeners.delete(receive); + if (listeners.size === 0) this.listeners.delete(type); + }; + } + + /** Publish an event to current event subscriptions and watch subscriptions. */ + emit(event: HarnessEvent): void { + // Deliver only to direct listeners registered for this event type. + // Async results are not awaited because emit() is synchronous. + for (const listener of this.listeners.get(event.type) ?? []) void listener(event); + + // Deliver every event to each watcher; watch() handles buffering until start(). + for (const listener of this.watchListeners) listener(event); + } + + watch(captureSnapshot: () => TSnapshot): WatchHandle { + let listener: HarnessEventListener | undefined; + let buffered: HarnessEvent[] = []; + const receive = (event: HarnessEvent): void => { + if (listener) void listener(event); + else buffered.push(event); + }; + this.watchListeners.add(receive); + const snapshot = captureSnapshot(); + + return { + snapshot, + start: (nextListener) => { + // Stay in buffering mode while flushing so reentrant emissions preserve order. + while (buffered.length > 0) { + const pending = buffered; + buffered = []; + for (const event of pending) void nextListener(event); + } + listener = nextListener; + }, + unsubscribe: () => { + this.watchListeners.delete(receive); + buffered = []; + }, + }; + } +} diff --git a/packages/agent/src/harness/session/jsonl/codec.ts b/packages/agent/src/harness/session/jsonl/codec.ts index e7abcdc0c68..84dbeeda068 100644 --- a/packages/agent/src/harness/session/jsonl/codec.ts +++ b/packages/agent/src/harness/session/jsonl/codec.ts @@ -1,6 +1,7 @@ +import { err, ok, type Result } from "../../types.ts"; import type { SessionMutation } from "../state.ts"; import type { Entry, LaneRecord } from "../types.ts"; -import { invalidFile } from "./errors.ts"; +import { JsonlDecodeError } from "./errors.ts"; import type { JsonlSessionMetadata, JsonlV4Header } from "./types.ts"; const ENTRY_TYPES = new Set([ @@ -29,69 +30,84 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseObject(line: string, path: string, lineNumber: number): Record { +function parseObject(line: string): Record { let value: unknown; try { value = JSON.parse(line); } catch (error) { - throw invalidFile(path, lineNumber, "is not valid JSON", error instanceof Error ? error : undefined); + throw new JsonlDecodeError("syntax", "is not valid JSON", error instanceof Error ? error : undefined); } - if (!isObject(value)) throw invalidFile(path, lineNumber, "is not a JSON object"); + if (!isObject(value)) throw new JsonlDecodeError("schema", "is not a JSON object"); return value; } -function requireString(value: unknown, path: string, line: number, field: string): string { - if (typeof value !== "string") throw invalidFile(path, line, `has invalid ${field}`); +function requireString(value: unknown, field: string): string { + if (typeof value !== "string") throw new JsonlDecodeError("schema", `has invalid ${field}`); return value; } -function requireSequence(value: unknown, path: string, line: number): number { - if (!Number.isSafeInteger(value) || (value as number) <= 0) throw invalidFile(path, line, "has invalid seq"); +function requireSequence(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new JsonlDecodeError("schema", "has invalid seq"); + } return value as number; } -function requireTimestamp(value: unknown, path: string, line: number): number { - if (!Number.isSafeInteger(value) || (value as number) < 0) throw invalidFile(path, line, "has invalid timestamp"); +function requireTimestamp(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new JsonlDecodeError("schema", "has invalid timestamp"); + } return value as number; } -function requireNullableId(value: unknown, path: string, line: number, field: string): string | null { +function requireNullableId(value: unknown, field: string): string | null { if (value !== null && typeof value !== "string") { - throw invalidFile(path, line, `has invalid ${field}`); + throw new JsonlDecodeError("schema", `has invalid ${field}`); } return value as string | null; } -export function parseHeader(line: string, path: string): JsonlV4Header { - const value = parseObject(line, path, 1); - if (value.kind !== "header") throw invalidFile(path, 1, "is not a header"); - if (value.version !== 4) throw invalidFile(path, 1, "has unsupported session version"); +function decodeHeader(line: string): JsonlV4Header { + const value = parseObject(line); + if (value.kind !== "header") throw new JsonlDecodeError("schema", "is not a header"); + if (value.version !== 4) throw new JsonlDecodeError("schema", "has unsupported session version"); const parentSessionId = value.parentSessionId; if (parentSessionId !== undefined && typeof parentSessionId !== "string") { - throw invalidFile(path, 1, "has invalid parentSessionId"); + throw new JsonlDecodeError("schema", "has invalid parentSessionId"); } const legacyParentSessionPath = value.legacyParentSessionPath; if (legacyParentSessionPath !== undefined && typeof legacyParentSessionPath !== "string") { - throw invalidFile(path, 1, "has invalid legacyParentSessionPath"); + throw new JsonlDecodeError("schema", "has invalid legacyParentSessionPath"); } if (parentSessionId !== undefined && legacyParentSessionPath !== undefined) { - throw invalidFile(path, 1, "has both parentSessionId and legacyParentSessionPath"); + throw new JsonlDecodeError("schema", "has both parentSessionId and legacyParentSessionPath"); } const metadataValue = value.metadata; - if (metadataValue !== undefined && !isObject(metadataValue)) throw invalidFile(path, 1, "has invalid metadata"); + if (metadataValue !== undefined && !isObject(metadataValue)) { + throw new JsonlDecodeError("schema", "has invalid metadata"); + } const metadata = metadataValue as JsonlV4Header["metadata"]; return { kind: "header", version: 4, - id: requireString(value.id, path, 1, "id"), - createdAt: requireTimestamp(value.createdAt, path, 1), - cwd: requireString(value.cwd, path, 1, "cwd"), + id: requireString(value.id, "id"), + createdAt: requireTimestamp(value.createdAt), + cwd: requireString(value.cwd, "cwd"), parentSessionId, legacyParentSessionPath, metadata, }; } +export function parseHeader(line: string): Result { + try { + return ok(decodeHeader(line)); + } catch (error) { + if (error instanceof JsonlDecodeError) return err(error); + throw error; + } +} + export function encodeHeader(header: JsonlV4Header): string { return `${JSON.stringify(header)}\n`; } @@ -112,70 +128,101 @@ export function metadataFromHeader(header: JsonlV4Header, path: string, modified }; } -export function parseMutation(line: string, path: string, lineNumber: number): SessionMutation { - const value = parseObject(line, path, lineNumber); - const seq = requireSequence(value.seq, path, lineNumber); - switch (value.kind) { - case "entry": { - const lane = value.lane === undefined ? undefined : requireString(value.lane, path, lineNumber, "lane"); - const id = requireString(value.id, path, lineNumber, "id"); - const type = requireString(value.type, path, lineNumber, "entry type"); - if (!ENTRY_TYPES.has(type as Entry["type"])) - throw invalidFile(path, lineNumber, `has unknown entry type ${type}`); - const parentId = requireNullableId(value.parentId, path, lineNumber, "parentId"); - const timestamp = requireTimestamp(value.timestamp, path, lineNumber); - if (type === "custom") requireString(value.customType, path, lineNumber, "customType"); - const { kind: _kind, lane: _lane, ...entryFields } = value; - const entry = { ...entryFields, id, type, parentId, seq, timestamp } as unknown as Entry; - return lane === undefined ? { kind: "entry", entry } : { kind: "entry", lane, entry }; +function parseEntryMutation(value: Record, seq: number): Extract { + const lane = value.lane === undefined ? undefined : requireString(value.lane, "lane"); + const id = requireString(value.id, "id"); + const type = requireString(value.type, "entry type"); + if (!ENTRY_TYPES.has(type as Entry["type"])) { + throw new JsonlDecodeError("schema", `has unknown entry type ${type}`); + } + const parentId = requireNullableId(value.parentId, "parentId"); + const timestamp = requireTimestamp(value.timestamp); + if (type === "custom") requireString(value.customType, "customType"); + const { kind: _kind, lane: _lane, ...entryFields } = value; + const entry = { ...entryFields, id, type, parentId, seq, timestamp } as unknown as Entry; + return lane === undefined ? { kind: "entry", entry } : { kind: "entry", lane, entry }; +} + +function parseRecordMutation( + value: Record, + seq: number, +): Extract { + const id = requireString(value.id, "id"); + const lane = requireString(value.lane, "lane"); + const type = requireString(value.type, "record type"); + if (!RECORD_TYPES.has(type as LaneRecord["type"])) { + throw new JsonlDecodeError("schema", `has unknown record type ${type}`); + } + const timestamp = requireTimestamp(value.timestamp); + if (type === "operation_started") { + if (!isObject(value.intent)) throw new JsonlDecodeError("schema", "has invalid intent"); + const operationKind = requireString(value.intent.kind, "operation kind"); + if (!OPERATION_KINDS.has(operationKind)) { + throw new JsonlDecodeError("schema", `has unknown operation kind ${operationKind}`); } - case "record": { - const id = requireString(value.id, path, lineNumber, "id"); - const lane = requireString(value.lane, path, lineNumber, "lane"); - const type = requireString(value.type, path, lineNumber, "record type"); - if (!RECORD_TYPES.has(type as LaneRecord["type"])) - throw invalidFile(path, lineNumber, `has unknown record type ${type}`); - const timestamp = requireTimestamp(value.timestamp, path, lineNumber); - if (type === "operation_started") { - if (!isObject(value.intent)) throw invalidFile(path, lineNumber, "has invalid intent"); - const operationKind = requireString(value.intent.kind, path, lineNumber, "operation kind"); - if (!OPERATION_KINDS.has(operationKind)) { - throw invalidFile(path, lineNumber, `has unknown operation kind ${operationKind}`); - } - } - if (type === "operation_finished") requireString(value.runId, path, lineNumber, "runId"); - const { kind: _kind, ...recordFields } = value; - return { - kind: "record", - record: { ...recordFields, id, lane, type, seq, timestamp } as unknown as LaneRecord, - }; + } + if (type === "operation_finished") requireString(value.runId, "runId"); + const { kind: _kind, ...recordFields } = value; + return { + kind: "record", + record: { ...recordFields, id, lane, type, seq, timestamp } as unknown as LaneRecord, + }; +} + +function parseLaneMutation(value: Record, seq: number): Extract { + return { + kind: "lane", + seq, + lane: requireString(value.lane, "lane"), + leafId: requireNullableId(value.leafId, "leafId"), + }; +} + +function parseFactMutation(value: Record, seq: number): Extract { + if (value.fact === "name") { + if (value.name !== undefined && typeof value.name !== "string") { + throw new JsonlDecodeError("schema", "has invalid name"); } + return { kind: "fact", seq, fact: "name", name: value.name }; + } + if (value.fact === "label") { + if (value.label !== undefined && typeof value.label !== "string") { + throw new JsonlDecodeError("schema", "has invalid label"); + } + return { + kind: "fact", + seq, + fact: "label", + targetId: requireString(value.targetId, "targetId"), + label: value.label, + }; + } + throw new JsonlDecodeError("schema", "has unknown fact type"); +} + +function decodeMutation(line: string): SessionMutation { + const value = parseObject(line); + const seq = requireSequence(value.seq); + switch (value.kind) { + case "entry": + return parseEntryMutation(value, seq); + case "record": + return parseRecordMutation(value, seq); case "lane": - return { - kind: "lane", - seq, - lane: requireString(value.lane, path, lineNumber, "lane"), - leafId: requireNullableId(value.leafId, path, lineNumber, "leafId"), - }; + return parseLaneMutation(value, seq); case "fact": - if (value.fact === "name") { - return { kind: "fact", seq, fact: "name", name: requireString(value.name, path, lineNumber, "name") }; - } - if (value.fact === "label") { - if (value.label !== undefined && typeof value.label !== "string") { - throw invalidFile(path, lineNumber, "has invalid label"); - } - return { - kind: "fact", - seq, - fact: "label", - targetId: requireString(value.targetId, path, lineNumber, "targetId"), - label: value.label, - }; - } - throw invalidFile(path, lineNumber, "has unknown fact type"); + return parseFactMutation(value, seq); default: - throw invalidFile(path, lineNumber, "has unknown mutation kind"); + throw new JsonlDecodeError("schema", "has unknown mutation kind"); + } +} + +export function parseMutation(line: string): Result { + try { + return ok(decodeMutation(line)); + } catch (error) { + if (error instanceof JsonlDecodeError) return err(error); + throw error; } } diff --git a/packages/agent/src/harness/session/jsonl/errors.ts b/packages/agent/src/harness/session/jsonl/errors.ts index 86a8b6fcb3e..bc191554118 100644 --- a/packages/agent/src/harness/session/jsonl/errors.ts +++ b/packages/agent/src/harness/session/jsonl/errors.ts @@ -1,6 +1,16 @@ import type { FileError, Result } from "../../types.ts"; import { SessionError } from "../types.ts"; +export class JsonlDecodeError extends Error { + readonly kind: "syntax" | "schema"; + + constructor(kind: "syntax" | "schema", message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "JsonlDecodeError"; + this.kind = kind; + } +} + export function fileResult(result: Result, message: string): T { if (!result.ok) { throw new SessionError( @@ -12,6 +22,6 @@ export function fileResult(result: Result, message: string): T return result.value; } -export function invalidFile(path: string, line: number, message: string, cause?: Error): SessionError { - return new SessionError("invalid_entry", `Invalid JSONL v4 session ${path}: line ${line} ${message}`, cause); +export function invalidFile(path: string, line: number, cause: Error): SessionError { + return new SessionError("invalid_entry", `Invalid JSONL v4 session ${path}: line ${line} ${cause.message}`, cause); } diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index 5cde6f6c3f3..bf8bb4874cc 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -2,7 +2,7 @@ import { uuidv7 } from "@earendil-works/pi-ai"; import { assertJsonSerializable, Session } from "../session.ts"; import { type ForkOptions, SessionError, type SessionRepo } from "../types.ts"; import { metadataFromHeader, parseHeader } from "./codec.ts"; -import { fileResult, invalidFile } from "./errors.ts"; +import { fileResult } from "./errors.ts"; import { JsonlSessionStorage } from "./storage.ts"; import type { JsonlSessionCreateOptions, @@ -24,10 +24,83 @@ function validateSessionId(id: string): void { } } -function sessionDirectoryName(cwd: string): string { +function jsonlSessionDirectoryName(cwd: string): string { return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } +async function jsonlSessionsRoot(options: JsonlSessionRepoOptions): Promise { + return fileResult( + await options.fs.absolutePath(options.sessionsRoot), + `Failed to resolve sessions root ${options.sessionsRoot}`, + ); +} + +async function jsonlSessionDirectory( + fs: JsonlSessionRepoFileSystem, + sessionsRoot: string, + cwd: string, +): Promise { + return fileResult( + await fs.joinPath([sessionsRoot, jsonlSessionDirectoryName(cwd)]), + `Failed to resolve sessions directory for ${cwd}`, + ); +} + +async function jsonlSessionDirectories(options: JsonlSessionRepoOptions, cwd?: string): Promise { + const sessionsRoot = await jsonlSessionsRoot(options); + if (cwd !== undefined) { + const resolvedCwd = fileResult(await options.fs.absolutePath(cwd), `Failed to resolve session cwd ${cwd}`); + const directory = await jsonlSessionDirectory(options.fs, sessionsRoot, resolvedCwd); + return fileResult(await options.fs.exists(directory), `Failed to check sessions directory ${directory}`) + ? [directory] + : []; + } + if (!fileResult(await options.fs.exists(sessionsRoot), `Failed to check sessions directory ${sessionsRoot}`)) + return []; + return fileResult(await options.fs.listDir(sessionsRoot), `Failed to list sessions directory ${sessionsRoot}`) + .filter((entry) => entry.kind === "directory" || entry.kind === "symlink") + .map((entry) => entry.path); +} + +export async function listJsonlSessionMetadata( + options: JsonlSessionRepoOptions, + query: JsonlSessionListOptions = {}, +): Promise { + const metadata: JsonlSessionMetadata[] = []; + for (const directory of await jsonlSessionDirectories(options, query.cwd)) { + const files = fileResult( + await options.fs.listDir(directory), + `Failed to list sessions directory ${directory}`, + ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); + for (const file of files) { + const [firstLine] = fileResult( + await options.fs.readTextLines(file.path, { maxLines: 1 }), + `Failed to read session header ${file.path}`, + ); + if (!firstLine) continue; + const headerResult = parseHeader(firstLine); + if (!headerResult.ok) continue; + metadata.push(metadataFromHeader(headerResult.value, file.path, file.mtimeMs)); + } + } + return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); +} + +export async function loadJsonlSessionStorage( + options: JsonlSessionRepoOptions, + metadata: JsonlSessionMetadata, +): Promise { + if (!fileResult(await options.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)) { + throw new SessionError("not_found", `Session not found: ${metadata.id}`); + } + const storage = await JsonlSessionStorage.load(options.fs, metadata.path); + const loadedMetadata = await storage.getMetadata(); + if (loadedMetadata.id !== metadata.id) { + throw new SessionError("invalid_entry", `Session id does not match header: ${metadata.id}`); + } + return storage; +} + function sessionFileName(createdAt: number, id: string): string { const timestamp = new Date(createdAt).toISOString().replace(/[:.]/g, "-"); return `${timestamp}_${id}.jsonl`; @@ -38,6 +111,7 @@ export class JsonlSessionRepo { private readonly fs: JsonlSessionRepoFileSystem; private readonly sessionsRootInput: string; + private readonly activeCreateDestinations = new Set(); private rootPromise: Promise | undefined; constructor(options: JsonlSessionRepoOptions) { @@ -46,16 +120,17 @@ export class JsonlSessionRepo } async create(options: JsonlSessionCreateOptions): Promise> { - const { header, path } = await this.prepareCreate(options); - return new Session(await JsonlSessionStorage.create(this.fs, path, header)); + const destination = await this.resolveCreateDestination(options); + return this.claimCreateDestination(destination, async () => { + const { header, path } = await this.prepareCreate(destination, options); + return new Session(await JsonlSessionStorage.create(this.fs, path, header)); + }); } async open(metadata: JsonlSessionMetadata): Promise> { return new Session(await this.loadStorage(metadata)); } - list(): Promise; - list(options: JsonlSessionListOptions): Promise; async list(options: JsonlSessionListOptions = {}): Promise { return this.listDirect(options); } @@ -69,32 +144,57 @@ export class JsonlSessionRepo options: ForkOptions & JsonlSessionCreateOptions, ): Promise> { const sourceStorage = await this.loadStorage(source); - const { header, path } = await this.prepareCreate({ + const createOptions = { ...options, parentSessionId: options.parentSessionId ?? source.id, + }; + const destination = await this.resolveCreateDestination(createOptions); + return this.claimCreateDestination(destination, async () => { + const { header, path } = await this.prepareCreate(destination, createOptions); + return new Session(await sourceStorage.fork(path, header, options)); }); - return new Session(await sourceStorage.fork(path, header, options)); } private async loadStorage(metadata: JsonlSessionMetadata): Promise { - if (!fileResult(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)) { - throw new SessionError("not_found", `Session not found: ${metadata.id}`); + return loadJsonlSessionStorage({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, metadata); + } + + private async resolveCreateDestination(options: JsonlSessionCreateOptions): Promise<{ id: string; cwd: string }> { + const id = options.id ?? uuidv7(); + validateSessionId(id); + const cwd = fileResult(await this.fs.absolutePath(options.cwd), `Failed to resolve session cwd ${options.cwd}`); + return { id, cwd }; + } + + /** + * Prevent same-process create/fork races for one logical destination. The durable filename includes a + * timestamp, so the async filesystem existence check alone can let two concurrent calls both decide the + * same {cwd, id} is free and publish duplicate sessions. + */ + private async claimCreateDestination( + destination: { id: string; cwd: string }, + operation: () => Promise, + ): Promise { + const key = `${destination.cwd}\0${destination.id}`; + if (this.activeCreateDestinations.has(key)) { + throw new SessionError("already_exists", `Session already exists: ${destination.id}`); } - const storage = await JsonlSessionStorage.load(this.fs, metadata.path); - const loadedMetadata = await storage.getMetadata(); - if (loadedMetadata.id !== metadata.id) { - throw new SessionError("invalid_entry", `Session id does not match header: ${metadata.id}`); + this.activeCreateDestinations.add(key); + try { + return await operation(); + } finally { + this.activeCreateDestinations.delete(key); } - return storage; } - private async prepareCreate(options: JsonlSessionCreateOptions): Promise<{ + private async prepareCreate( + destination: { id: string; cwd: string }, + options: JsonlSessionCreateOptions, + ): Promise<{ header: JsonlV4Header; path: string; }> { - const id = options.id ?? uuidv7(); - validateSessionId(id); - const cwd = fileResult(await this.fs.absolutePath(options.cwd), `Failed to resolve session cwd ${options.cwd}`); + const { id, cwd } = destination; if (await this.sessionIdExists(id, cwd)) { throw new SessionError("already_exists", `Session already exists: ${id}`); } @@ -120,24 +220,7 @@ export class JsonlSessionRepo } private async listDirect(options: JsonlSessionListOptions): Promise { - const directories = await this.sessionDirectories(options.cwd); - const metadata: JsonlSessionMetadata[] = []; - for (const directory of directories) { - const files = fileResult( - await this.fs.listDir(directory), - `Failed to list sessions directory ${directory}`, - ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); - for (const file of files) { - const content = fileResult( - await this.fs.readTextFile(file.path), - `Failed to read session header ${file.path}`, - ); - const firstLine = content.split("\n", 1)[0]; - if (!firstLine) throw invalidFile(file.path, 1, "is missing a header"); - metadata.push(metadataFromHeader(parseHeader(firstLine, file.path), file.path, file.mtimeMs)); - } - } - return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); + return listJsonlSessionMetadata({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, options); } private async sessionIdExists(id: string, cwd: string): Promise { @@ -148,24 +231,9 @@ export class JsonlSessionRepo return files.some((entry) => entry.kind !== "directory" && entry.name.endsWith(suffix)); } - private async sessionDirectories(cwd?: string): Promise { - const root = await this.root(); - if (cwd !== undefined) { - const resolvedCwd = fileResult(await this.fs.absolutePath(cwd), `Failed to resolve session cwd ${cwd}`); - const directory = await this.sessionDirectory(resolvedCwd); - return fileResult(await this.fs.exists(directory), `Failed to check sessions directory ${directory}`) - ? [directory] - : []; - } - if (!fileResult(await this.fs.exists(root), `Failed to check sessions directory ${root}`)) return []; - return fileResult(await this.fs.listDir(root), `Failed to list sessions directory ${root}`) - .filter((entry) => entry.kind === "directory" || entry.kind === "symlink") - .map((entry) => entry.path); - } - private async sessionDirectory(cwd: string): Promise { return fileResult( - await this.fs.joinPath([await this.root(), sessionDirectoryName(cwd)]), + await this.fs.joinPath([await this.root(), jsonlSessionDirectoryName(cwd)]), `Failed to resolve sessions directory for ${cwd}`, ); } diff --git a/packages/agent/src/harness/session/jsonl/storage.ts b/packages/agent/src/harness/session/jsonl/storage.ts index d9dca47af71..3d76cf92e61 100644 --- a/packages/agent/src/harness/session/jsonl/storage.ts +++ b/packages/agent/src/harness/session/jsonl/storage.ts @@ -17,7 +17,7 @@ import { type SessionStorage, } from "../types.ts"; import { encodeHeader, encodeMutation, metadataFromHeader, parseHeader, parseMutation } from "./codec.ts"; -import { fileResult, invalidFile } from "./errors.ts"; +import { fileResult, invalidFile, JsonlDecodeError } from "./errors.ts"; import type { JsonlSessionMetadata, JsonlSessionRepoFileSystem, JsonlV4Header } from "./types.ts"; /** @@ -70,25 +70,36 @@ export class JsonlSessionStorage implements SessionStorage const content = fileResult(await fs.readTextFile(path), `Failed to read session ${path}`); const physicalLines = content.split("\n"); if (physicalLines.at(-1) === "") physicalLines.pop(); - if (physicalLines.length === 0 || !physicalLines[0]) throw invalidFile(path, 1, "is missing a header"); - const header = parseHeader(physicalLines[0], path); + if (physicalLines.length === 0 || !physicalLines[0]) { + throw invalidFile(path, 1, new JsonlDecodeError("schema", "is missing a header")); + } + const headerResult = parseHeader(physicalLines[0]); + if (!headerResult.ok) throw invalidFile(path, 1, headerResult.error); const fileInfo = fileResult(await fs.fileInfo(path), `Failed to read session metadata ${path}`); - const storage = new JsonlSessionStorage(fs, metadataFromHeader(header, path, fileInfo.mtimeMs)); + const storage = new JsonlSessionStorage(fs, metadataFromHeader(headerResult.value, path, fileInfo.mtimeMs)); for (let index = 1; index < physicalLines.length; index++) { const line = physicalLines[index]!; - let mutation: SessionMutation; + const mutationResult = parseMutation(line); + if (!mutationResult.ok) { + const isTornTail = index === physicalLines.length - 1 && mutationResult.error.kind === "syntax"; + if (isTornTail) { + // Drop the unacknowledged partial append by atomically publishing the valid prefix. + const validPrefix = `${physicalLines.slice(0, index).join("\n")}\n`; + await publishFileAtomically(fs, path, async (tempPath) => { + fileResult(await fs.writeFile(tempPath, validPrefix), `Failed to stage torn-tail repair ${path}`); + }); + return storage; + } + throw invalidFile(path, index + 1, mutationResult.error); + } try { - mutation = parseMutation(line, path, index + 1); + storage.applyMutation(mutationResult.value); } catch (error) { - if (index !== physicalLines.length - 1 || !(error instanceof SessionError) || error.cause === undefined) - throw error; - const validPrefix = `${physicalLines.slice(0, index).join("\n")}\n`; - await publishFileAtomically(fs, path, async (tempPath) => { - fileResult(await fs.writeFile(tempPath, validPrefix), `Failed to stage torn-tail repair ${path}`); - }); - return storage; + if (error instanceof SessionError && error.code === "invalid_entry") { + throw invalidFile(path, index + 1, error); + } + throw error; } - storage.applyMutation(mutation, path, index + 1); } if (!content.endsWith("\n")) { fileResult(await fs.appendFile(path, "\n"), `Failed to repair unterminated session tail ${path}`); @@ -213,7 +224,7 @@ export class JsonlSessionStorage implements SessionStorage return this.state.getName(); } - setName(name: string): Promise { + setName(name: string | undefined): Promise { return this.enqueue(async () => { const mutation: SessionMutation = { kind: "fact", seq: this.state.nextSequence, fact: "name", name }; await this.appendMutation(mutation); @@ -260,13 +271,7 @@ export class JsonlSessionStorage implements SessionStorage ); } - private applyMutation( - mutation: SessionMutation, - path = this.metadata.path, - line = this.state.nextSequence + 1, - ): void { - this.state.applyMutation(mutation, (message) => { - throw invalidFile(path, line, message); - }); + private applyMutation(mutation: SessionMutation): void { + this.state.applyMutation(mutation); } } diff --git a/packages/agent/src/harness/session/jsonl/types.ts b/packages/agent/src/harness/session/jsonl/types.ts index 0483d9e558b..f838a90f37e 100644 --- a/packages/agent/src/harness/session/jsonl/types.ts +++ b/packages/agent/src/harness/session/jsonl/types.ts @@ -6,6 +6,7 @@ export type JsonlSessionRepoFileSystem = Pick< | "absolutePath" | "joinPath" | "readTextFile" + | "readTextLines" | "writeFile" | "appendFile" | "renameFile" diff --git a/packages/agent/src/harness/session/memory.ts b/packages/agent/src/harness/session/memory.ts index 598d8dcf4fc..cccbd82096c 100644 --- a/packages/agent/src/harness/session/memory.ts +++ b/packages/agent/src/harness/session/memory.ts @@ -121,7 +121,7 @@ export class InMemorySessionStorage implements SessionStorage { return this.state.getName(); } - async setName(name: string): Promise { + async setName(name: string | undefined): Promise { this.state.applyMutation({ kind: "fact", seq: this.state.nextSequence, fact: "name", name }); } diff --git a/packages/agent/src/harness/session/search.ts b/packages/agent/src/harness/session/search.ts deleted file mode 100644 index 0bd44c44cc1..00000000000 --- a/packages/agent/src/harness/session/search.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { FileError, Result } from "../types.ts"; -import type { Session } from "./session.ts"; -import { type SessionCreateOptions, SessionError, type SessionMetadata, type SessionRepo } from "./types.ts"; - -export interface SessionSearchOptions { - text: string; - cwd?: string; -} - -export interface SessionSearchHit { - metadata: TMetadata; - entryId: string; - timestamp: string; - snippet?: string; - score?: number; -} - -export interface SessionSearch { - search(options: SessionSearchOptions): Promise[]>; -} - -export function getFileSystemResultOrThrow(result: Result, message: string): TValue { - if (!result.ok) { - const code = result.error.code === "not_found" ? "not_found" : "storage"; - throw new SessionError(code, `${message}: ${result.error.message}`, result.error); - } - return result.value; -} - -type ScanningSessionSearchSource = { - list(): Promise; - open(metadata: TMetadata): Promise>; -}; - -class ScanningSessionSearch implements SessionSearch { - private readonly source: ScanningSessionSearchSource; - - constructor(source: ScanningSessionSearchSource) { - this.source = source; - } - - async search(options: SessionSearchOptions): Promise[]> { - const normalizedText = options.text.trim().toLowerCase(); - if (!normalizedText) return []; - const hits: SessionSearchHit[] = []; - for (const metadata of await this.source.list()) { - const cwd = (metadata as { cwd?: unknown }).cwd; - if (options.cwd !== undefined && cwd !== options.cwd) continue; - const session = await this.source.open(metadata); - for (const entry of await session.findEntries({ order: "oldestFirst" })) { - const payload = JSON.stringify(entry); - if (!payload.toLowerCase().includes(normalizedText)) continue; - hits.push({ - metadata, - entryId: entry.id, - timestamp: new Date(entry.timestamp).toISOString(), - snippet: payload, - }); - } - } - return hits; - } -} - -export function createScanningSessionSearch< - TMetadata extends SessionMetadata, - TCreateOptions extends SessionCreateOptions, - TListOptions, ->(source: Pick, "list" | "open">): SessionSearch { - return new ScanningSessionSearch(source); -} diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 262cdcd8c28..8027475dd74 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -147,7 +147,7 @@ export class Session implem return this.storage.getName(); } - async setName(name: string): Promise { + async setName(name: string | undefined): Promise { await this.storage.setName(name); } @@ -223,6 +223,7 @@ export class Session implem return this.queryLog(options); } + /** Returns the lane's current leaf, or null when empty. Throws when the lane does not exist. */ private async getLeafIdForLane(lane: string): Promise { const pointer = (await this.getLanes()).find((candidate) => candidate.lane === lane); if (!pointer) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); @@ -235,14 +236,18 @@ export class Session implem return this.storage.findEntries(resultLimit === query.limit ? query : { ...query, limit: resultLimit }); } + /** + * Queries from `query.start` toward the root, defaulting to the lane's current leaf. + * `resultLimit` lets single-entry queries cap results without changing the caller's query. + */ private async queryBranchEntries( - lane: string, + defaultLane: string, query: EntryQuery & BranchBounds = {}, resultLimit = query.limit, ): Promise { assertValidLimit(query.limit); assertValidCursor(query.cursor?.afterSeq); - const start = query.start ?? (await this.getLeafIdForLane(lane)); + const start = query.start ?? (await this.getLeafIdForLane(defaultLane)); if (start === null) return []; const storageQuery = resultLimit === query.limit ? query : { ...query, limit: resultLimit }; return this.storage.findEntriesOnBranch({ ...storageQuery, start }); diff --git a/packages/agent/src/harness/session/state.ts b/packages/agent/src/harness/session/state.ts index edc16f85b63..c63bd5b93e0 100644 --- a/packages/agent/src/harness/session/state.ts +++ b/packages/agent/src/harness/session/state.ts @@ -18,7 +18,7 @@ export type SessionMutation = | { kind: "entry"; lane?: string; entry: Entry } | { kind: "record"; record: LaneRecord } | { kind: "lane"; seq: number; lane: string; leafId: string | null } - | { kind: "fact"; seq: number; fact: "name"; name: string } + | { kind: "fact"; seq: number; fact: "name"; name: string | undefined } | { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined }; type InvalidMutation = (message: string) => never; diff --git a/packages/agent/src/harness/session/testing/conformance.ts b/packages/agent/src/harness/session/testing/conformance.ts index be33b221308..9adc10bb947 100644 --- a/packages/agent/src/harness/session/testing/conformance.ts +++ b/packages/agent/src/harness/session/testing/conformance.ts @@ -640,6 +640,29 @@ export function createSessionBackendConformance( }, ), + createCase(factory, "queries and facts", "clears session names durably", async (repository) => { + const session = await repository.create({ id: "session" }); + await session.setName("Temporary"); + await session.setName(undefined); + + strictEqual(await session.getName(), undefined); + deepStrictEqual(await session.getLog(), [ + { kind: "fact", seq: 1, fact: "name", name: "Temporary" }, + { kind: "fact", seq: 2, fact: "name", name: undefined }, + ]); + + const metadata = await session.getMetadata(); + const reopened = await repository.open(metadata); + strictEqual(await reopened.getName(), undefined); + deepStrictEqual(await reopened.getLog(), [ + { kind: "fact", seq: 1, fact: "name", name: "Temporary" }, + { kind: "fact", seq: 2, fact: "name", name: undefined }, + ]); + + const fork = await repository.fork(metadata, { id: "fork" }); + strictEqual(await fork.getName(), undefined); + }), + createCase(factory, "validation and immutability", "returns immutable copies from reads", async (repository) => { const session = await repository.create({ id: "immutable" }); const metadata = await session.getMetadata(); diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 77b05ad24f1..1164698ff26 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -14,9 +14,9 @@ export interface IdGenerator { export interface EntryBase { type: string; id: string; - seq: number; - parentId: string | null; - timestamp: number; + seq: number; // shared sequence; read-side, storage-assigned + parentId: string | null; // storage-assigned: the appending lane's leaf + timestamp: number; // Unix ms, storage-assigned } export interface MessageEntry extends EntryBase { @@ -222,26 +222,37 @@ export interface EntryCursor { export interface EntryQuery { type?: Entry["type"]; - customType?: string; - order?: EntryOrder; + customType?: string; // for type "custom" + order?: EntryOrder; // default newestFirst limit?: number; cursor?: EntryCursor; } +/** Bounds of a branch scan. Default: the whole path, leaf to root. */ export interface BranchBounds { - start?: string; - stopAtType?: Entry["type"]; + start?: string; // default: the view's lane leaf + stopAtType?: Entry["type"]; // scan ends after the first match, inclusive stopAtId?: string; } export interface RecordQuery { + /** Exact lane match. Omit to query every lane. */ lane?: string; + /** Exact record discriminant match. Omit to query every record type. */ type?: LaneRecord["type"]; + /** + * Operation identity. Matches OperationStartedRecord.id and the runId + * property of operation-owned records. Records without an operation + * identity do not match. + */ runId?: string; - /** Valid only with type "operation_started". */ + /** Exact operation intent kind. Valid only with type "operation_started". */ operationKind?: OperationStartedRecord["intent"]["kind"]; + /** Exclusive chronological lower bound: seq > afterSeq, regardless of order. */ afterSeq?: number; + /** Sequence order. Default: "newestFirst". */ order?: EntryOrder; + /** Positive maximum number of matching records. */ limit?: number; } @@ -268,7 +279,7 @@ export type LogItem = | { kind: "entry"; seq: number; entry: Entry } | { kind: "record"; seq: number; record: LaneRecord } | { kind: "lane"; seq: number; lane: string; leafId: string | null } - | { kind: "fact"; seq: number; fact: "name"; name: string } + | { kind: "fact"; seq: number; fact: "name"; name: string | undefined } | { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined }; export interface LogOptions { @@ -291,7 +302,7 @@ export interface SessionStorage; findEntries(query?: EntryQuery): Promise; - /** start is mandatory here; defaulting to a lane's leaf is view sugar. */ + /** start is mandatory here (as opposed to SessionTree's findEntriesOnBranch); defaulting to a lane's leaf is view sugar. */ findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise; findRecords( query: RecordQuery & { type: K }, @@ -308,7 +319,7 @@ export interface SessionStorage; - setName(name: string): Promise; + setName(name: string | undefined): Promise; getLabel(id: string): Promise; setLabel(id: string, label: string | undefined): Promise; getStats(): Promise; @@ -318,14 +329,24 @@ export interface SessionTree { getLeafId(): Promise; getEntry(id: string): Promise; getStats(): Promise; + + // Global facts. Latest wins; not branch-scoped. "set", not "append": + // append vocabulary is reserved for tree writes. getName(): Promise; - setName(name: string): Promise; + setName(name: string | undefined): Promise; getLabel(targetId: string): Promise; setLabel(targetId: string, label: string | undefined): Promise; + + /** Session-wide, all branches, sequence order. */ findEntries(query?: EntryQuery): Promise; findEntry(query?: EntryQuery): Promise; + + /** Branch-scoped: the path from start toward root. */ findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise; findEntryOnBranch(query?: EntryQuery & BranchBounds): Promise; + + // Writes. Resolve on durable acceptance; the returned id is the entry's + // id (provisioned when the write defers). appendMessage(message: AgentMessage): Promise; appendCustomEntry(customType: string, data?: unknown): Promise; } diff --git a/packages/agent/src/harness/skills.ts b/packages/agent/src/harness/skills.ts index 0c0b54330d3..44fd828ed7b 100644 --- a/packages/agent/src/harness/skills.ts +++ b/packages/agent/src/harness/skills.ts @@ -43,8 +43,9 @@ export function formatSkillInvocation(skill: Skill, additionalInstructions?: str /** * Load skills from one or more directories. * - * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files, - * and returns diagnostics for invalid skill files. Missing input directories are skipped. + * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files with skill + * frontmatter, honors ignore files, and returns diagnostics for invalid declared skill files. Missing input + * directories are skipped. */ export async function loadSkills( env: ExecutionEnv, @@ -246,6 +247,11 @@ async function loadSkillFromFile( parentDirName: string, ): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { const diagnostics: SkillDiagnostic[] = []; + const isDeclaredSkill = + filePath + .replace(/[\\/]+$/, "") + .split(/[\\/]/) + .pop() === "SKILL.md"; const rawContent = await env.readTextFile(filePath); if (!rawContent.ok) { diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath }); @@ -254,12 +260,17 @@ async function loadSkillFromFile( const parsed = parseFrontmatter(rawContent.value); if (!parsed.ok) { - diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + if (isDeclaredSkill) { + diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + } return { skill: null, diagnostics }; } const { frontmatter, body } = parsed.value; const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; + if (!isDeclaredSkill && (!description || description.trim() === "")) { + return { skill: null, diagnostics }; + } for (const error of validateDescription(description)) { diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); diff --git a/packages/agent/src/harness/tools/edit.ts b/packages/agent/src/harness/tools/edit.ts index c8210ad7018..5473c48b8a0 100644 --- a/packages/agent/src/harness/tools/edit.ts +++ b/packages/agent/src/harness/tools/edit.ts @@ -38,6 +38,13 @@ const editSchema = Type.Object( export type EditToolInput = Static; type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown }; +type SingleEditInput = { oldText: string; newText: string }; + +function isSingleEditInput(value: unknown): value is SingleEditInput { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const edit = value as Record; + return typeof edit.oldText === "string" && typeof edit.newText === "string"; +} export interface EditToolDetails { diff: string; @@ -51,8 +58,14 @@ function prepareEditArguments(input: unknown): EditToolInput { if (typeof args.edits === "string") { try { const parsed: unknown = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; + if (Array.isArray(parsed)) { + args.edits = parsed; + } else if (isSingleEditInput(parsed)) { + args.edits = [parsed]; + } } catch {} + } else if (isSingleEditInput(args.edits)) { + args.edits = [args.edits]; } const legacy = args as LegacyEditToolInput; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index b1315b68b67..109cb05008d 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -78,7 +78,6 @@ export * from "./harness/prompt-templates.ts"; // Harness export * from "./harness/result.ts"; export * from "./harness/session/index.ts"; -export * from "./harness/session/search.ts"; export * from "./harness/skills.ts"; export * from "./harness/system-prompt.ts"; export type { @@ -139,6 +138,7 @@ export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/truncate.ts"; // Proxy utilities export * from "./proxy.ts"; +export * from "./search/index.ts"; // Stream defaults export { setDefaultStreamFn } from "./stream-fn.ts"; // Types diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 2e4cc8f4876..678bc7abfe7 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -43,7 +43,7 @@ export type ProxyAssistantMessageEvent = | { type: "thinking_end"; contentIndex: number; contentSignature?: string } | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } | { type: "toolcall_delta"; contentIndex: number; delta: string } - | { type: "toolcall_end"; contentIndex: number } + | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall } | { type: "done"; reason: Extract; @@ -338,6 +338,7 @@ function processProxyEvent( case "toolcall_end": { const content = partial.content[proxyEvent.contentIndex]; if (content?.type === "toolCall") { + Object.assign(content, proxyEvent.toolCall); delete (content as any).partialJson; return { type: "toolcall_end", diff --git a/packages/agent/src/search/index.ts b/packages/agent/src/search/index.ts new file mode 100644 index 00000000000..73584789545 --- /dev/null +++ b/packages/agent/src/search/index.ts @@ -0,0 +1,32 @@ +import type { Entry } from "../harness/session/types.ts"; + +export type { + ScanningReadable, + ScanningReadableOptions, + ScanningReadableSource, + ScanningSearchTextProjector, + ScanningSessionSearchHit, + ScanningSessionSearchOptions, + SessionSearchCandidate, +} from "./scanning.ts"; +export { createScanningSessionSearch, scanningEntries } from "./scanning.ts"; + +export interface SessionSearchOptions { + /** Restrict results to specific canonical entry types. */ + readonly entryTypes?: readonly Entry["type"][]; + /** Maximum number of hits to return. */ + readonly limit?: number; + /** Abort signal for cancellation, e.g. search-as-you-type. */ + readonly signal?: AbortSignal; +} + +export interface SessionSearchHit { + /** Logical identifier of the session that owns the entry. */ + readonly sessionId: string; + /** Logical identifier of the entry within that session. */ + readonly entryId: string; +} + +export interface SessionSearch { + search(text: string, options?: SessionSearchOptions): AsyncIterable; +} diff --git a/packages/agent/src/search/scanning.ts b/packages/agent/src/search/scanning.ts new file mode 100644 index 00000000000..14e6bfbf26c --- /dev/null +++ b/packages/agent/src/search/scanning.ts @@ -0,0 +1,176 @@ +import type { Entry, SessionMetadata, SessionStorage } from "../harness/session/types.ts"; +import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "./index.ts"; + +export interface SessionSearchCandidate { + readonly entryId: string; + readonly seq: number; + readonly type: Entry["type"]; + readonly timestamp: number; + readonly text: string; + readonly fields?: Record; +} + +export type ScanningReadable = Pick< + SessionStorage, + "getMetadata" | "findEntries" | "getLabel" +>; + +export type ScanningReadableSource = ( + options?: TOptions, +) => AsyncIterable>; + +export type ScanningSearchTextProjector = ( + metadata: TMetadata, + entry: Entry, + label: string | undefined, +) => string; + +export interface ScanningReadableOptions { + projectText?: ScanningSearchTextProjector; + pageSize?: number; +} + +export interface ScanningSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; +} + +export interface ScanningSessionSearchOptions< + TMetadata extends SessionMetadata = SessionMetadata, + TSourceOptions = unknown, + THit extends SessionSearchHit = ScanningSessionSearchHit, +> extends ScanningReadableOptions { + sourceOptions?: (text: string, options: SessionSearchOptions) => TSourceOptions | undefined; + match?: (queryText: string, candidate: SessionSearchCandidate, metadata: TMetadata) => boolean; + createHit?: (metadata: TMetadata, candidate: SessionSearchCandidate) => THit; +} + +function defaultSearchText( + _metadata: TMetadata, + entry: Entry, + label: string | undefined, +): string { + return label === undefined ? JSON.stringify(entry) : `${JSON.stringify(entry)} ${label}`; +} + +async function* scanReadableEntries( + readable: ScanningReadable, + metadata: TMetadata, + options: ScanningReadableOptions, + query: { afterSeq?: number; limit?: number; entryTypes?: readonly Entry["type"][] } = {}, +): AsyncIterable { + const projectText = options.projectText ?? defaultSearchText; + const pageSize = query.limit ?? options.pageSize ?? 100; + let afterSeq = query.afterSeq ?? 0; + const entryTypes = query.entryTypes === undefined ? undefined : new Set(query.entryTypes); + while (true) { + const entries = await readable.findEntries({ + order: "oldestFirst", + limit: pageSize, + cursor: { afterSeq }, + type: query.entryTypes?.length === 1 ? query.entryTypes[0] : undefined, + }); + if (entries.length === 0) break; + for (const entry of entries) { + if (entryTypes !== undefined && !entryTypes.has(entry.type)) continue; + const label = await readable.getLabel(entry.id); + yield { + entryId: entry.id, + seq: entry.seq, + type: entry.type, + timestamp: entry.timestamp, + text: projectText(metadata, entry, label), + fields: label === undefined ? undefined : { label }, + }; + } + afterSeq = entries[entries.length - 1]?.seq ?? afterSeq; + if (entries.length < pageSize) break; + } +} + +export async function* scanningEntries( + readable: ScanningReadable, + options: ScanningReadableOptions = {}, +): AsyncIterable { + yield* scanReadableEntries(readable, await readable.getMetadata(), options); +} + +async function* arraySource( + readables: readonly ScanningReadable[], +): AsyncIterable> { + yield* readables; +} + +function readablesFor( + source: readonly ScanningReadable[] | ScanningReadableSource, + options: TSourceOptions | undefined, +): AsyncIterable> { + return typeof source === "function" ? source(options) : arraySource(source); +} + +function defaultMatch(queryText: string, candidate: SessionSearchCandidate): boolean { + return candidate.text.toLowerCase().includes(queryText); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + throw error; +} + +function createDefaultScanningHit( + metadata: TMetadata, + candidate: SessionSearchCandidate, +): ScanningSessionSearchHit { + return { + sessionId: metadata.id, + entryId: candidate.entryId, + timestamp: candidate.timestamp, + snippet: candidate.text, + }; +} + +export function createScanningSessionSearch< + TMetadata extends SessionMetadata, + TSourceOptions = unknown, + THit extends SessionSearchHit = ScanningSessionSearchHit, +>( + source: readonly ScanningReadable[] | ScanningReadableSource, + options: ScanningSessionSearchOptions = {}, +): SessionSearch { + const createHit = + options.createHit ?? + ((metadata: TMetadata, candidate: SessionSearchCandidate) => + createDefaultScanningHit(metadata, candidate) as unknown as THit); + return { + async *search(text: string, searchOptions: SessionSearchOptions = {}): AsyncIterable { + const normalizedText = text.trim().toLowerCase(); + if (!normalizedText || (searchOptions.limit !== undefined && searchOptions.limit <= 0)) return; + if (searchOptions.entryTypes?.length === 0) return; + let hitCount = 0; + const seenSessionIds = new Set(); + const entryTypes = searchOptions.entryTypes === undefined ? undefined : new Set(searchOptions.entryTypes); + const sourceOptions = options.sourceOptions?.(normalizedText, searchOptions); + for await (const readable of readablesFor(source, sourceOptions)) { + throwIfAborted(searchOptions.signal); + const metadata = await readable.getMetadata(); + if (seenSessionIds.has(metadata.id)) throw new Error(`Duplicate sessionId: ${metadata.id}`); + seenSessionIds.add(metadata.id); + for await (const candidate of scanReadableEntries(readable, metadata, options, { + entryTypes: searchOptions.entryTypes, + })) { + throwIfAborted(searchOptions.signal); + if (entryTypes !== undefined && !entryTypes.has(candidate.type)) continue; + const matches = + options.match?.(normalizedText, candidate, metadata) ?? defaultMatch(normalizedText, candidate); + if (!matches) continue; + yield createHit(metadata, candidate); + hitCount += 1; + if (searchOptions.limit !== undefined && hitCount >= searchOptions.limit) return; + } + } + }, + }; +} diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 52f34891a7e..5b20b21f4ea 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -61,6 +61,11 @@ export type AgentToolCall = Extract Promise; diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 8a2a7388dda..e5b2f1db866 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1250,6 +1250,124 @@ describe("agentLoop with AgentMessage", () => { expect(events.filter((event) => event.type === "turn_end")).toHaveLength(1); }); + it("should stop after a blocked tool call when beforeToolCall sets terminate=true", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + let executed = false; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute() { + executed = true; + return { + content: [{ type: "text", text: "should not execute" }], + details: { value: "unexpected" }, + }; + }, + }; + const context: AgentContext = { + systemPrompt: "", + messages: [], + tools: [tool], + }; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + beforeToolCall: async () => ({ block: true, reason: "Blocked by policy", terminate: true }), + }; + + let llmCalls = 0; + const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, () => { + llmCalls++; + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + const message = + llmCalls === 1 + ? createAssistantMessage( + [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }], + "toolUse", + ) + : createAssistantMessage([{ type: "text", text: "should not run" }]); + mockStream.push({ type: "done", reason: llmCalls === 1 ? "toolUse" : "stop", message }); + }); + return mockStream; + }); + + for await (const _event of stream) { + // consume + } + + const messages = await stream.result(); + const toolResult = messages.find((message) => message.role === "toolResult"); + expect(executed).toBe(false); + expect(llmCalls).toBe(1); + expect(toolResult?.role === "toolResult" ? toolResult.isError : false).toBe(true); + expect(toolResult?.role === "toolResult" ? toolResult.content : []).toContainEqual({ + type: "text", + text: "Blocked by policy", + }); + }); + + it("should continue after a mixed batch with one terminating blocked call", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + const executed: string[] = []; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.value); + return { + content: [{ type: "text", text: `echoed: ${params.value}` }], + details: { value: params.value }, + }; + }, + }; + const context: AgentContext = { + systemPrompt: "", + messages: [], + tools: [tool], + }; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + toolExecution: "parallel", + beforeToolCall: async ({ args }) => { + const { value } = args as { value: string }; + return value === "first" ? { block: true, reason: "Blocked first", terminate: true } : undefined; + }, + }; + + let llmCalls = 0; + const stream = agentLoop([createUserMessage("echo both")], context, config, undefined, () => { + llmCalls++; + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + const message = + llmCalls === 1 + ? createAssistantMessage( + [ + { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } }, + { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } }, + ], + "toolUse", + ) + : createAssistantMessage([{ type: "text", text: "done" }]); + mockStream.push({ type: "done", reason: llmCalls === 1 ? "toolUse" : "stop", message }); + }); + return mockStream; + }); + + for await (const _event of stream) { + // consume + } + + expect(executed).toEqual(["second"]); + expect(llmCalls).toBe(2); + }); + it("should continue after parallel tool calls when not all tool results terminate", async () => { const toolSchema = Type.Object({ value: Type.String() }); const tool: AgentTool = { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index e3fd9f00d84..672c4a6789c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -505,6 +505,40 @@ describe("Agent", () => { expect(() => agent.abort()).not.toThrow(); }); + it("should reject reset while processing without corrupting the transcript", async () => { + const streamStarted = createDeferred(); + const releaseResponse = createDeferred(); + const agent = new Agent({ + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(async () => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + streamStarted.resolve(); + await releaseResponse.promise; + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") }); + }); + return stream; + }, + }); + + const promptPromise = agent.prompt("Hello"); + await streamStarted.promise; + + try { + expect(agent.state.isStreaming).toBe(true); + expect(agent.state.messages.map((message) => message.role)).toEqual(["user"]); + expect(() => agent.reset()).toThrow("Agent is already processing. Wait for completion before resetting."); + expect(agent.state.isStreaming).toBe(true); + expect(agent.state.messages.map((message) => message.role)).toEqual(["user"]); + } finally { + releaseResponse.resolve(); + await promptPromise; + } + + expect(agent.state.isStreaming).toBe(false); + expect(agent.state.messages.map((message) => message.role)).toEqual(["user", "assistant"]); + }); + it("should throw when prompt() called while streaming", async () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ diff --git a/packages/agent/test/harness/events.test.ts b/packages/agent/test/harness/events.test.ts new file mode 100644 index 00000000000..84cb5e52e64 --- /dev/null +++ b/packages/agent/test/harness/events.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { type HarnessEvent, HarnessEventBus, type RunEndEvent, type RunStartEvent } from "../../src/harness/events.ts"; + +const runStartEvent: RunStartEvent = { + type: "run_start", + lane: "main", + runId: "run-1", +}; + +const runEndEvent: RunEndEvent = { + type: "run_end", + lane: "main", + runId: "run-1", + outcome: "completed", + leafId: "entry-1", +}; + +describe("HarnessEventBus", () => { + it("delivers matching events to direct listeners and watchers", () => { + const events = new HarnessEventBus(); + const direct: RunStartEvent[] = []; + const watchEvents: HarnessEvent[] = []; + const off = events.on("run_start", (event) => { + direct.push(event); + }); + const watch = events.watch(() => null); + watch.start((event) => { + watchEvents.push(event); + }); + + events.emit(runStartEvent); + events.emit(runEndEvent); + off(); + events.emit(runStartEvent); + + expect(direct).toEqual([runStartEvent]); + expect(watchEvents).toEqual([runStartEvent, runEndEvent, runStartEvent]); + }); + + it("captures a snapshot without an event gap, then flushes and delivers live events", () => { + const events = new HarnessEventBus(); + const expectedSnapshot = { leafId: null }; + const watch = events.watch(() => { + const snapshot = expectedSnapshot; + events.emit(runStartEvent); + return snapshot; + }); + const received: HarnessEvent[] = []; + + expect(watch.snapshot).toBe(expectedSnapshot); + expect(received).toEqual([]); + + watch.start((event) => { + received.push(event); + }); + expect(received).toEqual([runStartEvent]); + + events.emit(runEndEvent); + expect(received).toEqual([runStartEvent, runEndEvent]); + + watch.unsubscribe(); + events.emit(runStartEvent); + expect(received).toEqual([runStartEvent, runEndEvent]); + }); +}); diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index 09b706516fd..becaa8fc3b4 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -289,6 +289,37 @@ describe("NodeExecutionEnv", () => { expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); }); + it.each([ + ["a missing override preserves the base value", undefined, "x:/stale/parent.jsonl"], + ["an empty override shadows the base value", { PI_SESSION_FILE: "" }, "x:"], + [ + "a string override replaces the base value", + { PI_SESSION_FILE: "/sessions/current.jsonl" }, + "x:/sessions/current.jsonl", + ], + ] as const)( + "applies string shell environment overrides when %s", + async (_description, overrides, expectedSessionFile) => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ + cwd: root, + shellEnv: { + PI_SESSION_FILE: "/stale/parent.jsonl", + PI_CODING_AGENT: "true", + PI_NODE_ENV_PRESERVED_TEST: "preserved", + }, + }); + const result = getOrThrow( + await env.exec( + `printf '%s:%s|%s|%s' "\${PI_SESSION_FILE+x}" "\${PI_SESSION_FILE-}" "$PI_CODING_AGENT" "$PI_NODE_ENV_PRESERVED_TEST"`, + { env: overrides }, + ), + ); + + expect(result.stdout).toBe(`${expectedSessionFile}|true|preserved`); + }, + ); + it("can replace rather than inherit the default shell environment", async () => { const root = createTempDir(); const inheritedKey = "PI_NODE_ENV_INHERITED_TEST"; diff --git a/packages/agent/test/harness/session/jsonl-codec.test.ts b/packages/agent/test/harness/session/jsonl-codec.test.ts index e06473cc6ef..b8176755017 100644 --- a/packages/agent/test/harness/session/jsonl-codec.test.ts +++ b/packages/agent/test/harness/session/jsonl-codec.test.ts @@ -6,19 +6,20 @@ import { parseHeader, parseMutation, } from "../../../src/harness/session/jsonl/codec.ts"; +import { JsonlDecodeError } from "../../../src/harness/session/jsonl/errors.ts"; import type { JsonlV4Header } from "../../../src/harness/session/jsonl/types.ts"; import type { SessionMutation } from "../../../src/harness/session/state.ts"; function expectHeaderRoundTrip(header: JsonlV4Header): void { const encoded = encodeHeader(header); expect(encoded.endsWith("\n")).toBe(true); - expect(parseHeader(encoded.trimEnd(), "/sessions/example.jsonl")).toEqual(header); + expect(parseHeader(encoded.trimEnd())).toEqual({ ok: true, value: header }); } function expectMutationRoundTrip(mutation: SessionMutation): void { const encoded = encodeMutation(mutation); expect(encoded.endsWith("\n")).toBe(true); - expect(parseMutation(encoded.trimEnd(), "/sessions/example.jsonl", 2)).toEqual(mutation); + expect(parseMutation(encoded.trimEnd())).toEqual({ ok: true, value: mutation }); } describe("JSONL v4 codec", () => { @@ -71,6 +72,19 @@ describe("JSONL v4 codec", () => { }); describe("mutation lines", () => { + it("returns syntax and schema errors", () => { + for (const [line, kind] of [ + ["{", "syntax"], + [JSON.stringify({ kind: "unknown", seq: 1 }), "schema"], + ] as const) { + const result = parseMutation(line); + expect(result.ok).toBe(false); + if (result.ok) throw new Error(`Expected ${kind} decode error`); + expect(result.error).toBeInstanceOf(JsonlDecodeError); + expect(result.error).toMatchObject({ kind }); + } + }); + it("round trips a lane-bound entry line", () => { expectMutationRoundTrip({ kind: "entry", @@ -120,11 +134,12 @@ describe("JSONL v4 codec", () => { expectMutationRoundTrip({ kind: "lane", seq: 1, lane: "thread", leafId: "entry-1" }); }); - it("round trips both fact line discriminants", () => { + it("round trips fact lines, including cleared values", () => { expectMutationRoundTrip({ kind: "fact", seq: 1, fact: "name", name: "Example" }); + expectMutationRoundTrip({ kind: "fact", seq: 2, fact: "name", name: undefined }); expectMutationRoundTrip({ kind: "fact", - seq: 2, + seq: 3, fact: "label", targetId: "entry-1", label: "checkpoint", @@ -161,7 +176,7 @@ describe("JSONL v4 codec", () => { }, }, ])("rejects $name", ({ mutation }) => { - expect(() => parseMutation(JSON.stringify(mutation), "/sessions/example.jsonl", 2)).toThrow(); + expect(parseMutation(JSON.stringify(mutation))).toMatchObject({ ok: false }); }); }); }); diff --git a/packages/agent/test/harness/session/jsonl.test.ts b/packages/agent/test/harness/session/jsonl.test.ts index e5fcffea7d6..6b3a7671a94 100644 --- a/packages/agent/test/harness/session/jsonl.test.ts +++ b/packages/agent/test/harness/session/jsonl.test.ts @@ -35,7 +35,7 @@ function createRepository(root: string): JsonlSessionRepo { }); } -function withDefaultSessionCwd(repository: SessionRepo, cwd: string): SessionRepo { +function withDefaultSessionCwd(repository: JsonlSessionRepo, cwd: string): SessionRepo { return { create(options) { const optionsWithCwd = { ...options, cwd }; @@ -122,6 +122,41 @@ describe("JSONL v4 persistence", () => { expect(await repository.list({ cwd: join(root, "other", "project") })).toEqual([]); }); + it("rejects a malformed JSON header on open and skips it when listing", async () => { + const root = createTempDir(); + const repository = createRepository(root); + await repository.create({ id: "valid", cwd: root }); + const session = await repository.create({ id: "malformed-header", cwd: root }); + const metadata = await session.getMetadata(); + const malformed = "not json\n"; + writeFileSync(metadata.path, malformed); + + await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); + expect((await repository.list({ cwd: root })).map((listed) => listed.id)).toEqual(["valid"]); + expect(readFileSync(metadata.path, "utf8")).toBe(malformed); + }); + + it("rejects non-object header metadata on open and skips it when listing", async () => { + const root = createTempDir(); + const repository = createRepository(root); + await repository.create({ id: "valid", cwd: root }); + const session = await repository.create({ id: "invalid-header-metadata", cwd: root }); + const metadata = await session.getMetadata(); + const malformed = `${JSON.stringify({ + kind: "header", + version: 4, + id: metadata.id, + createdAt: metadata.createdAt, + cwd: metadata.cwd, + metadata: "invalid", + })}\n`; + writeFileSync(metadata.path, malformed); + + await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); + expect((await repository.list({ cwd: root })).map((listed) => listed.id)).toEqual(["valid"]); + expect(readFileSync(metadata.path, "utf8")).toBe(malformed); + }); + it("rejects session ids that cannot be used in coding-agent filenames", async () => { const root = createTempDir(); const repository = createRepository(root); @@ -145,6 +180,66 @@ describe("JSONL v4 persistence", () => { expect((await repository.list()).map((metadata) => metadata.id)).toEqual(["shared", "shared"]); }); + it.each([ + ["create", "create"], + ["create", "fork"], + ["fork", "fork"], + ] as const)("rejects concurrent %s and %s calls for the same destination", async (firstKind, secondKind) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + try { + const root = createTempDir(); + const repository = createRepository(root); + const cwd = join(root, "workspace"); + const source = await repository.create({ id: "source", cwd }); + const sourceMetadata = await source.getMetadata(); + const run = (kind: "create" | "fork") => + kind === "create" + ? repository.create({ id: "same", cwd }) + : repository.fork(sourceMetadata, { id: "same", cwd }); + + const results = await Promise.allSettled([run(firstKind), run(secondKind)]); + const successes = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ code: "already_exists" }); + expect((await repository.list({ cwd })).filter((listed) => listed.id === "same")).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it.each(["create", "fork"] as const)("releases a destination reservation after a failed %s", async (kind) => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const repository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); + const cwd = join(root, "workspace"); + const source = await repository.create({ id: "source", cwd }); + const sourceMetadata = await source.getMetadata(); + const run = () => + kind === "create" + ? repository.create({ id: "retry", cwd }) + : repository.fork(sourceMetadata, { id: "retry", cwd }); + + if (kind === "create") { + vi.spyOn(env, "writeFile").mockResolvedValueOnce({ + ok: false, + error: new FileError("unknown", "injected creation failure"), + }); + } else { + vi.spyOn(env, "renameFile").mockResolvedValueOnce({ + ok: false, + error: new FileError("unknown", "injected fork failure"), + }); + } + + await expect(run()).rejects.toMatchObject({ code: "storage" }); + await expect(run()).resolves.toBeDefined(); + expect((await repository.list({ cwd })).filter((listed) => listed.id === "retry")).toHaveLength(1); + }); + it("sorts listed sessions by current filesystem modification time", async () => { const root = createTempDir(); const repository = createRepository(root); @@ -391,6 +486,15 @@ describe("JSONL v4 persistence", () => { expect((await reopened.getEntry(appendedId))?.seq).toBe(2); }); + it("rejects a complete invalid final mutation without modifying the file", async () => { + const root = createTempDir(); + const metadata = writeRawSession(root, "invalid-final-mutation", [{ kind: "unknown", seq: 1 }]); + const corrupted = readFileSync(metadata.path, "utf8"); + + await expect(createRepository(root).open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); + expect(readFileSync(metadata.path, "utf8")).toBe(corrupted); + }); + it("rejects a malformed middle line without modifying the file", async () => { const root = createTempDir(); const repository = createRepository(root); @@ -433,7 +537,7 @@ describe("JSONL v4 persistence", () => { const repository = createRepository(root); await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry", - message: expect.stringContaining("references missing parent missing"), + message: `Invalid JSONL v4 session ${path}: line 2 Invalid session mutation: references missing parent missing`, }); }); @@ -638,4 +742,25 @@ describe("JSONL v4 persistence", () => { expect(readFileSync(metadata.path, "utf8")).toBe(original); expect(existsSync(`${metadata.path}.tmp`)).toBe(false); }); + + it("preserves the session when torn-tail repair cannot be published", async () => { + const root = createTempDir(); + const repository = createRepository(root); + const session = await repository.create({ id: "repair-rename-failure", cwd: root }); + const metadata = await session.getMetadata(); + await session.appendCustomEntry("kept"); + appendFileSync(metadata.path, '{"kind":"entry"'); + const original = readFileSync(metadata.path, "utf8"); + + const env = new NodeExecutionEnv({ cwd: root }); + vi.spyOn(env, "renameFile").mockResolvedValueOnce({ + ok: false, + error: new FileError("unknown", "injected repair rename failure"), + }); + const failingRepository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); + + await expect(failingRepository.open(metadata)).rejects.toMatchObject({ code: "storage" }); + expect(readFileSync(metadata.path, "utf8")).toBe(original); + expect(existsSync(`${metadata.path}.tmp`)).toBe(false); + }); }); diff --git a/packages/agent/test/harness/session/search.test.ts b/packages/agent/test/harness/session/search.test.ts new file mode 100644 index 00000000000..883f583964d --- /dev/null +++ b/packages/agent/test/harness/session/search.test.ts @@ -0,0 +1,125 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; +import { + InMemorySessionStorage, + type JsonlSessionListOptions, + JsonlSessionRepo, + type JsonlSessionRepoOptions, + Session, + type SessionMetadata, + type SessionStorage, +} from "../../../src/harness/session/index.ts"; +import { listJsonlSessionMetadata, loadJsonlSessionStorage } from "../../../src/harness/session/jsonl/repo.ts"; +import { createScanningSessionSearch } from "../../../src/search/index.ts"; +import type { AgentMessage } from "../../../src/types.ts"; + +interface WorkspaceMetadata extends SessionMetadata { + cwd: string; +} + +const tempDirs: string[] = []; + +function createTempDir(): string { + const directory = mkdtempSync(join(tmpdir(), "pi-agent-search-")); + tempDirs.push(directory); + return directory; +} + +afterEach(() => { + while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); +}); + +function message(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +function createMemorySession(metadata: WorkspaceMetadata): Session { + return new Session( + new InMemorySessionStorage(metadata) as unknown as SessionStorage, + ); +} + +async function collect(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) items.push(item); + return items; +} + +async function* jsonlReadables(options: JsonlSessionRepoOptions, query: JsonlSessionListOptions = {}) { + for (const metadata of await listJsonlSessionMetadata(options, query)) { + yield loadJsonlSessionStorage(options, metadata); + } +} + +describe("session search", () => { + it("scans an arbitrary in-memory projected source", async () => { + const root = createMemorySession({ id: "root", createdAt: 1, cwd: "/repo" }); + await root.appendMessage(message("fix auth flow")); + const other = createMemorySession({ id: "other", createdAt: 2, cwd: "/other" }); + await other.appendMessage(message("auth in another workspace")); + const search = createScanningSessionSearch([root, other]); + + expect("apply" in search).toBe(false); + expect(await collect(search.search("auth"))).toMatchObject([{ sessionId: "root" }, { sessionId: "other" }]); + expect(await collect(search.search("missing"))).toEqual([]); + }); + + it("includes labels in memory scanning projections", async () => { + const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); + const entryId = await session.appendMessage(message("plain body")); + await session.setLabel(entryId, "important label"); + const search = createScanningSessionSearch([session]); + + expect(await collect(search.search("important"))).toMatchObject([{ sessionId: "session", entryId }]); + }); + + it("honors entry type filters and abort signals in scanning search", async () => { + const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); + const messageEntryId = await session.appendMessage(message("auth message")); + await session.appendCustomEntry("note", { text: "auth custom" }); + const search = createScanningSessionSearch([session]); + + expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toMatchObject([ + { sessionId: "session", entryId: messageEntryId }, + ]); + + const controller = new AbortController(); + controller.abort(); + await expect(collect(search.search("auth", { signal: controller.signal }))).rejects.toMatchObject({ + name: "AbortError", + }); + }); + + it("scans JSONL sessions from disk through the JSONL scanning source", async () => { + const root = createTempDir(); + const options = { fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }; + const repository = new JsonlSessionRepo(options); + const cwd = join(root, "workspace"); + const otherCwd = join(root, "other"); + const session = await repository.create({ id: "jsonl", cwd }); + const entryId = await session.appendMessage(message("jsonl backed auth entry")); + await session.setLabel(entryId, "disk label"); + const other = await repository.create({ id: "other", cwd: otherCwd }); + const otherEntryId = await other.appendMessage(message("jsonl backed auth entry in another cwd")); + const search = createScanningSessionSearch((query?: JsonlSessionListOptions) => jsonlReadables(options, query)); + + const authHits = await collect(search.search("auth")); + expect(authHits).toHaveLength(2); + expect(authHits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "jsonl", + entryId, + }), + expect.objectContaining({ + sessionId: "other", + entryId: otherEntryId, + }), + ]), + ); + expect(await collect(search.search("disk"))).toMatchObject([{ sessionId: "jsonl", entryId }]); + }); +}); diff --git a/packages/agent/test/harness/skills.test.ts b/packages/agent/test/harness/skills.test.ts index bd769ea9168..88c1559480f 100644 --- a/packages/agent/test/harness/skills.test.ts +++ b/packages/agent/test/harness/skills.test.ts @@ -113,4 +113,23 @@ Use this skill. expect(skills.map((skill) => skill.name)).toEqual(["skills"]); expect(skills[0]?.content).toBe("Root content"); }); + + it("ignores root markdown docs that do not declare skills", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await env.createDir("skills/nested-skill", { recursive: true }); + await env.writeFile("skills/README.md", "# Shared skills\n\nDocumentation."); + await env.writeFile("skills/AGENTS.md", "# Agent notes\n\nDocumentation."); + await env.writeFile("skills/CLAUDE.md", "---\ndescription: [invalid\n---\n\nDocumentation."); + await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content"); + await env.writeFile( + "skills/nested-skill/SKILL.md", + "---\nname: nested-skill\ndescription: Nested skill\n---\nNested content", + ); + + const { skills, diagnostics } = await loadSkills(env, "skills"); + + expect(diagnostics).toEqual([]); + expect(skills.map((skill) => skill.name).sort()).toEqual(["nested-skill", "skills"]); + }); }); diff --git a/packages/agent/test/proxy.test.ts b/packages/agent/test/proxy.test.ts new file mode 100644 index 00000000000..05f59b11f09 --- /dev/null +++ b/packages/agent/test/proxy.test.ts @@ -0,0 +1,79 @@ +import type { AssistantMessage, AssistantMessageEvent, Model } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type ProxyAssistantMessageEvent, streamProxy } from "../src/proxy.ts"; + +const model: Model<"openai-responses"> = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, +}; + +const usage: AssistantMessage["usage"] = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("streamProxy", () => { + it("preserves tool-call metadata received only on toolcall_end", async () => { + const proxyEvents: ProxyAssistantMessageEvent[] = [ + { type: "start" }, + { type: "toolcall_start", contentIndex: 0, id: "call_test|fc_test", toolName: "lookup" }, + { type: "toolcall_delta", contentIndex: 0, delta: '{"value":"hello"}' }, + { + type: "toolcall_end", + contentIndex: 0, + toolCall: { + type: "toolCall", + id: "call_test|fc_test", + name: "lookup", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }, + }, + { type: "done", reason: "toolUse", usage }, + ]; + const body = proxyEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(body, { status: 200 })), + ); + + const stream = streamProxy( + model, + { systemPrompt: "", messages: [] }, + { + authToken: "test-token", + proxyUrl: "https://proxy.example.com", + }, + ); + const events: AssistantMessageEvent[] = []; + for await (const event of stream) events.push(event); + const result = await stream.result(); + const endEvent = events.find((event) => event.type === "toolcall_end"); + + expect(endEvent).toMatchObject({ + type: "toolcall_end", + toolCall: { namespace: "dynamic_tools" }, + }); + expect(result.content[0]).toMatchObject({ + type: "toolCall", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }); + }); +}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 92840d74694..6cc3e74da6f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,70 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +### Breaking Changes + +- Renamed `GoogleThinkingLevel` to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels. + +### Added + +- Added provider-neutral `toolChoice` support to simple stream requests. +- Added automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). +- Added configurable OpenAI-compatible thinking-token budget fields for vLLM, Qwen/SGLang, and llama.cpp servers ([#8275](https://github.com/earendil-works/pi/pull/8275) by [@bnsd55](https://github.com/bnsd55)). +- Added China-specific ZAI Coding Plan models, including GLM-4.6V vision support, and API-equivalent usage cost estimates for models with published PAYG prices ([#8220](https://github.com/earendil-works/pi/issues/8220)). +- Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). + +### Changed + +- Changed built-in xAI models to use the Responses API with encrypted reasoning replay and made Grok 4.6 the default xAI model ([#8124](https://github.com/earendil-works/pi/pull/8124) by [@Jaaneek](https://github.com/Jaaneek)). +- Changed the Anthropic, Azure OpenAI, Google Generative AI, Google Vertex, Mistral, OpenAI Chat Completions, and OpenAI Responses adapters to send Pi's default `User-Agent` unless overridden ([#8305](https://github.com/earendil-works/pi/issues/8305)). + +### Fixed + +- Fixed OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` (`reasoning.text`, `reasoning.summary`, and `reasoning.encrypted`) verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). +- Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). +- Fixed GitHub Copilot login triggering model-policy rate limits by limiting policy updates, retrying model discovery once, and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). +- Fixed Amazon Bedrock dropping and failing to replay opaque redacted reasoning from non-Anthropic models ([#8314](https://github.com/earendil-works/pi/pull/8314) by [@seiji](https://github.com/seiji)). +- Fixed Z.AI Coding Plan models deriving incomplete reasoning-effort metadata, including missing GLM-5.3 low, high, and max levels ([#8336](https://github.com/earendil-works/pi/issues/8336)). +- Fixed DeepSeek V4 Flash on OpenCode and OpenCode Go omitting its supported low thinking level ([#8181](https://github.com/earendil-works/pi/pull/8181) by [@tianshuang](https://github.com/tianshuang)). +- Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. +- Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). +- Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). +- Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). +- Fixed Xiaomi model catalog generation retaining shut-down MiMo V2 model names after models.dev marked them deprecated ([#8187](https://github.com/earendil-works/pi/issues/8187)). + +## [0.84.2] - 2026-08-14 + +### Added + +- Added `createGatewayBindingFetch()` for routing Cloudflare AI Gateway requests through a Workers AI binding without an API token ([#7901](https://github.com/earendil-works/pi/pull/7901) by [@Maximo-Guk](https://github.com/Maximo-Guk)). +- Added `AssistantMessage.endTurn` to preserve OpenAI Codex's terminal `end_turn` signal for diagnostics ([#7766](https://github.com/earendil-works/pi/pull/7766)). + +### Changed + +- Changed Kimi Coding requests to use pi's runtime `User-Agent` header. +- Automatically converted supported strict tool schemas to provider-compatible closed objects with required nullable optional fields while preserving original tool definitions, and treated `null` values for optional non-nullable tool arguments as omitted. +- Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Replaced the Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. + +### Fixed + +- Fixed GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). +- Fixed GitHub Copilot login still triggering API rate limits by updating only account models with unconfigured policies and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). +- Fixed upstream request buffer limit failures to trigger automatic assistant retries. +- Fixed OpenAI Responses function and custom tool calls to preserve namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Fixed built-in and custom DeepSeek API models to send output limits through the supported `max_tokens` field. +- Fixed Google Generative AI and Vertex AI responses with tool calls incorrectly treating output-limit or provider-error stops as normal tool use ([#8059](https://github.com/earendil-works/pi/issues/8059)). +- Fixed Amazon Bedrock replay rejecting tool arguments that contain empty object keys while preserving all valid nested values ([#7882](https://github.com/earendil-works/pi/pull/7882) by [@muyiyr](https://github.com/muyiyr)). +- Fixed DeepSeek compatibility detection for base URLs whose hostname contains uppercase letters ([#7933](https://github.com/earendil-works/pi/pull/7933) by [@yearth](https://github.com/yearth)). + +## [0.84.1] - 2026-08-07 + +### Added + +- Added Qwen Token Plan Individual as a built-in provider with its documented subscription model catalog and the shared international `QWEN_TOKEN_PLAN_API_KEY` ([#7659](https://github.com/earendil-works/pi/pull/7659) by [@arasovic](https://github.com/arasovic)). + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/ai/README.md b/packages/ai/README.md index 48bee35867f..dde9c83b48f 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -85,6 +85,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and - **OpenCode Go** - **Fireworks** (uses OpenAI- and Anthropic-compatible APIs) - **Kimi For Coding** (Moonshot AI subscription endpoint, uses Anthropic-compatible API) +- **Qwen Token Plan** (separate Individual and existing catalogs, with a separate China provider) - **Xiaomi MiMo** (defaults to API billing endpoint, with separate Token Plan providers for `cn`/`ams`/`sgp` regions) - **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc. @@ -438,7 +439,8 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Hugging Face | `HF_TOKEN` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | -| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | +| Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | +| Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` | | Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo (API billing) | `XIAOMI_API_KEY` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | @@ -446,6 +448,11 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | | GitHub Copilot | `COPILOT_GITHUB_TOKEN` | +`qwen-token-plan-individual` and `qwen-token-plan` share the international endpoint and +`QWEN_TOKEN_PLAN_API_KEY`. The Individual provider exposes only the models documented for Individual +subscriptions, while the existing provider retains its broader catalog for backward compatibility. +Stored credentials remain provider-scoped, so save the key under the provider ID you register. + Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files. ## Tools @@ -787,7 +794,7 @@ Many models support thinking/reasoning capabilities where they can show their in const model = models.getModel('anthropic', 'claude-sonnet-4-5')!; // or models.getModel('openai', 'gpt-5-mini'); // or models.getModel('google', 'gemini-2.5-flash'); -// or models.getModel('xai', 'grok-4.5'); +// or models.getModel('xai', 'grok-4.6'); // Check if model supports reasoning if (model.reasoning) { @@ -1176,8 +1183,10 @@ interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'baseten' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'baseten' uses configurable chat_template_args plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai) - chatTemplateKwargs?: Record; // chat_template_kwargs values; use $var for pi-controlled thinking values - chatTemplateArgs?: Record; // chat_template_args values for thinkingFormat: 'baseten'; use $var for pi-controlled thinking values + chatTemplateKwargs?: Record; // chat_template_kwargs values; use $var for pi-controlled thinking values + chatTemplateArgs?: Record; // chat_template_args values for thinkingFormat: 'baseten'; use $var for pi-controlled thinking values + thinkingTokenBudgetField?: 'thinking_token_budget' | 'thinking_budget' | 'thinking_budget_tokens'; // Top-level field that caps reasoning tokens from thinkingBudgets (vLLM / Qwen / llama.cpp). Off by default. + supportsThinkingTokenBudget?: boolean; // Alias for thinkingTokenBudgetField: 'thinking_token_budget' (vLLM). Prefer thinkingTokenBudgetField. Default: false. cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) diff --git a/packages/ai/package.json b/packages/ai/package.json index 7ca360ffd33..70c8d498693 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.84.0", + "version": "0.84.3", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -62,14 +62,12 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", - "@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", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -94,7 +92,7 @@ "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "canvas": "3.2.3", "vitest": "4.1.9" } diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 754662bcbf8..3b441e64d6e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -20,6 +20,7 @@ import type { OpenAIResponsesCompat, } from "../src/types.ts"; import { + assertExactModelIds, createModelDataManifest, type ModelDataStructure, MODEL_DATA_MANIFEST_FILE, @@ -147,10 +148,6 @@ const COPILOT_STATIC_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; -const KIMI_STATIC_HEADERS = { - "User-Agent": "KimiCLI/1.5", -} as const; - const TOGETHER_BASE_URL = "https://api.together.ai/v1"; const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { supportsStore: false, @@ -238,13 +235,6 @@ const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ "upstage/solar-10.7b-instruct", ]); const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); -const ZAI_GLM52_THINKING_LEVEL_MAP = { - minimal: null, - low: "high", - medium: "high", - high: "high", - max: "max", -} as const; const OPENCODE_GO_GLM52_THINKING_LEVEL_MAP = { off: null, minimal: null, @@ -258,6 +248,10 @@ const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([ "github-copilot:claude-sonnet-4", "github-copilot:claude-sonnet-4.5", ]); +const ANTHROPIC_ALLOWED_FALLBACK_MODELS = { + "claude-fable-5": ["claude-opus-4-8", "claude-opus-5"], + "claude-opus-5": ["claude-opus-4-8"], +} satisfies Record; const DEEPSEEK_V4_THINKING_LEVEL_MAP = { minimal: null, @@ -266,6 +260,10 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { high: "high", max: "max", } as const; +const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP = { + ...DEEPSEEK_V4_THINKING_LEVEL_MAP, + low: "low", +} as const; const QWEN_TOKEN_PLAN_HIGH_MAX_THINKING_LEVEL_MAP = { minimal: null, low: null, @@ -295,6 +293,24 @@ const QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS = new Set([ ]); // Retired preview id — models.dev may still list it after GA ships. const QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS = new Set(["qwen3.8-max-preview"]); +const QWEN_TOKEN_PLAN_PROVIDER_IDS = new Set([ + "qwen-token-plan", + "qwen-token-plan-cn", + "qwen-token-plan-individual", +]); +// QwenCloud Token Plan Individual text-model allowlist, verified 2026-08-05. +// Retired models remain excluded above even if the public catalog lags. +// https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview +const QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS = new Set([ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "deepseek-v4-pro-0813", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", +]); const KIMI_K3_MAX_TOKENS = 131072; const KIMI_K3_COST = { @@ -333,6 +349,12 @@ const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); +// Public OpenAI documents additional_tools for applications that load tools +// outside the normal tool-search flow. Codex currently uses the input item for +// its Responses Lite GPT-5.6 models. +// https://developers.openai.com/api/docs/guides/tools-tool-search#add-tools-at-a-specific-point-in-the-input +const OPENAI_ADDITIONAL_TOOLS_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS; +const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000; const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([ "gpt-5.4", @@ -386,7 +408,6 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); -const XAI_RESPONSES_MODEL_ID = "grok-4.5"; const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-3", "grok-3-fast", @@ -394,10 +415,6 @@ const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-4.20-0309-reasoning", "grok-code-fast-1", ]); -const XAI_RESPONSES_EFFORT_LEVEL_MAP = { - off: null, - minimal: null, -} as const; const XAI_RESPONSES_COMPAT: OpenAIResponsesCompat = { supportsLongCacheRetention: false, }; @@ -570,7 +587,12 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = { supportsOpenAIGrammarTools: false, sendSessionAffinityHeaders: false, supportsLongCacheRetention: true, -} satisfies Required> & { +} satisfies Required< + Omit< + OpenAICompletionsCompat, + "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" + > +> & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; }; @@ -601,6 +623,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); const isTogetherReasoningOnly = isTogether && TOGETHER_REASONING_ONLY_MODELS.has(model.id); + const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com"); const isNonStandard = isNvidia || @@ -610,7 +633,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open baseUrl.includes("api.x.ai") || isTogether || baseUrl.includes("chutes.ai") || - baseUrl.includes("deepseek.com") || + isDeepSeek || isZai || isMoonshot || provider === "opencode" || @@ -620,10 +643,16 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open isAntLing; const useMaxTokens = - baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia || isAntLing || isZai; + baseUrl.includes("chutes.ai") || + isDeepSeek || + isMoonshot || + isCloudflareAiGateway || + isTogether || + isNvidia || + isAntLing || + isZai; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); - const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const isOpenRouterDeveloperRoleModel = isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); const cacheControlFormat = @@ -700,8 +729,45 @@ function applyOpenAICompletionsCompatMetadata(model: Model): void { } } +function applyAnthropicMessagesCompatMetadata(model: Model): void { + if (model.api !== "anthropic-messages") return; + const compat = getAnthropicMessagesCompat(model.provider, model.id); + if (compat) { + mergeAnthropicMessagesCompat(model, compat); + } +} + +function isAnthropicFallbackMetadataModel(model: Model): model is Model<"anthropic-messages"> { + if (model.provider !== "anthropic" || model.api !== "anthropic-messages") return false; + return ( + model.id in ANTHROPIC_ALLOWED_FALLBACK_MODELS || + Object.values(ANTHROPIC_ALLOWED_FALLBACK_MODELS).some((fallbackModelIds) => fallbackModelIds.includes(model.id)) + ); +} + +function applyAnthropicAllowedFallbackModelMetadata(models: readonly Model<"anthropic-messages">[]): void { + const modelsById = new Map(models.map((model) => [model.id, model])); + for (const [modelId, fallbackModelIds] of Object.entries(ANTHROPIC_ALLOWED_FALLBACK_MODELS)) { + const model = modelsById.get(modelId); + if (!model) continue; + + const allowedFallbackModels = fallbackModelIds.flatMap((fallbackModelId) => { + const fallbackModel = modelsById.get(fallbackModelId); + return fallbackModel + ? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }] + : []; + }); + if (allowedFallbackModels.length > 0) { + mergeAnthropicMessagesCompat(model, { allowedFallbackModels }); + } + } +} + function applyStrictToolCompatMetadata(model: Model): void { - if (model.provider === "openai" && model.api === "openai-responses") { + if ( + (model.provider === "openai" || model.provider === "cloudflare-ai-gateway") && + model.api === "openai-responses" + ) { model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsStrictMode: true }; } else if (model.provider === "anthropic" && model.api === "anthropic-messages") { mergeAnthropicMessagesCompat(model, { supportsStrictTools: true }); @@ -737,8 +803,12 @@ function applyOpenAIToolSearchMetadata(model: Model): void { const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses"; const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses"; if (!(isOpenAIResponses || isOpenAICodex) || !OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) return; + const supportsAdditionalTools = + (isOpenAIResponses && OPENAI_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)) || + (isOpenAICodex && OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)); model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), + ...(supportsAdditionalTools ? { supportsAdditionalTools: true } : {}), supportsToolSearch: true, }; } @@ -785,8 +855,10 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { off: "none" }); } - if (model.provider === "xai" && model.api === "openai-responses" && model.id === XAI_RESPONSES_MODEL_ID) { - mergeThinkingLevelMap(model, XAI_RESPONSES_EFFORT_LEVEL_MAP); + // xAI models without verified effort options (e.g. grok-build-0.1) must not + // send the undocumented "none"/"minimal" efforts. + if (model.provider === "xai" && model.api === "openai-responses" && model.thinkingLevelMap === undefined) { + mergeThinkingLevelMap(model, { off: null, minimal: null }); } if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); @@ -837,7 +909,10 @@ function applyThinkingLevelMetadata(model: Model): void { model, model.provider === "openrouter" ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null } - : DEEPSEEK_V4_THINKING_LEVEL_MAP, + : (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") && + model.id.includes("deepseek-v4-flash") + ? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP + : DEEPSEEK_V4_THINKING_LEVEL_MAP, ); } if (isGoogleThinkingApi(model) && isGemini3ProModel(model.id)) { @@ -1033,6 +1108,100 @@ async function fetchOpenRouterModels(): Promise[]> { } } +const AIMLAPI_MODELS_URL = "https://api.aimlapi.com/v1/models?include=pricing"; +const AIMLAPI_BASE_URL = "https://api.aimlapi.com/v1"; + +interface AimlapiPricingUnit { + content?: string; + author?: string; + origin?: string; + phase?: string; + price?: number; + per?: number; +} + +interface AimlapiPricingThreshold { + from: number; + units: AimlapiPricingUnit[]; +} + +interface AimlapiPricing { + units?: AimlapiPricingUnit[]; + thresholds?: AimlapiPricingThreshold[]; +} + +/** $/unit-price -> $/million tokens, honoring the unit's own `per` denominator. */ +function aimlapiRate(units: AimlapiPricingUnit[] | undefined, match: Partial): number { + const unit = units?.find((candidate) => + Object.entries(match).every(([key, value]) => candidate[key as keyof AimlapiPricingUnit] === value), + ); + if (!unit || typeof unit.price !== "number") return 0; + const per = unit.per || 1_000_000; + return roundCost((unit.price * 1_000_000) / per); +} + +function aimlapiCostFromUnits(units: AimlapiPricingUnit[] | undefined): ModelCost { + return { + input: aimlapiRate(units, { content: "text", author: "user", origin: "provided" }), + output: aimlapiRate(units, { content: "text", author: "model", origin: "generated", phase: "inference" }), + cacheRead: aimlapiRate(units, { content: "text", author: "user", origin: "cached" }), + cacheWrite: aimlapiRate(units, { content: "text", author: "user", origin: "cache_write" }), + }; +} + +/** + * AI/ML API's public catalog exposes live pricing (`?include=pricing`) but no + * tool-calling capability flag, so — matching the filter our own OpenClaude + * fork's aimlapi gateway already applies — every `openai/chat-completions` + * entry is included rather than guessing per-model tool support. + */ +async function fetchAimlapiModels(): Promise[]> { + try { + console.log("Fetching models from AI/ML API..."); + const response = await fetch(AIMLAPI_MODELS_URL); + if (!response.ok) throw new Error(`AI/ML API returned ${response.status}`); + const data = await response.json(); + + const models: Model[] = []; + const seen = new Set(); + for (const model of data.data ?? []) { + if (model.type !== "openai/chat-completions") continue; + if (seen.has(model.id)) continue; + seen.add(model.id); + + const pricing: AimlapiPricing | undefined = model.pricing; + const baseCost = aimlapiCostFromUnits(pricing?.units); + // A reasoning-phase output unit means the model bills (and thus supports) thinking tokens. + const reasoning = (pricing?.units ?? []).some((unit) => unit.phase === "reasoning"); + const tiers = (pricing?.thresholds ?? []) + .filter((threshold) => threshold.from > 0) + .map((threshold) => ({ inputTokensAbove: threshold.from, ...aimlapiCostFromUnits(threshold.units) })); + + const contextWindow = model.info?.contextLength || 4096; + + models.push({ + id: model.id, + name: model.info?.name || model.id, + api: "openai-completions", + baseUrl: AIMLAPI_BASE_URL, + provider: "aimlapi", + reasoning, + input: ["text"], + cost: tiers.length > 0 ? { ...baseCost, tiers } : baseCost, + contextWindow, + maxTokens: Math.min(contextWindow, 8192), + }); + } + + console.log(`Fetched ${models.length} chat models from AI/ML API`); + return models; + } catch (error) { + console.error("Failed to fetch AI/ML API models:", error); + if (generatorOptions.strict) throw error; + return []; + } +} + async function fetchAiGatewayModels(): Promise[]> { try { console.log("Fetching models from Vercel AI Gateway API..."); @@ -1093,6 +1262,66 @@ async function fetchAiGatewayModels(): Promise[]> { } } +function processZaiModels(data: ModelsDevCatalog): Model[] { + const variants = [ + { + source: "zai-coding-plan", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + }, + { + source: "zhipuai-coding-plan", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + }, + ] as const; + const models: Model[] = []; + + for (const { source, provider, baseUrl } of variants) { + for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + const supportsImage = m.modalities?.input?.includes("image"); + + const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []); + const isGlm52 = modelId === "glm-5.2" || modelId === "glm-5.2-highspeed"; + if (thinkingLevelMap && isGlm52) { + thinkingLevelMap.off = "none"; + } + const supportsReasoningEffort = thinkingLevelMap !== undefined; + const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + reasoning: m.reasoning === true, + ...(thinkingLevelMap ? { thinkingLevelMap } : {}), + input: supportsImage ? ["text", "image"] : ["text"], + cost: { + input: referenceCost?.input || 0, + output: referenceCost?.output || 0, + cacheRead: referenceCost?.cache_read || 0, + cacheWrite: referenceCost?.cache_write || 0, + }, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + ...(supportsReasoningEffort ? { supportsReasoningEffort: true } : {}), + ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + recordModelsDevReasoningOptions(provider, modelId, m); + } + } + + return models; +} + function processBasetenModels(provider: ModelsDevProvider | undefined): Model[] { if (!provider?.models) return []; @@ -1531,6 +1760,7 @@ async function loadModelsDevData(): Promise[]> { } // Process Cloudflare AI Gateway models + const cloudflareAIGatewayModelIds = new Set(); if (data["cloudflare-ai-gateway"]?.models) { for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) { const m = model as ModelsDevModel; @@ -1565,6 +1795,7 @@ async function loadModelsDevData(): Promise[]> { const compat = upstream === "anthropic" || upstream === "workers-ai" ? { sendSessionAffinityHeaders: true } : undefined; + cloudflareAIGatewayModelIds.add(id); models.push({ id, name: m.name || id, @@ -1587,20 +1818,54 @@ async function loadModelsDevData(): Promise[]> { } } + // models.dev may omit Workers AI passthroughs from the AI Gateway provider + // list even though the gateway /compat endpoint supports routing to them. + // Mirror the Workers AI catalog under the documented workers-ai/ prefix so + // the gateway keeps its OpenAI-compatible /compat models stable. + if (data["cloudflare-workers-ai"]?.models) { + for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + const id = `workers-ai/${modelId}`; + if (cloudflareAIGatewayModelIds.has(id)) continue; + cloudflareAIGatewayModelIds.add(id); + + models.push({ + id, + name: m.name || id, + api: "openai-completions", + provider: "cloudflare-ai-gateway", + baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + compat: { sendSessionAffinityHeaders: true }, + }); + recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); + } + } + // Process xAi models if (data.xai?.models) { for (const [modelId, model] of Object.entries(data.xai.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; - const useResponsesApi = modelId === XAI_RESPONSES_MODEL_ID; models.push({ id: modelId, name: m.name || modelId, - api: useResponsesApi ? "openai-responses" : "openai-completions", + api: "openai-responses", provider: "xai", baseUrl: "https://api.x.ai/v1", - ...(useResponsesApi ? { compat: { ...XAI_RESPONSES_COMPAT } } : {}), + compat: { ...XAI_RESPONSES_COMPAT }, reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { @@ -1616,49 +1881,7 @@ async function loadModelsDevData(): Promise[]> { } } - // Process zAi models - const zaiCodingPlanVariants = [ - { provider: "zai", baseUrl: "https://api.z.ai/api/coding/paas/v4" }, - { provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" }, - ] as const; - - if (data["zai-coding-plan"]?.models) { - for (const { provider, baseUrl } of zaiCodingPlanVariants) { - for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; - const supportsImage = m.modalities?.input?.includes("image"); - - const isGlm52 = modelId === "glm-5.2"; - - models.push({ - id: modelId, - name: m.name || modelId, - api: "openai-completions", - provider, - baseUrl, - reasoning: m.reasoning === true, - ...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}), - input: supportsImage ? ["text", "image"] : ["text"], - cost: { - input: m.cost?.input || 0, - output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, - cacheWrite: m.cost?.cache_write || 0, - }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - ...(isGlm52 ? { supportsReasoningEffort: true } : {}), - ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), - }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, - }); - recordModelsDevReasoningOptions(provider, modelId, m); - } - } - } + models.push(...processZaiModels(data)); // Process Mistral models if (data.mistral?.models) { @@ -1906,10 +2129,10 @@ async function loadModelsDevData(): Promise[]> { // Claude 4.x and 5.x models route to Anthropic Messages API const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId); - // Grok 4.5, gpt-5, oswe, and MAI-Code models are only served through + // Grok, gpt-5, oswe, and MAI-Code models are only served through // the Copilot /responses endpoint. const needsResponsesApi = - modelId === "grok-4.5" || + modelId.startsWith("grok-") || modelId.startsWith("gpt-5") || modelId.startsWith("oswe") || modelId.startsWith("mai-"); @@ -2013,7 +2236,6 @@ async function loadModelsDevData(): Promise[]> { provider: "kimi-coding", // Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages baseUrl: "https://api.kimi.com/coding", - headers: { ...KIMI_STATIC_HEADERS }, compat: { ...(allowEmptySignature ? { allowEmptySignature: true } : {}), forceAdaptiveThinking: true, @@ -2123,6 +2345,7 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(providerModels)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + if (m.status === "deprecated") continue; models.push({ id: modelId, @@ -2146,10 +2369,11 @@ async function loadModelsDevData(): Promise[]> { } } - // Process Alibaba Cloud Model Studio Token Plan models - // Two regions (international / cn) with identical catalogs, separate - // endpoints and API keys (sk-sp- prefix). models.dev keys are - // "alibaba-token-plan[-cn]"; pi exposes them as "qwen-token-plan[-cn]". + // Process Alibaba Cloud Model Studio Token Plan models. International and + // China use separate endpoints and API keys (sk-sp- prefix). The Individual + // provider reuses the international source and endpoint with a narrower catalog. + // models.dev keys are "alibaba-token-plan[-cn]"; pi exposes them as + // "qwen-token-plan[-cn]" plus the Individual catalog view. const qwenTokenPlanCompat: OpenAICompletionsCompat = { thinkingFormat: "qwen", supportsDeveloperRole: false, @@ -2161,22 +2385,31 @@ async function loadModelsDevData(): Promise[]> { source: "alibaba-token-plan", provider: "qwen-token-plan", baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + modelIds: undefined, + }, + { + source: "alibaba-token-plan", + provider: "qwen-token-plan-individual", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + modelIds: QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS, }, { source: "alibaba-token-plan-cn", provider: "qwen-token-plan-cn", baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + modelIds: undefined, }, ] as const; - for (const { source, provider, baseUrl } of qwenTokenPlanVariants) { + for (const { source, provider, baseUrl, modelIds } of qwenTokenPlanVariants) { const providerModels = data[source]?.models; - if (!providerModels) continue; + const emittedModelIds = modelIds ? new Set() : undefined; - for (const [modelId, model] of Object.entries(providerModels)) { + for (const [modelId, model] of Object.entries(providerModels ?? {})) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; if (QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS.has(modelId)) continue; + if (modelIds && !modelIds.has(modelId)) continue; const supportsReasoningEffort = !QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS.has(modelId); models.push({ @@ -2207,8 +2440,13 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + emittedModelIds?.add(modelId); recordModelsDevReasoningOptions(provider, modelId, m); } + + if (modelIds && emittedModelIds && generatorOptions.strict) { + assertExactModelIds(provider, modelIds, emittedModelIds); + } } console.log(`Loaded ${models.length} tool-capable models from models.dev`); @@ -2228,9 +2466,10 @@ async function generateModels() { const modelsDevModels = await loadModelsDevData(); const openRouterModels = await fetchOpenRouterModels(); const aiGatewayModels = await fetchAiGatewayModels(); + const aimlapiModels = await fetchAimlapiModels(); // Combine models (models.dev has priority) - const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels].filter( + const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...aimlapiModels].filter( (model) => !(model.provider === "xai" && XAI_BUILTIN_EXCLUDED_MODEL_IDS.has(model.id)) && !((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "gpt-5.3-codex-spark"), @@ -2313,7 +2552,6 @@ async function generateModels() { } } - // Add missing gpt models const missingOpenAiModels: Model<"openai-responses">[] = [ { @@ -2474,8 +2712,7 @@ async function generateModels() { if ( candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4") && - candidate.provider !== "qwen-token-plan" && - candidate.provider !== "qwen-token-plan-cn" + !QWEN_TOKEN_PLAN_PROVIDER_IDS.has(candidate.provider) ) { const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; candidate.compat = { @@ -2696,6 +2933,7 @@ async function generateModels() { for (const model of allModels) { applyOpenAICompletionsCompatMetadata(model); + applyAnthropicMessagesCompatMetadata(model); applyModelsDevReasoningOptionMetadata(model); applyThinkingLevelMetadata(model); applyStrictToolCompatMetadata(model); @@ -2703,6 +2941,7 @@ async function generateModels() { applyOpenAIToolSearchMetadata(model); applyOpenAIExplicitPromptCacheMetadata(model); } + applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); // Group by provider and deduplicate by model ID const providers: Record>> = {}; diff --git a/packages/ai/scripts/model-data.ts b/packages/ai/scripts/model-data.ts index 4dd94000e4e..f2081817af2 100644 --- a/packages/ai/scripts/model-data.ts +++ b/packages/ai/scripts/model-data.ts @@ -39,6 +39,13 @@ function describeSetDifference(expected: readonly string[], actual: readonly str .join("; "); } +export function assertExactModelIds(label: string, expected: Iterable, actual: Iterable): void { + const expectedIds = Array.from(new Set(expected)).sort(); + const actualIds = Array.from(new Set(actual)).sort(); + if (sameStrings(expectedIds, actualIds)) return; + throw new Error(`${label} model IDs do not match (${describeSetDifference(expectedIds, actualIds)})`); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 04709374686..6c4ff62d08c 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -33,11 +33,12 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; @@ -167,12 +168,21 @@ export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; export type AnthropicThinkingDisplay = "summarized" | "omitted"; +type MessageCreateParamsStreamingWithFallbacks = MessageCreateParamsStreaming & { + fallbacks?: readonly { model: string }[]; +}; + const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; +const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01"; + +function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): boolean { + return (model.compat?.allowedFallbackModels?.length ?? 0) > 0; +} function getAnthropicCompat( model: Model<"anthropic-messages">, -): Required> { +): Required> { return { supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true, supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, @@ -271,6 +281,10 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid return merged; } +function mergeClientHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders { + return mergeHeaders({ "User-Agent": getPiUserAgent() }, ...headerSources); +} + function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean { if (!headers) return false; const expected = name.toLowerCase(); @@ -513,6 +527,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( try { let client: Anthropic; let isOAuth: boolean; + let usageModel = model; if (options?.client) { client = options.client; @@ -538,6 +553,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, context), + shouldUseServerSideFallbackBeta(model), options?.headers, options?.fetch, copilotDynamicHeaders, @@ -573,6 +589,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( for await (const event of iterateAnthropicEvents(response, options?.signal)) { if (event.type === "message_start") { output.responseId = event.message.id; + output.model = event.message.model; + const fallbackCost = + output.model === model.id + ? undefined + : model.compat?.allowedFallbackModels?.find( + (fallback) => fallback.provider === model.provider && fallback.model === output.model, + )?.cost; + usageModel = fallbackCost ? { ...model, id: output.model, cost: fallbackCost } : model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; @@ -583,7 +607,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(model, output.usage); + calculateCost(usageModel, output.usage); } else if (event.type === "content_block_start") { if (event.content_block.type === "text") { const block: Block = { @@ -740,7 +764,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(model, output.usage); + calculateCost(usageModel, output.usage); } } @@ -805,9 +829,15 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti ): AssistantMessageEventStream => { assertRequestAuth(model.provider, options?.apiKey, options?.headers); - const base = buildBaseOptions(model, context, options, options?.apiKey); + const base = { + ...buildBaseOptions(model, context, options, options?.apiKey), + toolChoice: options?.toolChoice, + } satisfies AnthropicOptions; if (!options?.reasoning) { - return stream(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions); + return stream(model, context, { + ...base, + thinkingEnabled: false, + } satisfies AnthropicOptions); } // For models with adaptive thinking: use an effort level. @@ -849,6 +879,7 @@ function createClient( apiKey: string | undefined, interleavedThinking: boolean, useFineGrainedToolStreamingBeta: boolean, + useServerSideFallbackBeta: boolean, optionsHeaders?: ProviderHeaders, fetch?: typeof globalThis.fetch, dynamicHeaders?: Record, @@ -863,6 +894,9 @@ function createClient( if (needsInterleavedBeta) { betaFeatures.push(INTERLEAVED_THINKING_BETA); } + if (useServerSideFallbackBeta) { + betaFeatures.push(SERVER_SIDE_FALLBACK_BETA); + } // Copilot: Bearer auth, selective betas. if (model.provider === "github-copilot") { @@ -872,7 +906,7 @@ function createClient( baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, - defaultHeaders: mergeHeaders( + defaultHeaders: mergeClientHeaders( { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -895,7 +929,7 @@ function createClient( baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, - defaultHeaders: mergeHeaders( + defaultHeaders: mergeClientHeaders( { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -914,7 +948,7 @@ function createClient( // API key or header-owned auth. const sessionAffinityHeaders: ProviderHeaders = sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; - const defaultHeaders = mergeHeaders( + const defaultHeaders = mergeClientHeaders( { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -941,7 +975,7 @@ function buildParams( context: Context, isOAuthToken: boolean, options?: AnthropicOptions, -): MessageCreateParamsStreaming { +): MessageCreateParamsStreamingWithFallbacks { const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env); const compat = getAnthropicCompat(model); const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); @@ -958,7 +992,7 @@ function buildParams( deferredTools = []; } const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name))); - const params: MessageCreateParamsStreaming = { + const params: MessageCreateParamsStreamingWithFallbacks = { model: model.id, messages: convertMessages( transformedMessages, @@ -1070,6 +1104,11 @@ function buildParams( } } + const allowedFallbackModels = model.compat?.allowedFallbackModels; + if (allowedFallbackModels && allowedFallbackModels.length > 0) { + params.fallbacks = allowedFallbackModels.map((fallback) => ({ model: fallback.model })); + } + return params; } @@ -1296,7 +1335,8 @@ function convertTools( return tools.map((tool, index) => { const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools); - const schema = tool.parameters as { properties?: unknown; required?: string[] }; + const parameters = getJsonSchemaToolParameters(tool, strict); + const schema = parameters as { properties?: unknown; required?: string[] }; const legacyInputSchema = { type: "object" as const, properties: schema.properties ?? {}, @@ -1305,7 +1345,7 @@ function convertTools( const inputSchema = strict === true ? { - ...(tool.parameters as Record), + ...(parameters as Record), ...legacyInputSchema, } : legacyInputSchema; diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 519fc0dcf35..56578805d2b 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -13,6 +13,7 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -55,6 +56,7 @@ function formatAzureOpenAIError(error: unknown): string { // Azure OpenAI Responses-specific options export interface AzureOpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; reasoningSummary?: "auto" | "detailed" | "concise" | null; azureApiVersion?: string; azureResourceName?: string; @@ -168,7 +170,10 @@ export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStream throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies AzureOpenAIResponsesOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; @@ -249,7 +254,7 @@ function resolveAzureConfig( } function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { - const headers = { ...model.headers }; + const headers = { "User-Agent": getPiUserAgent(), ...model.headers }; if (options?.headers) { Object.assign(headers, options.headers); @@ -303,6 +308,9 @@ function buildParams( supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false, }); } + if (options?.toolChoice !== undefined) { + params.tool_choice = options.toolChoice; + } if (model.reasoning) { if (options?.reasoningEffort || options?.reasoningSummary) { diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index c2021b1714c..2e944fcd246 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -23,7 +23,7 @@ import { ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; import { NodeHttpHandler } from "@smithy/node-http-handler"; -import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types"; +import type { BuildMiddleware, DeserializeMiddleware, DocumentType, HttpResponse, MetadataBearer } from "@smithy/types"; import { HttpProxyAgent } from "http-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent"; import { calculateCost } from "../models.ts"; @@ -35,6 +35,7 @@ import type { ImageContent, Model, ProviderEnv, + ProviderResponse, SimpleStreamOptions, StopReason, StreamFunction, @@ -55,7 +56,7 @@ import { parseStreamingJson } from "../utils/json-parse.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { adjustMaxTokensForThinking, buildBaseOptions, @@ -100,10 +101,18 @@ export interface BedrockOptions extends StreamOptions { bearerToken?: string; } -type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; +type Block = (TextContent | ThinkingContent | ToolCall) & { + index?: number; + partialJson?: string; + /** Scratch buffer for encrypted reasoning deltas, joined into `thinkingSignature`. */ + redactedChunks?: Uint8Array[]; +}; const EMPTY_TEXT_PLACEHOLDER = ""; +/** Matches the placeholder the Anthropic API path uses for redacted thinking. */ +const REDACTED_THINKING_PLACEHOLDER = "[Reasoning redacted]"; + export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, @@ -225,7 +234,14 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = let responseRequestId: string | undefined; try { + const supportsStrictMode = model.compat?.supportsStrictMode ?? false; const client = new BedrockRuntimeClient(config); + let observedRawResponse = false; + if (options.onResponse) { + addResponseHeadersMiddleware(client, options.onResponse, model, () => { + observedRawResponse = true; + }); + } const customHeaders = providerHeadersToRecord(options.headers); if (customHeaders) { addCustomHeadersMiddleware(client, customHeaders); @@ -240,7 +256,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }), ...(options.temperature !== undefined && { temperature: options.temperature }), }, - toolConfig: convertToolConfig(context.tools, options.toolChoice, model.compat?.supportsStrictMode ?? false), + toolConfig: convertToolConfig(context.tools, options.toolChoice, supportsStrictMode), additionalModelRequestFields: buildAdditionalModelRequestFields(model, options), ...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }), }; @@ -252,7 +268,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = const response = await client.send(command, { abortSignal: options.signal }); responseRequestId = normalizeDiagnosticValue(response.$metadata.requestId); - if (response.$metadata.httpStatusCode !== undefined) { + if (!observedRawResponse && response.$metadata.httpStatusCode !== undefined) { const responseHeaders: Record = {}; if (response.$metadata.requestId) { responseHeaders["x-amzn-requestid"] = response.$metadata.requestId; @@ -305,13 +321,13 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = throw new Error(output.errorMessage || "An unknown error occurred"); } + // A stream can settle without stopping every block, so finalize here too. + for (const block of output.content) finalizeStreamingBlock(block as Block); stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { for (const block of output.content) { - delete (block as Block).index; - // partialJson is only a streaming scratch buffer; never persist it. - delete (block as Block).partialJson; + finalizeStreamingBlock(block as Block); } output.stopReason = options.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatBedrockError(error); @@ -457,12 +473,50 @@ function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Recor client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" }); } +function isSmithyHttpResponse(response: unknown): response is HttpResponse { + if (!response || typeof response !== "object") return false; + const candidate = response as Partial; + return typeof candidate.statusCode === "number" && !!candidate.headers && typeof candidate.headers === "object"; +} + +function toProviderResponse(response: unknown): ProviderResponse | undefined { + if (!isSmithyHttpResponse(response)) return undefined; + return { status: response.statusCode, headers: { ...response.headers } }; +} + +/** + * Bedrock's modeled `$metadata` only preserves selected HTTP metadata (for example + * requestId), so custom gateway headers are otherwise lost before callers see + * `onResponse`. Capture the raw Smithy HTTP response at the deserialize step, + * after the SDK receives the response but before the event stream is consumed. + */ +function addResponseHeadersMiddleware( + client: BedrockRuntimeClient, + onResponse: NonNullable, + model: Model<"bedrock-converse-stream">, + onObserved: () => void, +): void { + const middleware: DeserializeMiddleware = (next) => async (args) => { + const result = await next(args); + const providerResponse = toProviderResponse(result.response); + if (providerResponse) { + onObserved(); + await onResponse(providerResponse, model); + } + return result; + }; + client.middlewareStack.add(middleware, { step: "deserialize", name: "pi-ai-response-headers" }); +} + export const streamSimple: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const base = buildBaseOptions(model, context, options, undefined); + const base = { + ...buildBaseOptions(model, context, options, undefined), + toolChoice: options?.toolChoice, + } satisfies BedrockOptions; if (!options?.reasoning) { return stream(model, context, { ...base, reasoning: undefined } satisfies BedrockOptions); } @@ -578,14 +632,56 @@ function handleContentBlockDelta( partial: output, }); } - if (delta.reasoningContent.signature) { + // `thinkingSignature` holds either an Anthropic signature or an opaque redacted + // payload, never both: mixing them would corrupt whichever arrived first. + if (delta.reasoningContent.signature && !thinkingBlock.redacted) { thinkingBlock.thinkingSignature = (thinkingBlock.thinkingSignature || "") + delta.reasoningContent.signature; } + if (delta.reasoningContent.redactedContent?.length) { + // Encrypted reasoning from non-Anthropic models on Bedrock (e.g. OpenAI GPT-5.6). + // The payload is opaque, so keep it verbatim in `thinkingSignature` the way the + // Anthropic path stores redacted thinking, and replay it on the next turn. + if (!thinkingBlock.redacted) { + thinkingBlock.redacted = true; + thinkingBlock.thinkingSignature = ""; + thinkingBlock.thinking += REDACTED_THINKING_PLACEHOLDER; + stream.push({ + type: "thinking_delta", + contentIndex: thinkingIndex, + delta: REDACTED_THINKING_PLACEHOLDER, + partial: output, + }); + } + thinkingBlock.redactedChunks ??= []; + thinkingBlock.redactedChunks.push(delta.reasoningContent.redactedContent); + } } } } +/** + * Encodes buffered encrypted reasoning into `thinkingSignature` and drops the scratch + * buffer, which must never reach a persisted message: `Uint8Array` serializes to an + * index-keyed object roughly ten times the size of the base64 payload. + */ +function flushRedactedContent(block: Block): void { + if (block.type !== "thinking" || !block.redactedChunks) return; + block.thinkingSignature = bytesToBase64(block.redactedChunks); + delete block.redactedChunks; +} + +/** + * Strips every streaming scratch field. Runs from the terminal paths as well as + * `contentBlockStop`, because a stream can settle without stopping each block. + */ +function finalizeStreamingBlock(block: Block): void { + delete block.index; + // partialJson is only a streaming scratch buffer; never persist it. + delete block.partialJson; + flushRedactedContent(block); +} + function handleMetadata( event: ConverseStreamMetadataEvent, model: Model<"bedrock-converse-stream">, @@ -617,6 +713,7 @@ function handleContentBlockStop( stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output }); break; case "thinking": + flushRedactedContent(block); stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output }); break; case "toolCall": @@ -800,6 +897,20 @@ function createRequiredTextBlock(text: string): ContentBlock.TextMember { return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER }; } +function sanitizeBedrockDocument(value: DocumentType): DocumentType { + if (Array.isArray(value)) { + return value.map(sanitizeBedrockDocument); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key.length > 0) + .map(([key, nestedValue]) => [key, sanitizeBedrockDocument(nestedValue)]), + ); + } + return value; +} + function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] { const result: ToolResultContentBlock[] = []; for (const c of content) { @@ -872,10 +983,19 @@ function convertMessages( } case "toolCall": contentBlocks.push({ - toolUse: { toolUseId: c.id, name: c.name, input: c.arguments }, + toolUse: { toolUseId: c.id, name: c.name, input: sanitizeBedrockDocument(c.arguments) }, }); break; case "thinking": { + // Encrypted reasoning is opaque: replay the stored payload as the + // `redactedContent` member instead of lowering it to reasoning text. + if (c.redacted) { + const redactedContent = decodeRedactedContent(c.thinkingSignature); + if (redactedContent?.length) { + contentBlocks.push({ reasoningContent: { redactedContent } }); + } + continue; + } // Skip empty thinking blocks const thinking = sanitizeSurrogates(c.thinking); if (thinking.trim().length === 0) continue; @@ -993,7 +1113,7 @@ function convertToolConfig( toolSpec: { name: tool.name, description: tool.description, - inputSchema: { json: tool.parameters as unknown as DocumentType }, + inputSchema: { json: getJsonSchemaToolParameters(tool, strict) as unknown as DocumentType }, ...(strict === true ? { strict: true } : {}), }, }; @@ -1163,11 +1283,43 @@ function createImageBlock(mimeType: string, data: string) { throw new Error(`Unknown image type: ${mimeType}`); } + return { source: { bytes: base64ToBytes(data) }, format }; +} + +function base64ToBytes(data: string): Uint8Array { const binaryString = atob(data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } + return bytes; +} + +/** + * Decodes a stored redacted payload. The AWS SDK hands the blob over as bytes, but a + * persisted session carries it as base64. A hand-edited or externally produced session + * can hold a signature that is not base64; drop that block instead of failing the + * whole request. + */ +function decodeRedactedContent(signature: string | undefined): Uint8Array | undefined { + if (!signature) return undefined; + try { + return base64ToBytes(signature); + } catch { + return undefined; + } +} - return { source: { bytes }, format }; +function bytesToBase64(chunks: Uint8Array[]): string { + // Encrypted reasoning runs to tens of KB, so build the binary string in slices + // rather than one concatenation per byte. The window stays under the engine's + // argument-count limit for spread calls. + const WINDOW = 0x8000; + let binary = ""; + for (const chunk of chunks) { + for (let i = 0; i < chunk.length; i += WINDOW) { + binary += String.fromCharCode(...chunk.subarray(i, i + WINDOW)); + } + } + return btoa(binary); } diff --git a/packages/ai/src/api/cloudflare-gateway-binding.ts b/packages/ai/src/api/cloudflare-gateway-binding.ts new file mode 100644 index 00000000000..c2eaa5d4855 --- /dev/null +++ b/packages/ai/src/api/cloudflare-gateway-binding.ts @@ -0,0 +1,192 @@ +/** + * AI Gateway transport over the Workers AI binding. + * + * pi's Cloudflare AI Gateway support speaks HTTPS + * (`gateway.ai.cloudflare.com/v1/{account}/{gateway}/{provider}/...`, see `api/cloudflare.ts`), + * which needs a Cloudflare API token even when the caller is a Worker in the gateway's own + * account. + * + * In order to solve for this problem, `createGatewayBindingFetch` returns a {@link FetchFunction} + * that translates requests under a gateway HTTPS prefix into calls to the Workers AI binding's + * universal endpoint, `env.AI.gateway(id).run({provider, endpoint, headers, query})`. + * Binding calls are pre-authenticated in-account and return the provider's native wire format as a + * regular (streaming) `Response`, so API implementations behave identically over either + * transport. + * + * The result is the transport for one gateway-bound client, not a general-purpose fetch: + * requests it cannot serve — URLs outside the prefix, or in-prefix requests the universal + * endpoint cannot express (non-POST, non-JSON body) — reject with a descriptive error. + * Transport selection is the caller's job, per client: route such traffic over HTTPS with + * real gateway auth instead of through this shim. + */ + +import type { FetchFunction } from "../types.ts"; + +/** + * Structural type for the Workers AI binding's gateway surface (`env.AI`), so this + * module does not depend on `@cloudflare/workers-types`. Any real `Ai` binding satisfies it. + */ +export interface AiGatewayBinding { + gateway(id: string): AiGatewayBindingGateway; +} + +export interface AiGatewayBindingGateway { + run(data: AiGatewayUniversalRequestLike, options?: { signal?: AbortSignal }): Promise; +} + +/** One universal-endpoint request entry, as accepted by `AiGateway.run()`. */ +export interface AiGatewayUniversalRequestLike { + provider: string; + endpoint: string; + headers: Record; + query: unknown; +} + +/** + * Placeholder value for auth headers on binding-routed requests. API implementations + * require an API key or a recognized auth header (`authorization`, `x-api-key`, + * `cf-aig-authorization`) before dispatch; binding calls are pre-authenticated, so pass + * `cf-aig-authorization: Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}` to satisfy + * the check. The shim strips `cf-aig-authorization` before calling the binding. Pair it with + * `Authorization: null` / `x-api-key: null` so the SDKs' placeholder auth headers never reach + * the gateway, which would treat a request-supplied auth header as a BYOK provider key that + * overrides its stored keys — the same as it would over HTTPS. + */ +export const CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL = "cloudflare-gateway-binding"; + +export interface GatewayBindingFetchOptions { + /** The Workers AI binding (e.g. `env.AI`). */ + binding: AiGatewayBinding; + /** + * Gateway HTTPS prefix every request must fall under, without a trailing slash: + * `https://gateway.ai.cloudflare.com/v1/{accountId}/{gatewayName}`. + */ + baseUrl: string; + /** Gateway name passed to `binding.gateway()`. Must match the `baseUrl` gateway. */ + gateway: string; +} + +// Never forwarded to the binding: hop-by-hop/derived headers, and gateway auth +// (binding calls are pre-authenticated; the sentinel must not reach the wire). +const STRIP_HEADERS = new Set(["content-length", "host", "cf-aig-authorization"]); + +type FetchInput = Parameters[0]; + +/** + * Create a `fetch` that routes AI Gateway requests through the Workers AI binding. + * See the module docs for behavior and composition notes. + */ +export function createGatewayBindingFetch(options: GatewayBindingFetchOptions): FetchFunction { + const { binding, gateway } = options; + // Prefix matching runs on URL-normalized components (origin + pathname), not raw strings: + // dot segments resolve away and fragments drop, matching what real fetch would put on the + // wire, so a lexical variant can't split provider/endpoint differently than HTTPS would. + const base = new URL(options.baseUrl); + const basePath = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`; + + return async (input: FetchInput, init?: RequestInit): Promise => { + const request = input instanceof Request ? input : undefined; + const url = request ? request.url : input.toString(); + const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); + let parsed: URL | undefined; + try { + parsed = new URL(url); + } catch { + parsed = undefined; + } + // Out-of-prefix URLs are a configuration bug, not passthrough traffic: silently + // forwarding would ship the auth sentinel to whatever host the URL names. + if (parsed === undefined || parsed.origin !== base.origin || !parsed.pathname.startsWith(basePath)) { + throw new Error( + `createGatewayBindingFetch: ${method} ${url} is outside the configured gateway ` + + `prefix (${base.origin}${basePath}); this fetch only serves its gateway-bound client`, + ); + } + + // In-prefix requests the universal endpoint cannot express always reject: forwarding + // them over HTTPS would send the sentinel to the gateway and fail with a misleading + // auth error instead of naming the real problem. Callers that need such endpoints + // route them over HTTPS with real gateway auth themselves. + const unexpressible = (reason: string): never => { + throw new Error( + `createGatewayBindingFetch: cannot express ${method} ${url} as a universal ` + + `gateway request (${reason}); route it over HTTPS with gateway auth instead`, + ); + }; + if (method !== "POST") return unexpressible("only POST is supported"); + + const rest = parsed.pathname.slice(basePath.length); + const slash = rest.indexOf("/"); + if (slash <= 0) { + return unexpressible("missing provider/endpoint path"); + } + const provider = rest.slice(0, slash); + // Keep the query string on the endpoint — it's part of what HTTPS would have sent. + const endpoint = rest.slice(slash + 1) + parsed.search; + + const bodyText = await readBodyText(request, init); + let query: unknown; + try { + query = bodyText === undefined ? undefined : JSON.parse(bodyText); + } catch { + return unexpressible("non-JSON body"); + } + if (query === undefined) { + return unexpressible("missing body"); + } + + const headers = collectHeaders(request, init); + // Per the fetch spec an explicit `signal: null` in init clears a Request input's signal. + const signal = init?.signal ?? (init && "signal" in init && init.signal === null ? undefined : request?.signal); + return binding.gateway(gateway).run({ provider, endpoint, headers, query }, signal ? { signal } : {}); + }; +} + +async function readBodyText(request: Request | undefined, init?: RequestInit): Promise { + const body = init?.body; + if (body === undefined || body === null) { + // Per the fetch spec an explicit `body: null` in init clears a Request input's body. + if (init && "body" in init && body === null) return undefined; + if (request && request.body !== null) return request.clone().text(); + return undefined; + } + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return new TextDecoder().decode(body); + if (body instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(body)); + // URLSearchParams, FormData, Blob, ReadableStream in init: read via a Request wrapper. + // Consuming a one-shot stream here is fine — unexpressible requests reject rather than + // replay, so nothing downstream needs the body again. + return new Request("http://body.local", { + method: "POST", + body, + // The fetch spec requires `duplex: "half"` to construct a Request with a stream body + // (Node's undici enforces it; it is ignored for the replayable body types). TypeScript's + // RequestInit does not declare the field yet, hence the cast. + duplex: "half", + } as RequestInit).text(); +} + +// Entry header names are lowercased so case-variant duplicates collapse and stripping is +// uniform. Per the fetch spec, `init.headers` replaces a Request input's headers entirely. +function collectHeaders(request: Request | undefined, init?: RequestInit): Record { + const result: Record = {}; + const add = (key: string, value: string) => { + const name = key.toLowerCase(); + if (!STRIP_HEADERS.has(name)) result[name] = value; + }; + const headers = init?.headers; + if (headers === undefined) { + if (request) { + for (const [key, value] of request.headers) add(key, value); + } + } else if (headers instanceof Headers) { + for (const [key, value] of headers) add(key, value); + } else if (Array.isArray(headers)) { + for (const [key, value] of headers) add(key, value); + } else { + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined) add(key, String(value)); + } + } + return result; +} diff --git a/packages/ai/src/api/constrained-sampling.ts b/packages/ai/src/api/constrained-sampling.ts index ec961a12399..ff9c4f1514a 100644 --- a/packages/ai/src/api/constrained-sampling.ts +++ b/packages/ai/src/api/constrained-sampling.ts @@ -1,11 +1,135 @@ import type { Tool } from "../types.ts"; interface JsonSchemaObject { + [key: string]: unknown; type?: unknown; properties?: Record; required?: unknown; } +class UnsupportedStrictJsonSchemaError extends Error {} + +const UNSUPPORTED_STRICT_SCHEMA_KEYS = [ + "$ref", + "$defs", + "definitions", + "allOf", + "oneOf", + "patternProperties", + "dependentSchemas", + "dependencies", + "unevaluatedProperties", + "propertyNames", + "contains", + "prefixItems", + "not", + "if", + "then", + "else", +] as const; + +function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStructuredSchema(schema: unknown): boolean { + if (!isJsonSchemaObject(schema)) return false; + const types = typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : []; + return ( + types.includes("object") || + types.includes("array") || + schema.properties !== undefined || + schema.items !== undefined + ); +} + +function schemaAllowsNull(schema: unknown): boolean { + if (!isJsonSchemaObject(schema)) return false; + if (schema.type === "null" || (Array.isArray(schema.type) && schema.type.includes("null"))) return true; + if (schema.const === null || (Array.isArray(schema.enum) && schema.enum.includes(null))) return true; + return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant)); +} + +function makeJsonSchemaNodeStrict(schema: unknown): void { + if (!isJsonSchemaObject(schema)) { + throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported"); + } + for (const key of UNSUPPORTED_STRICT_SCHEMA_KEYS) { + if (schema[key] !== undefined) { + throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`); + } + } + + if (schema.anyOf !== undefined) { + if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) { + throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema"); + } + for (const variant of schema.anyOf) { + if (isStructuredSchema(variant)) { + throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported"); + } + makeJsonSchemaNodeStrict(variant); + } + } + + if (schema.items !== undefined) { + if (Array.isArray(schema.items)) { + throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported"); + } + makeJsonSchemaNodeStrict(schema.items); + } + + const isObjectSchema = schema.type === "object"; + if (schema.properties !== undefined && !isObjectSchema) { + throw new UnsupportedStrictJsonSchemaError("properties require type object"); + } + if (!isObjectSchema) return; + if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) { + throw new UnsupportedStrictJsonSchemaError("schema-valued or true additionalProperties is unsupported"); + } + if (schema.properties !== undefined && !isJsonSchemaObject(schema.properties)) { + throw new UnsupportedStrictJsonSchemaError("object properties must be a schema map"); + } + if ( + schema.required !== undefined && + (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string")) + ) { + throw new UnsupportedStrictJsonSchemaError("object required must be a string array"); + } + + const properties = schema.properties ?? {}; + const propertyNames = Object.keys(properties); + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + if ([...required].some((key) => !propertyNames.includes(key))) { + throw new UnsupportedStrictJsonSchemaError("required contains an unknown property"); + } + for (const [key, property] of Object.entries(properties)) { + makeJsonSchemaNodeStrict(property); + if (!required.has(key) && !schemaAllowsNull(property)) { + properties[key] = { anyOf: [property, { type: "null" }] }; + } + } + schema.required = propertyNames; + schema.additionalProperties = false; +} + +/** Convert a tool schema to the strict subset expected by provider constrained sampling. */ +export function makeStrictJsonSchema(schema: Tool["parameters"]): Record { + const cloned: unknown = structuredClone(schema); + if (!isJsonSchemaObject(cloned)) { + throw new UnsupportedStrictJsonSchemaError("root schema must have type object"); + } + makeJsonSchemaNodeStrict(cloned); + if (cloned.type !== "object") { + throw new UnsupportedStrictJsonSchemaError("root schema must have type object"); + } + return cloned; +} + +export function getJsonSchemaToolParameters(tool: Tool, strict: boolean | undefined): Tool["parameters"] { + return (strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters) as Tool["parameters"]; +} + export interface GrammarConstrainedSampling { format: "lark" | "regex"; definition: string; @@ -83,12 +207,17 @@ function inferGrammarInputProperty(tool: Tool): string { export function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean): boolean | undefined { const config = tool.constrainedSampling; - if (!config || config.type !== "json_schema") { - return undefined; - } + if (!config || config.type !== "json_schema") return undefined; if (supportsStrictMode) { - return true; + try { + makeStrictJsonSchema(tool.parameters); + return true; + } catch (error) { + if (!(error instanceof UnsupportedStrictJsonSchemaError)) throw error; + if (config.strict !== "require") return undefined; + throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but ${error.message}.`); + } } if (config.strict === "require") { throw new Error( diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 8bd6319004a..a0f9f39c978 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -17,20 +17,21 @@ import type { TextContent, ThinkingBudgets, ThinkingContent, - ThinkingLevel, ToolCall, } from "../types.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import type { GoogleThinkingLevel } from "./google-shared.ts"; +import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, + resolveGoogleThinkingLevel, retainThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, @@ -42,7 +43,7 @@ export interface GoogleOptions extends StreamOptions { thinking?: { enabled: boolean; budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; + level?: GoogleApiThinkingLevel; }; } @@ -215,7 +216,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( if (candidate?.finishReason) { output.rawStopReason = candidate.finishReason; output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some((b) => b.type === "toolCall")) { + if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { output.stopReason = "toolUse"; } } @@ -303,13 +304,16 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies GoogleOptions; if (!options?.reasoning) { return stream(model, context, { ...base, thinking: { enabled: false } } satisfies GoogleOptions); } const clampedReasoning = clampThinkingLevel(model, options.reasoning); - const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning); const googleModel = model as Model<"google-generative-ai">; if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel) || isGemma4Model(googleModel)) { @@ -317,7 +321,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp ...base, thinking: { enabled: true, - level: getThinkingLevel(effort, googleModel), + level: getThinkingLevel(resolvedLevel, googleModel), }, } satisfies GoogleOptions); } @@ -326,7 +330,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp ...base, thinking: { enabled: true, - budgetTokens: getGoogleBudget(googleModel, effort, options.thinkingBudgets), + budgetTokens: getGoogleBudget(googleModel, resolvedLevel, options.thinkingBudgets), }, } satisfies GoogleOptions); }; @@ -341,7 +345,7 @@ function createClient( httpOptions.baseUrl = model.baseUrl; httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append } - const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders }); + const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders }); if (headers) { httpOptions.headers = headers; } @@ -367,13 +371,17 @@ function buildParams( generationConfig.maxOutputTokens = options.maxTokens; } + const supportsStrictMode = supportsGoogleStrictToolSampling(model.id); const functionCallingMode = context.tools?.length - ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id)) + ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode) : undefined; const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + ...(context.tools && + context.tools.length > 0 && { + tools: convertTools(context.tools, false, supportsStrictMode), + }), ...(functionCallingMode !== undefined && { toolConfig: { functionCallingConfig: { mode: functionCallingMode } }, }), @@ -382,7 +390,7 @@ function buildParams( if (options.thinking?.enabled && model.reasoning) { const thinkingConfig: ThinkingConfig = { includeThoughts: true }; if (options.thinking.level !== undefined) { - // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values + // Cast to any since our GoogleApiThinkingLevel mirrors Google's ThinkingLevel enum values thinkingConfig.thinkingLevel = options.thinking.level as any; } else if (options.thinking.budgetTokens !== undefined) { thinkingConfig.thinkingBudget = options.thinking.budgetTokens; @@ -408,8 +416,6 @@ function buildParams( return params; } -type ClampedThinkingLevel = Exclude; - function isGemma4Model(model: Model<"google-generative-ai">): boolean { return /gemma-?4/.test(model.id.toLowerCase()); } @@ -441,7 +447,10 @@ function getDisabledThinkingConfig(model: Model<"google-generative-ai">): Thinki return { thinkingBudget: 0 }; } -function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-generative-ai">): GoogleThinkingLevel { +function getThinkingLevel( + effort: ResolvedGoogleThinkingLevel, + model: Model<"google-generative-ai">, +): GoogleApiThinkingLevel { if (isGemini3ProModel(model)) { switch (effort) { case "minimal": @@ -476,41 +485,41 @@ function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-gen function getGoogleBudget( model: Model<"google-generative-ai">, - effort: ClampedThinkingLevel, + level: ResolvedGoogleThinkingLevel, customBudgets?: ThinkingBudgets, ): number { - if (customBudgets?.[effort] !== undefined) { - return customBudgets[effort]!; + if (customBudgets?.[level] !== undefined) { + return customBudgets[level]!; } if (model.id.includes("2.5-pro")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 32768, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash-lite")) { - const budgets: Record = { + const budgets: Record = { minimal: 512, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } return -1; diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index ae1dcdd73f5..a49c6689300 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -3,10 +3,20 @@ */ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; -import type { Context, ImageContent, Model, StopReason, StreamOptions, TextContent, Tool } from "../types.ts"; +import type { + Context, + ImageContent, + Model, + ModelThinkingLevel, + StopReason, + StreamOptions, + TextContent, + ThinkingLevel, + Tool, +} from "../types.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { transformMessages } from "./transform-messages.ts"; type GoogleApiType = "google-generative-ai" | "google-vertex"; @@ -15,7 +25,30 @@ type GoogleApiType = "google-generative-ai" | "google-vertex"; * Thinking level for Gemini 3 models. * Mirrors Google's ThinkingLevel enum values. */ -export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type GoogleApiThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type ResolvedGoogleThinkingLevel = Exclude; + +/** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */ +export function resolveGoogleThinkingLevel( + model: Model, + level: ModelThinkingLevel, +): ResolvedGoogleThinkingLevel { + if (level === "off") return "high"; + + const mapped = model.thinkingLevelMap?.[level]; + const resolvedLevel = typeof mapped === "string" ? mapped.toLowerCase() : level; + switch (resolvedLevel) { + case "minimal": + case "low": + case "medium": + case "high": + return resolvedLevel; + default: + throw new Error( + `Unsupported Google thinking level mapping for ${model.provider}/${model.id}: ${level} -> ${String(mapped)}`, + ); + } +} /** * Determines whether a streamed Gemini `Part` should be treated as "thinking". @@ -285,17 +318,22 @@ function sanitizeForOpenApi(schema: unknown): unknown { export function convertTools( tools: Tool[], useParameters = false, + supportsStrictMode = true, ): { functionDeclarations: Record[] }[] | undefined { if (tools.length === 0) return undefined; return [ { - functionDeclarations: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - ...(useParameters - ? { parameters: sanitizeForOpenApi(tool.parameters as unknown) } - : { parametersJsonSchema: tool.parameters }), - })), + functionDeclarations: tools.map((tool) => { + const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode); + const parameters = getJsonSchemaToolParameters(tool, strict); + return { + name: tool.name, + description: tool.description, + ...(useParameters + ? { parameters: sanitizeForOpenApi(parameters as unknown) } + : { parametersJsonSchema: parameters }), + }; + }), }, ]; } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 112c385f3e2..cf8c13706a0 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -13,7 +13,6 @@ import type { AssistantMessage, Context, Model, - ThinkingLevel as PiThinkingLevel, ProviderEnv, ProviderHeaders, SimpleStreamOptions, @@ -27,15 +26,17 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import type { GoogleThinkingLevel } from "./google-shared.ts"; +import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, + resolveGoogleThinkingLevel, retainThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, @@ -47,7 +48,7 @@ export interface GoogleVertexOptions extends StreamOptions { thinking?: { enabled: boolean; budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; + level?: GoogleApiThinkingLevel; }; project?: string; location?: string; @@ -56,7 +57,7 @@ export interface GoogleVertexOptions extends StreamOptions { const API_VERSION = "v1"; const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; -const THINKING_LEVEL_MAP: Record = { +const THINKING_LEVEL_MAP: Record = { THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, MINIMAL: ThinkingLevel.MINIMAL, LOW: ThinkingLevel.LOW, @@ -232,7 +233,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( if (candidate?.finishReason) { output.rawStopReason = candidate.finishReason; output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some((b) => b.type === "toolCall")) { + if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { output.stopReason = "toolUse"; } } @@ -315,7 +316,10 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const base = buildBaseOptions(model, context, options, undefined); + const base = { + ...buildBaseOptions(model, context, options, undefined), + toolChoice: options?.toolChoice, + } satisfies GoogleVertexOptions; if (!options?.reasoning) { return stream(model, context, { ...base, @@ -324,7 +328,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> } const clampedReasoning = clampThinkingLevel(model, options.reasoning); - const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning); const geminiModel = model as unknown as Model<"google-generative-ai">; if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) { @@ -332,7 +336,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> ...base, thinking: { enabled: true, - level: getGemini3ThinkingLevel(effort, geminiModel), + level: getGemini3ThinkingLevel(resolvedLevel, geminiModel), }, } satisfies GoogleVertexOptions); } @@ -341,7 +345,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> ...base, thinking: { enabled: true, - budgetTokens: getGoogleBudget(geminiModel, effort, options.thinkingBudgets), + budgetTokens: getGoogleBudget(geminiModel, resolvedLevel, options.thinkingBudgets), }, } satisfies GoogleVertexOptions); }; @@ -388,7 +392,7 @@ function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: Provid } } - const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders }); + const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders }); if (headers) { httpOptions.headers = headers; } @@ -466,13 +470,17 @@ function buildParams( generationConfig.maxOutputTokens = options.maxTokens; } + const supportsStrictMode = supportsGoogleStrictToolSampling(model.id); const functionCallingMode = context.tools?.length - ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id)) + ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode) : undefined; const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + ...(context.tools && + context.tools.length > 0 && { + tools: convertTools(context.tools, false, supportsStrictMode), + }), ...(functionCallingMode !== undefined && { toolConfig: { functionCallingConfig: { mode: functionCallingMode } }, }), @@ -506,8 +514,6 @@ function buildParams( return params; } -type ClampedThinkingLevel = Exclude; - function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); } @@ -534,9 +540,9 @@ function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfi } function getGemini3ThinkingLevel( - effort: ClampedThinkingLevel, + effort: ResolvedGoogleThinkingLevel, model: Model<"google-generative-ai">, -): GoogleThinkingLevel { +): GoogleApiThinkingLevel { if (isGemini3ProModel(model)) { switch (effort) { case "minimal": @@ -561,31 +567,31 @@ function getGemini3ThinkingLevel( function getGoogleBudget( model: Model<"google-generative-ai">, - effort: ClampedThinkingLevel, + level: ResolvedGoogleThinkingLevel, customBudgets?: ThinkingBudgets, ): number { - if (customBudgets?.[effort] !== undefined) { - return customBudgets[effort]!; + if (customBudgets?.[level] !== undefined) { + return customBudgets[level]!; } if (model.id.includes("2.5-pro")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 32768, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } return -1; diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index 9bbb96b79f4..64bb10815c2 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -1,11 +1,3 @@ -import { HTTPClient, Mistral } from "@mistralai/mistralai"; -import type { - ChatCompletionStreamRequest, - ChatCompletionStreamRequestMessage, - CompletionEvent, - ContentChunk, - FunctionTool, -} from "@mistralai/mistralai/models/components"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, @@ -23,9 +15,11 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; +import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { buildBaseOptions } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; @@ -43,8 +37,87 @@ export interface MistralOptions extends StreamOptions { reasoningEffort?: MistralReasoningEffort; } +type MistralContentChunk = + | { type: "text"; text: string } + | { type: "image_url"; imageUrl: string } + | { type: "thinking"; thinking: Array<{ type: "text"; text: string }> }; + +type MistralRequestToolCall = { + id: string; + type: "function"; + function: { name: string; arguments: string }; + index: number; +}; + +type MistralChatMessage = { + role: "system" | "user" | "assistant" | "tool"; + content?: string | MistralContentChunk[]; + toolCalls?: MistralRequestToolCall[]; + toolCallId?: string; + name?: string; + prefix?: boolean; +}; + +type MistralFunctionTool = { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + strict: boolean; + }; +}; + +type MistralChatPayload = { + [key: string]: unknown; + model: string; + stream: boolean; + messages: MistralChatMessage[]; + tools?: MistralFunctionTool[]; + temperature?: number; + maxTokens?: number; + toolChoice?: Exclude; + promptMode?: "reasoning"; + reasoningEffort?: MistralReasoningEffort; + promptCacheKey?: string; +}; + +type MistralStreamContentChunk = { + type: string; + text?: string; + thinking?: Array<{ text?: string }>; +}; + +type MistralStreamToolCall = { + id?: string; + index?: number; + function: { + name: string; + arguments: string | Record; + }; +}; + +type MistralCompletionEvent = { + data: { + id?: string; + usage?: { + [key: string]: unknown; + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + choices: Array<{ + finish_reason?: string | null; + delta: { + content?: string | MistralStreamContentChunk[] | null; + tool_calls?: MistralStreamToolCall[] | null; + }; + }>; + }; +}; + /** - * Stream responses from Mistral using `chat.stream`. + * Stream responses from the native Mistral Chat Completions endpoint. */ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = ( model: Model<"mistral-conversations">, @@ -62,22 +135,15 @@ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = ( throw new Error(`No API key for provider: ${model.provider}`); } - // Intentionally per-request: avoids shared SDK mutable state across concurrent consumers. - const mistral = new Mistral({ - apiKey, - serverURL: model.baseUrl, - ...(options?.fetch ? { httpClient: new HTTPClient({ fetcher: options.fetch }) } : {}), - }); - const normalizeMistralToolCallId = createMistralToolCallIdNormalizer(); const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id)); let payload = buildChatPayload(model, context, transformedMessages, options); const nextPayload = await options?.onPayload?.(payload, model); if (nextPayload !== undefined) { - payload = nextPayload as ChatCompletionStreamRequest; + payload = nextPayload as MistralChatPayload; } - const mistralStream = await mistral.chat.stream(payload, buildRequestOptions(model, options)); + const mistralStream = await requestMistralStream(model, payload, apiKey, options); stream.push({ type: "start", partial: output }); await consumeChatStream(model, output, stream, mistralStream); @@ -122,7 +188,10 @@ export const streamSimple: StreamFunction<"mistral-conversations", SimpleStreamO throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies MistralOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoning = clampedReasoning === "off" ? undefined : clampedReasoning; const shouldUseReasoning = model.reasoning && reasoning !== undefined; @@ -189,9 +258,9 @@ function deriveMistralToolCallId(id: string, attempt: number): string { function formatMistralError(error: unknown): string { if (error instanceof Error) { - const sdkError = error as Error & { statusCode?: unknown; body?: unknown }; - const statusCode = typeof sdkError.statusCode === "number" ? sdkError.statusCode : undefined; - const bodyText = typeof sdkError.body === "string" ? sdkError.body.trim() : undefined; + const httpError = error as Error & { statusCode?: unknown; body?: unknown }; + const statusCode = typeof httpError.statusCode === "number" ? httpError.statusCode : undefined; + const bodyText = typeof httpError.body === "string" ? httpError.body.trim() : undefined; if (statusCode !== undefined && bodyText) { return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`; } @@ -215,31 +284,220 @@ function safeJsonStringify(value: unknown): string { } } -function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions) { - const requestOptions: { - signal?: AbortSignal; - retries: { strategy: "none" }; - headers?: Record; - } = { - retries: { strategy: "none" }, - }; - if (options?.signal) requestOptions.signal = options.signal; +async function requestMistralStream( + model: Model<"mistral-conversations">, + payload: MistralChatPayload, + apiKey: string, + options?: MistralOptions, +): Promise> { + const baseUrl = new URL(model.baseUrl); + baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/u, "")}/`; + const url = new URL("v1/chat/completions", baseUrl); + const headers = buildMistralHeaders(model, apiKey, options); + const timeoutSignal = AbortSignal.timeout(options?.timeoutMs ?? 60_000); + const signal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; + const response = await (options?.fetch ?? globalThis.fetch)(url, { + method: "POST", + headers, + body: JSON.stringify(toMistralWirePayload(payload)), + signal, + }); - const headers: Record = {}; - if (model.headers) Object.assign(headers, model.headers); - if (options?.headers) Object.assign(headers, options.headers); + await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); - // Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching). - // Respect explicit caller-provided header values. - if (shouldUsePromptCaching(options) && !headers["x-affinity"]) { - headers["x-affinity"] = options.sessionId; + if (!response.ok) { + const body = await response.text(); + throw new MistralHttpError(response.status, body, response.statusText); + } + if (!response.body) { + throw new Error("Mistral response has no body"); } - if (Object.keys(headers).length > 0) { - requestOptions.headers = headers; + return readMistralEvents(response.body, signal); +} + +class MistralHttpError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, body: string, statusText: string) { + super(statusText || `Request failed with status ${statusCode}`); + this.name = "MistralHttpError"; + this.statusCode = statusCode; + this.body = body; } +} + +function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers { + const headers = new Headers({ + "User-Agent": getPiUserAgent(), + accept: "text/event-stream", + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }); + applyMistralHeaderOverrides(headers, model.headers); + applyMistralHeaderOverrides(headers, options?.headers); + + const hasExplicitAffinity = + hasMistralHeaderOverride(model.headers, "x-affinity") || hasMistralHeaderOverride(options?.headers, "x-affinity"); + if (shouldUsePromptCaching(options) && !hasExplicitAffinity) { + headers.set("x-affinity", options.sessionId); + } + + return headers; +} - return requestOptions; +function applyMistralHeaderOverrides(headers: Headers, overrides?: Record): void { + if (!overrides) return; + for (const [name, value] of Object.entries(overrides)) { + if (value === null) headers.delete(name); + else headers.set(name, value); + } +} + +function hasMistralHeaderOverride(overrides: Record | undefined, target: string): boolean { + return !!overrides && Object.keys(overrides).some((name) => name.toLowerCase() === target); +} + +function toMistralWirePayload(payload: MistralChatPayload): Record { + const wirePayload: Record = { ...payload }; + for (const [source, target] of [ + ["topP", "top_p"], + ["maxTokens", "max_tokens"], + ["randomSeed", "random_seed"], + ["responseFormat", "response_format"], + ["toolChoice", "tool_choice"], + ["presencePenalty", "presence_penalty"], + ["frequencyPenalty", "frequency_penalty"], + ["parallelToolCalls", "parallel_tool_calls"], + ["reasoningEffort", "reasoning_effort"], + ["promptMode", "prompt_mode"], + ["promptCacheKey", "prompt_cache_key"], + ["safePrompt", "safe_prompt"], + ] as const) { + remapMistralProperty(wirePayload, source, target); + } + wirePayload.messages = payload.messages.map((message) => toMistralWireMessage(message)); + + const responseFormat = wirePayload.response_format; + if (isMistralRecord(responseFormat)) { + const wireResponseFormat = { ...responseFormat }; + remapMistralProperty(wireResponseFormat, "jsonSchema", "json_schema"); + const jsonSchema = wireResponseFormat.json_schema; + if (isMistralRecord(jsonSchema)) { + const wireJsonSchema = { ...jsonSchema }; + remapMistralProperty(wireJsonSchema, "schemaDefinition", "schema"); + wireResponseFormat.json_schema = wireJsonSchema; + } + wirePayload.response_format = wireResponseFormat; + } + + return wirePayload; +} + +function toMistralWireMessage(message: MistralChatMessage): Record { + const wireMessage: Record = { ...message }; + remapMistralProperty(wireMessage, "toolCalls", "tool_calls"); + remapMistralProperty(wireMessage, "toolCallId", "tool_call_id"); + if (Array.isArray(message.content)) { + wireMessage.content = message.content.map((chunk) => toMistralWireContentChunk(chunk)); + } + return wireMessage; +} + +function toMistralWireContentChunk(chunk: MistralContentChunk): Record { + const wireChunk: Record = { ...chunk }; + for (const [source, target] of [ + ["imageUrl", "image_url"], + ["documentUrl", "document_url"], + ["documentName", "document_name"], + ["fileId", "file_id"], + ["referenceIds", "reference_ids"], + ["inputAudio", "input_audio"], + ] as const) { + remapMistralProperty(wireChunk, source, target); + } + return wireChunk; +} + +function remapMistralProperty(record: Record, source: string, target: string): void { + if (!(source in record)) return; + record[target] = record[source]; + delete record[source]; +} + +function isMistralRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const MISTRAL_STREAM_DONE = Symbol("mistral-stream-done"); + +async function* readMistralEvents( + body: ReadableStream, + signal: AbortSignal, +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const onAbort = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener("abort", onAbort, { once: true }); + + try { + while (true) { + if (signal.aborted) throw signal.reason; + const { done, value } = await reader.read(); + if (signal.aborted) throw signal.reason; + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + + let boundary = findMistralEventBoundary(buffer); + while (boundary) { + const event = parseMistralEvent(buffer.slice(0, boundary.index)); + buffer = buffer.slice(boundary.index + boundary.length); + if (event === MISTRAL_STREAM_DONE) return; + if (event) yield event; + boundary = findMistralEventBoundary(buffer); + } + + if (done) break; + } + + if (buffer.trim()) { + const event = parseMistralEvent(buffer); + if (event !== MISTRAL_STREAM_DONE && event) yield event; + } + } finally { + signal.removeEventListener("abort", onAbort); + try { + await reader.cancel(); + } catch {} + try { + reader.releaseLock(); + } catch {} + } +} + +function findMistralEventBoundary(buffer: string): { index: number; length: number } | undefined { + const match = /\r\n\r\n|\r\n\r|\r\n\n|\r\r\n|\n\r\n|\r\r|\n\r|\n\n/u.exec(buffer); + return match?.index === undefined ? undefined : { index: match.index, length: match[0].length }; +} + +function parseMistralEvent(raw: string): MistralCompletionEvent | typeof MISTRAL_STREAM_DONE | undefined { + const data = raw + .split(/\r\n|\r|\n/u) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n") + .trim(); + if (!data) return undefined; + if (data === "[DONE]") return MISTRAL_STREAM_DONE; + + const parsed: unknown = JSON.parse(data); + if (!isMistralRecord(parsed) || !Array.isArray(parsed.choices)) { + throw new Error("Invalid Mistral streaming event"); + } + return { data: parsed as MistralCompletionEvent["data"] }; } function buildChatPayload( @@ -247,8 +505,8 @@ function buildChatPayload( context: Context, messages: Message[], options?: MistralOptions, -): ChatCompletionStreamRequest { - const payload: ChatCompletionStreamRequest = { +): MistralChatPayload { + const payload: MistralChatPayload = { model: model.id, stream: true, messages: toChatMessages(messages, model.input.includes("image")), @@ -301,7 +559,7 @@ async function consumeChatStream( model: Model<"mistral-conversations">, output: AssistantMessage, stream: AssistantMessageEventStream, - mistralStream: AsyncIterable, + mistralStream: AsyncIterable, ): Promise { let currentBlock: TextContent | ThinkingContent | null = null; const blocks = output.content; @@ -336,15 +594,15 @@ async function consumeChatStream( output.responseId ||= chunk.id; if (chunk.usage) { - const promptTokens = chunk.usage.promptTokens || 0; + const promptTokens = chunk.usage.prompt_tokens || 0; const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens); output.usage.input = Math.max(0, promptTokens - cachedPromptTokens); - output.usage.output = chunk.usage.completionTokens || 0; + output.usage.output = chunk.usage.completion_tokens || 0; output.usage.cacheRead = cachedPromptTokens; output.usage.cacheWrite = 0; output.usage.totalTokens = - chunk.usage.totalTokens || + chunk.usage.total_tokens || output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(model, output.usage); } @@ -352,9 +610,9 @@ async function consumeChatStream( const choice = chunk.choices[0]; if (!choice) continue; - if (choice.finishReason) { - output.rawStopReason = choice.finishReason; - const stopReasonResult = mapChatStopReason(choice.finishReason); + if (choice.finish_reason) { + output.rawStopReason = choice.finish_reason; + const stopReasonResult = mapChatStopReason(choice.finish_reason); output.stopReason = stopReasonResult.stopReason; if (stopReasonResult.errorMessage) { output.errorMessage = stopReasonResult.errorMessage; @@ -384,8 +642,8 @@ async function consumeChatStream( } if (item.type === "thinking") { - const deltaText = item.thinking - .map((part) => ("text" in part ? part.text : "")) + const deltaText = (item.thinking ?? []) + .map((part) => part.text ?? "") .filter((text) => text.length > 0) .join(""); const thinkingDelta = sanitizeSurrogates(deltaText); @@ -407,7 +665,7 @@ async function consumeChatStream( } if (item.type === "text") { - const textDelta = sanitizeSurrogates(item.text); + const textDelta = sanitizeSurrogates(item.text ?? ""); if (!currentBlock || currentBlock.type !== "text") { finishCurrentBlock(currentBlock); currentBlock = { type: "text", text: "" }; @@ -425,7 +683,7 @@ async function consumeChatStream( } } - const toolCalls = delta.toolCalls || []; + const toolCalls = delta.tool_calls || []; for (const toolCall of toolCalls) { if (currentBlock) { finishCurrentBlock(currentBlock); @@ -492,7 +750,7 @@ async function consumeChatStream( } } -function toFunctionTools(tools: Tool[]): Array { +function toFunctionTools(tools: Tool[]): MistralFunctionTool[] { return tools.map((tool) => { const strict = resolveJsonSchemaStrictSampling(tool, true); return { @@ -500,7 +758,7 @@ function toFunctionTools(tools: Tool[]): Array, + parameters: stripSymbolKeys(getJsonSchemaToolParameters(tool, strict)) as Record, strict: strict ?? false, }, }; @@ -523,8 +781,8 @@ function stripSymbolKeys(value: unknown): unknown { return value; } -function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessage[] { - const result: ChatCompletionStreamRequestMessage[] = []; +function toChatMessages(messages: Message[], supportsImages: boolean): MistralChatMessage[] { + const result: MistralChatMessage[] = []; for (const msg of messages) { if (msg.role === "user") { @@ -533,7 +791,7 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl continue; } const hadImages = msg.content.some((item) => item.type === "image"); - const content: ContentChunk[] = msg.content + const content: MistralContentChunk[] = msg.content .filter((item) => item.type === "text" || supportsImages) .map((item) => { if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) }; @@ -550,8 +808,8 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl } if (msg.role === "assistant") { - const contentParts: ContentChunk[] = []; - const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = []; + const contentParts: MistralContentChunk[] = []; + const toolCalls: MistralRequestToolCall[] = []; for (const block of msg.content) { if (block.type === "text") { @@ -573,17 +831,18 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) }, + index: 0, }); } - const assistantMessage: ChatCompletionStreamRequestMessage = { role: "assistant" }; + const assistantMessage: MistralChatMessage = { role: "assistant", prefix: false }; if (contentParts.length > 0) assistantMessage.content = contentParts; if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls; if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage); continue; } - const toolContent: ContentChunk[] = []; + const toolContent: MistralContentChunk[] = []; const textResult = msg.content .filter((part) => part.type === "text") .map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : "")) @@ -651,7 +910,7 @@ function mapToolChoice( ): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined { if (!choice) return undefined; if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") { - return choice as any; + return choice; } return { type: "function", diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 52e1becfbde..71a917d675d 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -1,4 +1,3 @@ -import type * as NodeOs from "node:os"; import type * as NodeZlib from "node:zlib"; import type { Tool as OpenAITool, @@ -7,20 +6,6 @@ import type { ResponseStreamEvent, } from "openai/resources/responses/responses.js"; -type ProcessWithOsBuiltinModule = typeof process & { - getBuiltinModule?: (id: "node:os") => typeof NodeOs; -}; - -function loadNodeOs(): typeof NodeOs | null { - if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { - return null; - } - return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; -} - -// NEVER convert to top-level runtime imports - breaks browser/Vite builds -const _os: typeof NodeOs | null = loadNodeOs(); - import { clampThinkingLevel } from "../models.ts"; import { registerSessionResourceCleanup } from "../session-resources.ts"; import type { @@ -46,6 +31,7 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { uuidv7 } from "../utils/uuid.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; @@ -513,7 +499,10 @@ export const streamSimple: StreamFunction<"openai-codex-responses", SimpleStream throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies OpenAICodexResponsesOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; @@ -539,11 +528,17 @@ function buildRequestBody( ): RequestBody { const supportsStrictMode = model.compat?.supportsStrictMode ?? true; const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false; - const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false); + const deferredToolsMode = model.compat?.supportsAdditionalTools + ? "additional-tools" + : model.compat?.supportsToolSearch + ? "tool-search" + : undefined; + const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined); const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, { includeSystemPrompt: false, grammarToolInputProperties, deferredTools: toolPlacement.deferred, + deferredToolsMode, toolOptions: { strict: null, supportsStrictMode, @@ -662,7 +657,7 @@ async function processStream( grammarToolInputProperties: ReadonlyMap, options?: OpenAICodexResponsesOptions, ): Promise { - await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, { + await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal), output), output, stream, model, { serviceTier: options?.serviceTier, grammarToolInputProperties, resolveServiceTier: resolveCodexServiceTier, @@ -719,7 +714,10 @@ function extractCodexEventError(event: Record): { code?: string }; } -async function* mapCodexEvents(events: AsyncIterable>): AsyncGenerator { +async function* mapCodexEvents( + events: AsyncIterable>, + output: AssistantMessage, +): AsyncGenerator { for await (const event of events) { const type = typeof event.type === "string" ? event.type : undefined; if (!type) continue; @@ -740,7 +738,10 @@ async function* mapCodexEvents(events: AsyncIterable>): } if (type === "response.done" || type === "response.completed" || type === "response.incomplete") { - const response = (event as { response?: { status?: unknown } }).response; + const response = (event as { response?: { status?: unknown; end_turn?: unknown } }).response; + if (typeof response?.end_turn === "boolean") { + output.endTurn = response.end_turn; + } const normalizedResponse = response ? { ...response, status: normalizeCodexStatus(response.status) } : response; @@ -1504,7 +1505,7 @@ async function processWebSocketStream( socket.send(JSON.stringify({ type: "response.create", ...requestBody })); await processResponsesStream( startWebSocketOutputOnFirstEvent( - mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)), + mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs), output), onStart, ), output, @@ -1606,8 +1607,7 @@ function buildBaseCodexHeaders( headers.set("Authorization", `Bearer ${token}`); headers.set("chatgpt-account-id", accountId); headers.set("originator", "pi"); - const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "pi (browser)"; - headers.set("User-Agent", userAgent); + headers.set("User-Agent", getPiUserAgent()); return headers; } diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 20da4e05deb..04326ca2f7d 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -18,6 +18,7 @@ import type { ChatTemplateKwargValue, Context, ImageContent, + JsonValue, Message, Model, OpenAICompletionsCompat, @@ -30,6 +31,7 @@ import type { TextContent, ThinkingBudgets, ThinkingContent, + ThinkingTokenBudgetField, Tool, ToolCall, ToolResultMessage, @@ -39,6 +41,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -47,12 +50,13 @@ import { createGrammarToolInputProperties, type GrammarToolInputJsonBuffer, getGrammarToolInput, + getJsonSchemaToolParameters, resolveGrammarConstrainedSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; -import { buildBaseOptions, clampReasoning, MIN_ANSWER_TOKENS } from "./simple-options.ts"; +import { buildBaseOptions, clampThinkingBudgetToAnswerRoom, thinkingBudgetForLevel } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; /** @@ -125,24 +129,41 @@ function isImageContentBlock(block: { type: string }): block is ImageContent { return block.type === "image"; } -function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { - if (typeof detail !== "object" || detail === null) { - return false; - } - const candidate = detail as Record; +function isReasoningDetailObject(detail: unknown): detail is Record { + return typeof detail === "object" && detail !== null && !Array.isArray(detail); +} + +function hasValidCommonReasoningDetailFields(candidate: Record): boolean { return ( - candidate.type === "reasoning.encrypted" && - typeof candidate.id === "string" && - candidate.id.length > 0 && - typeof candidate.data === "string" && - candidate.data.length > 0 + (candidate.id === undefined || candidate.id === null || typeof candidate.id === "string") && + (candidate.format === undefined || typeof candidate.format === "string") && + (candidate.index === undefined || typeof candidate.index === "number") ); } +function isOpenAIReasoningDetail(detail: unknown): detail is OpenAIReasoningDetail { + if (!isReasoningDetailObject(detail) || !hasValidCommonReasoningDetailFields(detail)) { + return false; + } + switch (detail.type) { + case "reasoning.summary": + return typeof detail.summary === "string"; + case "reasoning.encrypted": + return typeof detail.data === "string"; + case "reasoning.text": + return ( + typeof detail.text === "string" && + (detail.signature === undefined || detail.signature === null || typeof detail.signature === "string") + ); + default: + return false; + } +} + export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; - /** Token budgets per thinking level. Only used when `compat.supportsThinkingTokenBudget` is set. */ + /** Token budgets per thinking level. Used when `compat.thinkingTokenBudgetField` or `compat.supportsThinkingTokenBudget` is set, or by `{ "$var": "thinking.budget" }`. */ thinkingBudgets?: ThinkingBudgets; } @@ -157,11 +178,12 @@ interface OpenAICompatCacheControl { type ResolvedOpenAICompletionsCompat = Omit< Required, - "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" + "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" > & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"]; + thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; }; type ResolvedChatTemplateKwargValue = string | number | boolean | null; @@ -173,12 +195,96 @@ type KimiToolSystemMessageParam = { tools: OpenAI.Chat.Completions.ChatCompletionTool[]; }; -type OpenAIEncryptedReasoningDetail = { +type OpenAIReasoningDetailBase = Record & { + id?: string | null; + format?: string; + index?: number; +}; + +type OpenAIReasoningSummaryDetail = OpenAIReasoningDetailBase & { + type: "reasoning.summary"; + summary: string; +}; + +type OpenAIEncryptedReasoningDetail = OpenAIReasoningDetailBase & { type: "reasoning.encrypted"; - id: string; data: string; }; +type OpenAIReasoningTextDetail = OpenAIReasoningDetailBase & { + type: "reasoning.text"; + text: string; + signature?: string | null; +}; + +type OpenAIReasoningDetail = OpenAIReasoningSummaryDetail | OpenAIEncryptedReasoningDetail | OpenAIReasoningTextDetail; + +function parseOpenAIReasoningDetails(signature: string | undefined): OpenAIReasoningDetail[] | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isOpenAIReasoningDetail) ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseLegacyEncryptedReasoningDetail( + signature: string | undefined, +): OpenAIEncryptedReasoningDetail | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return isOpenAIReasoningDetail(parsed) && + parsed.type === "reasoning.encrypted" && + typeof parsed.id === "string" && + parsed.id.length > 0 && + parsed.data.length > 0 + ? parsed + : undefined; + } catch { + return undefined; + } +} + +function fillMissingCommonReasoningDetailFields( + target: OpenAIReasoningDetailBase, + source: OpenAIReasoningDetail, +): void { + target.id ??= source.id; + target.format ||= source.format; + target.index ??= source.index; +} + +function appendOpenAIReasoningDetail(details: OpenAIReasoningDetail[], detail: OpenAIReasoningDetail): void { + const lastDetail = details[details.length - 1]; + if (detail.type === "reasoning.text" && lastDetail?.type === "reasoning.text") { + lastDetail.text += detail.text; + lastDetail.signature ||= detail.signature; + fillMissingCommonReasoningDetailFields(lastDetail, detail); + return; + } + if (detail.type === "reasoning.summary" && lastDetail?.type === "reasoning.summary") { + lastDetail.summary += detail.summary; + fillMissingCommonReasoningDetailFields(lastDetail, detail); + return; + } + details.push({ ...detail }); +} + +const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; + +type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; + +function isOpenAICompletionsReasoningField(field: string): field is OpenAICompletionsReasoningField { + return OPENAI_COMPLETIONS_REASONING_FIELDS.includes(field as OpenAICompletionsReasoningField); +} + +type ChatCompletionAssistantMessageParamWithReasoning = ChatCompletionAssistantMessageParam & + Partial> & { + reasoning_details?: JsonValue[]; + }; + type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { cache_control?: OpenAICompatCacheControl; }; @@ -276,7 +382,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio let hasFinishReason = false; const toolCallBlocksByIndex = new Map(); const toolCallBlocksById = new Map(); - const pendingReasoningDetailsByToolCallId = new Map(); const blocks = output.content as StreamingBlock[]; const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); const getCustomToolCallInput = (block: StreamingToolCallBlock): string => { @@ -367,16 +472,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio } return thinkingBlock; }; - const applyPendingReasoningDetail = (block: StreamingToolCallBlock) => { - if (!block.id) { - return; - } - const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id); - if (pendingReasoningDetail) { - block.thoughtSignature = pendingReasoningDetail; - pendingReasoningDetailsByToolCallId.delete(block.id); - } - }; const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; const name = toolCall.function?.name ?? toolCall.custom?.name ?? ""; @@ -433,7 +528,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio }; delete block.partialArgs; } - applyPendingReasoningDetail(block); return block; }; @@ -551,15 +645,14 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; if (Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { - if (isEncryptedReasoningDetail(detail)) { - const serializedDetail = JSON.stringify(detail); - const matchingToolCall = toolCallBlocksById.get(detail.id); - if (matchingToolCall) { - matchingToolCall.thoughtSignature = serializedDetail; - } else { - pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail); - } - } + if (!isOpenAIReasoningDetail(detail)) continue; + const block = ensureThinkingBlock(""); + const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? []; + appendOpenAIReasoningDetail(preservedDetails, detail); + // Keep provider replay data in the existing signature slot. OpenRouter streams + // reasoning_details as deltas: consecutive text/summary deltas are merged into + // logical entries, while encrypted entries remain opaque and discrete. + block.thinkingSignature = JSON.stringify(preservedDetails); } } } @@ -620,15 +713,16 @@ export const streamSimple: StreamFunction<"openai-completions", SimpleStreamOpti ): AssistantMessageEventStream => { getClientApiKey(model.provider, options?.apiKey, options?.headers); - const base = buildBaseOptions(model, context, options, options?.apiKey); + const base = { + ...buildBaseOptions(model, context, options, options?.apiKey), + toolChoice: options?.toolChoice, + } satisfies OpenAICompletionsOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; - const toolChoice = (options as OpenAICompletionsOptions | undefined)?.toolChoice; return stream(model, context, { ...base, reasoningEffort, - toolChoice, thinkingBudgets: options?.thinkingBudgets, } satisfies OpenAICompletionsOptions); }; @@ -642,7 +736,7 @@ function createClient( sessionId?: string, compat: ResolvedOpenAICompletionsCompat = getCompat(model), ) { - const headers: ProviderHeaders = { ...model.headers }; + const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ @@ -745,6 +839,9 @@ function buildParams( params.tool_choice = options.toolChoice; } + const thinkingTokenBudgetField = resolveThinkingTokenBudgetField(compat); + const thinkingBudget = resolveClampedThinkingBudget(model, options, params); + if (compat.thinkingFormat === "zai" && model.reasoning) { const zaiParams = params as Omit & { thinking?: { type: "enabled" | "disabled"; clear_thinking?: boolean }; @@ -772,7 +869,7 @@ function buildParams( preserve_thinking: true, }; } else if (compat.thinkingFormat === "chat-template" && model.reasoning) { - const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs); + const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs, thinkingBudget); if (chatTemplateKwargs) { (params as any).chat_template_kwargs = chatTemplateKwargs; } @@ -781,7 +878,7 @@ function buildParams( chat_template_args?: Record; reasoning_effort?: string; }; - const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs); + const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs, thinkingBudget); if (chatTemplateArgs) { basetenParams.chat_template_args = chatTemplateArgs; } @@ -844,25 +941,12 @@ function buildParams( } } - // vLLM caps reasoning with a top-level thinking_token_budget. Independent of - // thinkingFormat: the same server can serve zai, qwen or chat-template models. - // Reasoning and the answer share max_tokens here, so an uncapped reasoning - // phase can consume the whole response and leave no answer and no tool call. - if (compat.supportsThinkingTokenBudget && options?.reasoningEffort && model.reasoning) { - const level = clampReasoning(options.reasoningEffort)!; - const budgets: ThinkingBudgets = { - minimal: 1024, - low: 2048, - medium: 8192, - high: 16384, - ...options.thinkingBudgets, - }; - const ceiling = (params as { max_tokens?: number }).max_tokens ?? params.max_completion_tokens ?? model.maxTokens; - // Always leave room for the answer, otherwise the budget recreates the bug it prevents. - const budget = Math.min(budgets[level]!, Math.max(0, ceiling - MIN_ANSWER_TOKENS)); - if (budget > 0) { - (params as { thinking_token_budget?: number }).thinking_token_budget = budget; - } + // Cap reasoning with a top-level budget field. Independent of thinkingFormat: the + // same server can serve zai, qwen or chat-template models. Reasoning and the answer + // share max_tokens here, so an uncapped reasoning phase can consume the whole + // response and leave no answer and no tool call. + if (thinkingTokenBudgetField && thinkingBudget !== undefined) { + Object.assign(params, { [thinkingTokenBudgetField]: thinkingBudget }); } // OpenRouter provider routing preferences @@ -889,15 +973,38 @@ function buildParams( return params; } +function resolveThinkingTokenBudgetField( + compat: Pick, +): ThinkingTokenBudgetField | undefined { + if (compat.thinkingTokenBudgetField) return compat.thinkingTokenBudgetField; + if (compat.supportsThinkingTokenBudget) return "thinking_token_budget"; + return undefined; +} + +function resolveClampedThinkingBudget( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + params: { max_tokens?: number | null; max_completion_tokens?: number | null }, +): number | undefined { + if (!options?.reasoningEffort || !model.reasoning) return undefined; + const ceiling = params.max_tokens ?? params.max_completion_tokens ?? model.maxTokens; + const budget = clampThinkingBudgetToAnswerRoom( + thinkingBudgetForLevel(options.reasoningEffort, options.thinkingBudgets), + ceiling, + ); + return budget > 0 ? budget : undefined; +} + function buildChatTemplateValues( model: Model<"openai-completions">, options: OpenAICompletionsOptions | undefined, values: Record, + thinkingBudget?: number, ): Record | undefined { const resolvedValues: Record = {}; for (const [key, value] of Object.entries(values)) { - const resolved = resolveChatTemplateKwargValue(model, options, value); + const resolved = resolveChatTemplateKwargValue(model, options, value, thinkingBudget); if (resolved !== undefined) { resolvedValues[key] = resolved; } @@ -910,6 +1017,7 @@ function resolveChatTemplateKwargValue( model: Model<"openai-completions">, options: OpenAICompletionsOptions | undefined, value: ChatTemplateKwargValue, + thinkingBudget?: number, ): ResolvedChatTemplateKwargValue | undefined { if (typeof value !== "object" || value === null) { return value; @@ -922,6 +1030,9 @@ function resolveChatTemplateKwargValue( if (value.$var === "thinking.enabled") { return !!reasoningEffort; } + if (value.$var === "thinking.budget") { + return thinkingBudget; + } const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off; return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined; @@ -1128,7 +1239,7 @@ export function convertMessages( } } else if (msg.role === "assistant") { // Some providers don't accept null content, use empty string instead - const assistantMsg: ChatCompletionAssistantMessageParam = { + const assistantMsg: ChatCompletionAssistantMessageParamWithReasoning = { role: "assistant", content: compat.requiresAssistantAfterToolResult ? "" : null, }; @@ -1145,9 +1256,18 @@ export function convertMessages( ); const assistantText = assistantTextParts.map((part) => part.text).join(""); - const nonEmptyThinkingBlocks = msg.content - .filter(isThinkingContentBlock) - .filter((block) => block.thinking.trim().length > 0); + const thinkingBlocks = msg.content.filter(isThinkingContentBlock); + const toolCalls = msg.content.filter(isToolCallBlock); + const signedReasoningDetails = thinkingBlocks + .map((block) => parseOpenAIReasoningDetails(block.thinkingSignature)) + .find((details) => details !== undefined); + const legacyReasoningDetails = toolCalls + .map((toolCall) => parseLegacyEncryptedReasoningDetail(toolCall.thoughtSignature)) + .filter((detail): detail is OpenAIEncryptedReasoningDetail => detail !== undefined); + const preservedReasoningDetails = + signedReasoningDetails ?? (legacyReasoningDetails.length > 0 ? legacyReasoningDetails : undefined); + + const nonEmptyThinkingBlocks = thinkingBlocks.filter((block) => block.thinking.trim().length > 0); if (nonEmptyThinkingBlocks.length > 0) { if (compat.requiresThinkingAsText) { // Convert thinking blocks to plain text (no tags to avoid model mimicking them) @@ -1165,13 +1285,16 @@ export function convertMessages( assistantMsg.content = assistantText; } - // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) - let signature = nonEmptyThinkingBlocks[0].thinkingSignature; - if (model.provider === "opencode-go" && signature === "reasoning") { - signature = "reasoning_content"; - } - if (signature && signature.length > 0) { - (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + // reasoning_details is the structured alternative to a raw reasoning field. + if (!preservedReasoningDetails) { + // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } + if (signature && isOpenAICompletionsReasoningField(signature)) { + assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + } } } } else if (assistantText.length > 0) { @@ -1183,7 +1306,6 @@ export function convertMessages( assistantMsg.content = assistantText; } - const toolCalls = msg.content.filter(isToolCallBlock); if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => { const customInputProperty = options?.grammarToolInputProperties?.get(tc.name); @@ -1206,26 +1328,16 @@ export function convertMessages( }, }; }); - const reasoningDetails = toolCalls - .filter((tc) => tc.thoughtSignature) - .map((tc) => { - try { - return JSON.parse(tc.thoughtSignature!); - } catch { - return null; - } - }) - .filter(Boolean); - if (reasoningDetails.length > 0) { - (assistantMsg as any).reasoning_details = reasoningDetails; - } + } + if (preservedReasoningDetails) { + assistantMsg.reasoning_details = preservedReasoningDetails; } if ( compat.requiresReasoningContentOnAssistantMessages && model.reasoning && - (assistantMsg as { reasoning_content?: string }).reasoning_content === undefined + assistantMsg.reasoning_content === undefined ) { - (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; + assistantMsg.reasoning_content = ""; } // Skip assistant messages that have no content and no tool calls. // Some providers require "either content or tool_calls, but not none". @@ -1363,7 +1475,7 @@ function convertTools( function: { name: tool.name, description: tool.description, - parameters: tool.parameters as Record, // TypeBox already generates JSON Schema + parameters: getJsonSchemaToolParameters(tool, strict) as Record, // Only include strict if provider supports it. Some reject unknown fields. ...(compat.supportsStrictMode !== false && { strict: strict ?? false }), }, @@ -1375,6 +1487,7 @@ function parseChunkUsage( rawUsage: { prompt_tokens?: number; completion_tokens?: number; + cached_tokens?: number; prompt_cache_hit_tokens?: number; prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; completion_tokens_details?: { reasoning_tokens?: number }; @@ -1382,11 +1495,15 @@ function parseChunkUsage( model: Model<"openai-completions">, ): AssistantMessage["usage"] { const promptTokens = rawUsage.prompt_tokens || 0; - const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; + const cacheReadTokens = + rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0; const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0; // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read - // tokens (hits). OpenAI does not document or emit cache_write_tokens, but + // tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use + // prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens, + // and Kimi documents top-level usage.cached_tokens on the final usage chunk. + // OpenAI does not document or emit cache_write_tokens, but // OpenRouter-compatible providers can include it as a separate write count. // OpenRouter's own provider/tests affirm the separate mapping: // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409 @@ -1457,6 +1574,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); + const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com"); const isNonStandard = isNvidia || @@ -1466,7 +1584,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet baseUrl.includes("api.x.ai") || isTogether || baseUrl.includes("chutes.ai") || - baseUrl.includes("deepseek.com") || + isDeepSeek || isZai || isMoonshot || provider === "opencode" || @@ -1477,6 +1595,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const useMaxTokens = baseUrl.includes("chutes.ai") || + isDeepSeek || isMoonshot || isCloudflareAiGateway || isTogether || @@ -1485,7 +1604,6 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet isZai; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); - const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const isOpenRouterDeveloperRoleModel = isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; @@ -1519,6 +1637,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet chatTemplateArgs: {}, zaiToolStream: false, supportsThinkingTokenBudget: false, + thinkingTokenBudgetField: undefined, supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsOpenAIGrammarTools: false, cacheControlFormat, @@ -1564,6 +1683,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion chatTemplateArgs: model.compat.chatTemplateArgs ?? detected.chatTemplateArgs, zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, supportsThinkingTokenBudget: model.compat.supportsThinkingTokenBudget ?? detected.supportsThinkingTokenBudget, + thinkingTokenBudgetField: model.compat.thinkingTokenBudgetField ?? detected.thinkingTokenBudgetField, supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools, cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index ad24c8d463c..dca994a6f32 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -36,6 +36,7 @@ import { appendGrammarToolInputJsonDelta, type GrammarToolInputJsonBuffer, getGrammarToolInput, + getJsonSchemaToolParameters, resolveGrammarConstrainedSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.ts"; @@ -119,6 +120,7 @@ export interface ConvertResponsesMessagesOptions { includeSystemPrompt?: boolean; grammarToolInputProperties?: ReadonlyMap; deferredTools?: ReadonlyMap; + deferredToolsMode?: "additional-tools" | "tool-search"; toolOptions?: ConvertResponsesToolsOptions; } @@ -210,10 +212,9 @@ export function convertResponsesMessages( } else if (msg.role === "assistant") { const output: ResponseInput = []; const assistantMsg = msg as AssistantMessage; - const isDifferentModel = - assistantMsg.model !== model.id && - assistantMsg.provider === model.provider && - assistantMsg.api === model.api; + const isSameProviderAndApi = assistantMsg.provider === model.provider && assistantMsg.api === model.api; + const isSameModel = isSameProviderAndApi && assistantMsg.model === model.id; + const isDifferentModel = isSameProviderAndApi && assistantMsg.model !== model.id; let textBlockIndex = 0; for (const block of msg.content) { @@ -261,6 +262,8 @@ export function convertResponsesMessages( itemId = undefined; } + const canReplayNamespace = isSameModel || options?.deferredTools?.has(toolCall.name) === true; + if (customInputProperty !== undefined) { output.push({ type: "custom_tool_call", @@ -270,6 +273,9 @@ export function convertResponsesMessages( input: sanitizeSurrogates( getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty), ), + ...(canReplayNamespace && toolCall.namespace !== undefined + ? { namespace: toolCall.namespace } + : {}), } satisfies ResponseOutputItem); } else { output.push({ @@ -278,6 +284,9 @@ export function convertResponsesMessages( call_id: callId, name: toolCall.name, arguments: JSON.stringify(toolCall.arguments), + ...(canReplayNamespace && toolCall.namespace !== undefined + ? { namespace: toolCall.namespace } + : {}), }); } } @@ -309,7 +318,13 @@ export function convertResponsesMessages( loadedToolNames.add(name); deferredTools.push(tool); } - if (deferredTools.length > 0) { + if (deferredTools.length > 0 && options?.deferredToolsMode === "additional-tools") { + messages.push({ + type: "additional_tools", + role: "developer", + tools: convertResponsesTools(deferredTools, options.toolOptions), + } satisfies ResponseInputItem); + } else if (deferredTools.length > 0 && options?.deferredToolsMode === "tool-search") { const names = deferredTools.map((tool) => tool.name); const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`; messages.push({ @@ -325,7 +340,7 @@ export function convertResponsesMessages( execution: "client", status: "completed", tools: convertResponsesTools(deferredTools, { - ...options?.toolOptions, + ...options.toolOptions, deferLoading: true, }), } satisfies ResponseToolSearchOutputItemParam); @@ -363,17 +378,18 @@ export function convertResponsesTools(tools: readonly Tool[], options?: ConvertR } const constrainedStrict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode); + const strict = constrainedStrict ?? defaultStrict; const functionTool: Omit, "strict"> & { strict?: Extract["strict"]; } = { type: "function", name: tool.name, description: tool.description, - parameters: tool.parameters as Record, // TypeBox already generates JSON Schema + parameters: getJsonSchemaToolParameters(tool, strict === true) as Record, ...(options?.deferLoading ? { defer_loading: true } : {}), }; if (supportsStrictMode) { - functionTool.strict = constrainedStrict ?? defaultStrict; + functionTool.strict = strict; } return functionTool as OpenAITool; }); @@ -472,6 +488,7 @@ export async function processResponsesStream( id: `${item.call_id}|${item.id}`, name: item.name, arguments: {}, + ...(item.namespace !== undefined ? { namespace: item.namespace } : {}), partialJson: item.arguments || "", }; output.content.push(block); @@ -492,6 +509,7 @@ export async function processResponsesStream( id: `${item.call_id}|${item.id}`, name: item.name, arguments: { [inputProperty]: input }, + ...(item.namespace !== undefined ? { namespace: item.namespace } : {}), customInput: { property: inputProperty, jsonBuffer: { input: "", started: false, closed: false }, @@ -693,6 +711,7 @@ export async function processResponsesStream( slot.block.partialJson !== undefined ) { slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}"); + if (item.namespace !== undefined) slot.block.namespace = item.namespace; // Finalize in-place and strip the scratch buffer so replay only // carries parsed arguments. delete slot.block.partialJson; @@ -708,6 +727,7 @@ export async function processResponsesStream( slot, appendCustomToolCallInput(slot.block, item.input ?? getCustomToolCallInput(slot.block), true), ); + if (item.namespace !== undefined) slot.block.namespace = item.namespace; delete slot.block.customInput; stream.push({ type: "toolcall_end", diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index b90de3b768f..6bfb3d65b86 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -19,6 +19,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -71,6 +72,7 @@ function getCompat(model: Model<"openai-responses">): Required { getClientApiKey(model.provider, options?.apiKey, options?.headers); - const base = buildBaseOptions(model, context, options, options?.apiKey); + const base = { + ...buildBaseOptions(model, context, options, options?.apiKey), + toolChoice: options?.toolChoice, + } satisfies OpenAIResponsesOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; @@ -219,7 +224,7 @@ function createClient( sessionId?: string, ) { const compat = getCompat(model); - const headers: ProviderHeaders = { ...model.headers }; + const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ @@ -264,10 +269,16 @@ function buildParams( compat.supportsOpenAIGrammarTools, ), ) { - const toolPlacement = splitDeferredTools(context, compat.supportsToolSearch); + const deferredToolsMode = compat.supportsAdditionalTools + ? "additional-tools" + : compat.supportsToolSearch + ? "tool-search" + : undefined; + const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined); const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, { grammarToolInputProperties, deferredTools: toolPlacement.deferred, + deferredToolsMode, toolOptions: { supportsStrictMode: compat.supportsStrictMode, supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools, diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 31a9dbbd72b..6bedc667476 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -427,7 +427,7 @@ export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = return stream(model, context, { ...options, reasoning: options?.reasoning, - toolChoice: extra?.toolChoice, + toolChoice: options?.toolChoice, debug: extra?.debug, }); }; diff --git a/packages/ai/src/api/simple-options.ts b/packages/ai/src/api/simple-options.ts index 067d01e5951..2a30265ad25 100644 --- a/packages/ai/src/api/simple-options.ts +++ b/packages/ai/src/api/simple-options.ts @@ -54,10 +54,28 @@ export function buildBaseOptions( /** Tokens always left for the answer when a thinking budget shares the response ceiling. */ export const MIN_ANSWER_TOKENS = 1024; +export const DEFAULT_THINKING_BUDGETS: ThinkingBudgets = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, +}; + export function clampReasoning(effort: ThinkingLevel | undefined): Exclude | undefined { return effort === "xhigh" || effort === "max" ? "high" : effort; } +export function thinkingBudgetForLevel(reasoningLevel: ThinkingLevel, customBudgets?: ThinkingBudgets): number { + const budgets = { ...DEFAULT_THINKING_BUDGETS, ...customBudgets }; + const level = clampReasoning(reasoningLevel)!; + return budgets[level]!; +} + +/** Cap a thinking budget so at least MIN_ANSWER_TOKENS remain under a shared response ceiling. */ +export function clampThinkingBudgetToAnswerRoom(thinkingBudget: number, ceiling: number): number { + return Math.min(thinkingBudget, Math.max(0, ceiling - MIN_ANSWER_TOKENS)); +} + export function adjustMaxTokensForThinking( // Undefined means no explicit caller cap. Use the model cap and fit thinking inside it. baseMaxTokens: number | undefined, @@ -65,21 +83,12 @@ export function adjustMaxTokensForThinking( reasoningLevel: ThinkingLevel, customBudgets?: ThinkingBudgets, ): { maxTokens: number; thinkingBudget: number } { - const defaultBudgets: ThinkingBudgets = { - minimal: 1024, - low: 2048, - medium: 8192, - high: 16384, - }; - const budgets = { ...defaultBudgets, ...customBudgets }; - - const level = clampReasoning(reasoningLevel)!; - let thinkingBudget = budgets[level]!; + let thinkingBudget = thinkingBudgetForLevel(reasoningLevel, customBudgets); const maxTokens = baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens); if (maxTokens <= thinkingBudget) { - thinkingBudget = Math.max(0, maxTokens - MIN_ANSWER_TOKENS); + thinkingBudget = clampThinkingBudgetToAnswerRoom(thinkingBudget, maxTokens); } return { maxTokens, thinkingBudget }; diff --git a/packages/ai/src/auth/oauth/device-code.ts b/packages/ai/src/auth/oauth/device-code.ts index f87c5e3b541..a078155c89a 100644 --- a/packages/ai/src/auth/oauth/device-code.ts +++ b/packages/ai/src/auth/oauth/device-code.ts @@ -23,7 +23,7 @@ export type OAuthDeviceCodePollOptions = { signal: AbortSignal; }; -function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise { +export function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error(cancelMessage)); diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index df38ee45e37..5a17d07f059 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -3,6 +3,7 @@ */ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"; +import { sleep } from "../../utils/sleep.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; @@ -89,48 +90,108 @@ function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } -function parseAvailableCopilotModelIds(raw: unknown, allowPolicyFallback: boolean): string[] { +function parseGitHubCopilotModelCatalog(raw: unknown, allowPolicyFallback: boolean) { const data = asRecord(raw)?.data; if (!Array.isArray(data)) { throw new Error("Invalid Copilot models response"); } - const pickerIds: string[] = []; - const policyEnabledIds: string[] = []; - for (const rawItem of data) { + const accountModels = data.flatMap((rawItem) => { const item = asRecord(rawItem); const id = item?.id; - if (!item || typeof id !== "string") continue; + if (!item || typeof id !== "string") return []; const capabilities = asRecord(item.capabilities); const supports = asRecord(capabilities?.supports); - if (supports?.tool_calls === false) continue; - const policy = asRecord(item.policy); - if (item.model_picker_enabled === true && policy?.state !== "disabled") pickerIds.push(id); - if (policy?.state === "enabled") policyEnabledIds.push(id); + if (supports?.tool_calls === false) return []; + + return [ + { + id, + pickerEnabled: item.model_picker_enabled === true, + policyState: asRecord(item.policy)?.state, + }, + ]; + }); + const pickerModelIds = accountModels + .filter((model) => model.pickerEnabled && model.policyState !== "disabled") + .map((model) => model.id); + const usePolicyFallback = allowPolicyFallback && pickerModelIds.length === 0; + const availableModelIds = + pickerModelIds.length > 0 || !allowPolicyFallback + ? pickerModelIds + : accountModels.filter((model) => model.policyState === "enabled").map((model) => model.id); + const policyModelIds = accountModels + .filter( + (model) => + model.policyState === "unconfigured" && + Object.hasOwn(GITHUB_COPILOT_MODELS, model.id) && + (model.pickerEnabled || usePolicyFallback), + ) + .map((model) => model.id); + return { availableModelIds, policyModelIds }; +} + +async function fetchWithRateLimitRetry( + url: string, + init: RequestInit, + signal: AbortSignal, + retryPolicy: { maxRetries: number; maxElapsedMs: number }, +): Promise { + const retryBudgetSignal = + retryPolicy.maxRetries > 0 && retryPolicy.maxElapsedMs > 0 + ? AbortSignal.timeout(retryPolicy.maxElapsedMs) + : undefined; + const requestSignal = retryBudgetSignal ? AbortSignal.any([signal, retryBudgetSignal]) : signal; + const retryDeadline = retryBudgetSignal ? Date.now() + retryPolicy.maxElapsedMs : undefined; + for (let retry = 0; ; retry++) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.any([requestSignal, AbortSignal.timeout(5000)]), + }); + if (response.status !== 429 || retry === retryPolicy.maxRetries) return response; + + const retryAfter = response.headers.get("retry-after"); + let delayMs = 500 * 2 ** retry; + if (retryAfter) { + const seconds = Number.parseFloat(retryAfter); + delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000; + if (!Number.isFinite(delayMs)) return response; + } + delayMs = Math.max(0, delayMs); + if (retryDeadline !== undefined && delayMs >= retryDeadline - Date.now()) return response; + await response.body?.cancel(); + await sleep(delayMs, requestSignal); } - return pickerIds.length > 0 || !allowPolicyFallback ? pickerIds : policyEnabledIds; } -async function fetchAvailableGitHubCopilotModelIds( +async function fetchGitHubCopilotModels( copilotToken: string, enterpriseDomain: string | undefined, signal: AbortSignal, -): Promise { + retryPolicy: { maxRetries: number; maxElapsedMs: number }, +) { const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain); // Some Individual accounts return false for every picker flag despite explicit enabled policies. // Limit the fallback to that endpoint so other account types keep strict picker semantics. const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com"; - const raw = await fetchJson(`${baseUrl}/models`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${copilotToken}`, - ...COPILOT_HEADERS, - "X-GitHub-Api-Version": COPILOT_API_VERSION, + const response = await fetchWithRateLimitRetry( + `${baseUrl}/models`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${copilotToken}`, + ...COPILOT_HEADERS, + "X-GitHub-Api-Version": COPILOT_API_VERSION, + }, }, - signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), - }); - return parseAvailableCopilotModelIds(raw, allowPolicyFallback); + signal, + retryPolicy, + ); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return parseGitHubCopilotModelCatalog(await response.json(), allowPolicyFallback); } async function fetchJson(url: string, init: RequestInit): Promise { @@ -295,9 +356,13 @@ async function refreshGitHubCopilotToken( signal: AbortSignal, ): Promise { const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain, signal); + const { availableModelIds } = await fetchGitHubCopilotModels(credentials.access, enterpriseDomain, signal, { + maxRetries: 0, + maxElapsedMs: 0, + }); return { ...credentials, - availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain, signal), + availableModelIds, }; } @@ -314,41 +379,56 @@ async function enableGitHubCopilotModel( const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain); const url = `${baseUrl}/models/${modelId}/policy`; + let response: Response; try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...COPILOT_HEADERS, - "openai-intent": "chat-policy", - "x-interaction-type": "chat-policy", + response = await fetchWithRateLimitRetry( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, + "openai-intent": "chat-policy", + "x-interaction-type": "chat-policy", + }, + body: JSON.stringify({ state: "enabled" }), }, - body: JSON.stringify({ state: "enabled" }), signal, - }); - return response.ok; + { maxRetries: 2, maxElapsedMs: 5000 }, + ); } catch (error) { if (signal.aborted) throw error; return false; } + if (response.status === 429) { + throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return response.ok; } /** - * Enable all known GitHub Copilot models that may require policy acceptance. - * Called after successful login to ensure all models are available. + * Enable the requested GitHub Copilot models and return the successful IDs. + * Policy updates are best effort; exhausted rate limiting stops the batch. */ -async function enableAllGitHubCopilotModels( +async function enableGitHubCopilotModels( token: string, + modelIds: readonly string[], enterpriseDomain: string | undefined, signal: AbortSignal, -): Promise { - const models = Object.values(GITHUB_COPILOT_MODELS); - await Promise.all( - models.map(async (model) => { - await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); - }), - ); +): Promise { + const enabledModelIds: string[] = []; + for (const modelId of modelIds) { + try { + if (await enableGitHubCopilotModel(token, modelId, enterpriseDomain, signal)) { + enabledModelIds.push(modelId); + } + } catch (error) { + if (signal.aborted) throw error; + break; + } + } + return enabledModelIds; } async function loginGitHubCopilot(interaction: ProviderAuthInteraction): Promise { @@ -379,15 +459,28 @@ async function loginGitHubCopilot(interaction: ProviderAuthInteraction): Promise enterpriseDomain ?? undefined, interaction.signal, ); - interaction.notify({ type: "progress", message: "Enabling models..." }); - await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined, interaction.signal); - return { - ...credentials, - availableModelIds: await fetchAvailableGitHubCopilotModelIds( + const models = await fetchGitHubCopilotModels( + credentials.access, + enterpriseDomain ?? undefined, + interaction.signal, + { + maxRetries: 2, + maxElapsedMs: 5000, + }, + ); + let enabledModelIds: string[] = []; + if (models.policyModelIds.length > 0) { + interaction.notify({ type: "progress", message: "Enabling models..." }); + enabledModelIds = await enableGitHubCopilotModels( credentials.access, + models.policyModelIds, enterpriseDomain ?? undefined, interaction.signal, - ), + ); + } + return { + ...credentials, + availableModelIds: [...new Set([...models.availableModelIds, ...enabledModelIds])], }; } diff --git a/packages/ai/src/auth/oauth/kimi-coding.ts b/packages/ai/src/auth/oauth/kimi-coding.ts index 349b5a14129..6c5bd49532f 100644 --- a/packages/ai/src/auth/oauth/kimi-coding.ts +++ b/packages/ai/src/auth/oauth/kimi-coding.ts @@ -7,6 +7,7 @@ */ import { getProviderEnvValue } from "../../utils/provider-env.ts"; +import { sleep } from "../../utils/sleep.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; @@ -206,21 +207,6 @@ async function pollForToken( }); } -function sleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - signal.throwIfAborted(); - const onAbort = () => { - clearTimeout(timeout); - reject(signal.reason); - }; - const timeout = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal.addEventListener("abort", onAbort, { once: true }); - }); -} - function isRetryableRefreshFailure(response: Response): boolean { return response.status === 429 || response.status >= 500; } diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index f535f53abbd..24b82b52a9b 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -77,9 +77,11 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { } const envMap: Record = { + aimlapi: "AIMLAPI_API_KEY", "ant-ling": "ANT_LING_API_KEY", "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY", "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", + "qwen-token-plan-individual": "QWEN_TOKEN_PLAN_API_KEY", openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", nvidia: "NVIDIA_API_KEY", diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 2679897286f..80bb8f77dee 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -80,6 +80,36 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "bytedance-seed/seedream-5-0-lite": { + id: "bytedance-seed/seedream-5-0-lite", + name: "ByteDance Seed: Seedream 5.0 Lite", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "bytedance-seed/seedream-5-0-pro": { + id: "bytedance-seed/seedream-5-0-pro", + name: "ByteDance Seed: Seedream 5.0 Pro", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "google/gemini-2.5-flash-image": { id: "google/gemini-2.5-flash-image", name: "Google: Nano Banana (Gemini 2.5 Flash Image)", @@ -620,6 +650,21 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "x-ai/grok-imagine-image-2.0": { + id: "x-ai/grok-imagine-image-2.0", + name: "xAI: Grok Imagine Image 2.0", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "x-ai/grok-imagine-image-quality": { id: "x-ai/grok-imagine-image-quality", name: "SpaceXAI: Grok Imagine Image Quality", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4ff678102d5..82bac973852 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -10,7 +10,7 @@ export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from export type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts"; export type { BedrockOptions, BedrockThinkingDisplay } from "./api/bedrock-converse-stream.ts"; export type { GoogleOptions } from "./api/google-generative-ai.ts"; -export type { GoogleThinkingLevel } from "./api/google-shared.ts"; +export type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./api/google-shared.ts"; export type { GoogleVertexOptions } from "./api/google-vertex.ts"; export * from "./api/lazy.ts"; export type { MistralOptions } from "./api/mistral-conversations.ts"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8832e4c83d9..bc5cd3ec9c4 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1,6 +1,7 @@ // This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update +import { AIMLAPI_MODELS } from "./providers/aimlapi.models.ts"; import { AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts"; import { ANT_LING_MODELS } from "./providers/ant-ling.models.ts"; import { ANTHROPIC_MODELS } from "./providers/anthropic.models.ts"; @@ -30,6 +31,7 @@ import { OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts"; import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts"; import { QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts"; import { QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts"; +import { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.models.ts"; import { TOGETHER_MODELS } from "./providers/together.models.ts"; import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts"; import { XAI_MODELS } from "./providers/xai.models.ts"; @@ -41,6 +43,7 @@ import { ZAI_MODELS } from "./providers/zai.models.ts"; import { ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts"; export const MODELS: { + readonly "aimlapi": typeof AIMLAPI_MODELS; readonly "amazon-bedrock": typeof AMAZON_BEDROCK_MODELS; readonly "ant-ling": typeof ANT_LING_MODELS; readonly "anthropic": typeof ANTHROPIC_MODELS; @@ -70,6 +73,7 @@ export const MODELS: { readonly "openrouter": typeof OPENROUTER_MODELS; readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_MODELS; readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_MODELS; + readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS; readonly "together": typeof TOGETHER_MODELS; readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS; readonly "xai": typeof XAI_MODELS; @@ -80,6 +84,7 @@ export const MODELS: { readonly "zai": typeof ZAI_MODELS; readonly "zai-coding-cn": typeof ZAI_CODING_CN_MODELS; } = { + "aimlapi": AIMLAPI_MODELS, "amazon-bedrock": AMAZON_BEDROCK_MODELS, "ant-ling": ANT_LING_MODELS, "anthropic": ANTHROPIC_MODELS, @@ -109,6 +114,7 @@ export const MODELS: { "openrouter": OPENROUTER_MODELS, "qwen-token-plan": QWEN_TOKEN_PLAN_MODELS, "qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_MODELS, + "qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS, "together": TOGETHER_MODELS, "vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS, "xai": XAI_MODELS, diff --git a/packages/ai/src/providers/aimlapi.models.ts b/packages/ai/src/providers/aimlapi.models.ts new file mode 100644 index 00000000000..d317a815c71 --- /dev/null +++ b/packages/ai/src/providers/aimlapi.models.ts @@ -0,0 +1,8 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/aimlapi.json" with { type: "json" }; +import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts"; + +export const AIMLAPI_MODELS: ModelCatalog = + flattenModelCatalog("aimlapi", values); diff --git a/packages/ai/src/providers/aimlapi.ts b/packages/ai/src/providers/aimlapi.ts new file mode 100644 index 00000000000..21098fd82e9 --- /dev/null +++ b/packages/ai/src/providers/aimlapi.ts @@ -0,0 +1,17 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { AIMLAPI_MODELS } from "./aimlapi.models.ts"; + +export function aimlapiProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "aimlapi", + name: "AI/ML API", + baseUrl: "https://api.aimlapi.com/v1", + auth: { + apiKey: envApiKeyAuth("AI/ML API key", ["AIMLAPI_API_KEY"]), + }, + models: Object.values(AIMLAPI_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 5e62f8d0a11..78d1cb3c530 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -2,6 +2,7 @@ import { createImagesModels, type ImagesProvider, type MutableImagesModels } fro import { MODELS } from "../models.generated.ts"; import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts"; import type { Api, Model } from "../types.ts"; +import { aimlapiProvider } from "./aimlapi.ts"; import { amazonBedrockProvider } from "./amazon-bedrock.ts"; import { antLingProvider } from "./ant-ling.ts"; import { anthropicProvider } from "./anthropic.ts"; @@ -33,6 +34,7 @@ import { openrouterProvider } from "./openrouter.ts"; import { openrouterImagesProvider } from "./openrouter-images.ts"; import { qwenTokenPlanProvider } from "./qwen-token-plan.ts"; import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts"; +import { qwenTokenPlanIndividualProvider } from "./qwen-token-plan-individual.ts"; import { radiusProvider } from "./radius.ts"; import { togetherProvider } from "./together.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; @@ -87,6 +89,7 @@ export function getBuiltinModels( /** All built-in providers, freshly constructed. */ export function builtinProviders(): Provider[] { return [ + aimlapiProvider(), amazonBedrockProvider(), antLingProvider(), anthropicProvider(), @@ -116,6 +119,7 @@ export function builtinProviders(): Provider[] { openrouterProvider(), qwenTokenPlanProvider(), qwenTokenPlanCnProvider(), + qwenTokenPlanIndividualProvider(), radiusProvider(), togetherProvider(), vercelAIGatewayProvider(), diff --git a/packages/ai/src/providers/cloudflare-ai-gateway.ts b/packages/ai/src/providers/cloudflare-ai-gateway.ts index 50c10569ff0..7c1cc3b78a1 100644 --- a/packages/ai/src/providers/cloudflare-ai-gateway.ts +++ b/packages/ai/src/providers/cloudflare-ai-gateway.ts @@ -6,10 +6,10 @@ import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts" import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts"; import { cloudflareStreams } from "./cloudflare-stream.ts"; -export function cloudflareAIGatewayProvider(): Provider< - "anthropic-messages" | "openai-completions" | "openai-responses" -> { - return createProvider({ +type CloudflareAIGatewayApi = "anthropic-messages" | "openai-completions" | "openai-responses"; + +export function cloudflareAIGatewayProvider(): Provider { + return createProvider({ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: cloudflareAIGatewayAuth() }, diff --git a/packages/ai/src/providers/qwen-token-plan-individual.models.ts b/packages/ai/src/providers/qwen-token-plan-individual.models.ts new file mode 100644 index 00000000000..30e111a2943 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual.models.ts @@ -0,0 +1,8 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-token-plan-individual.json" with { type: "json" }; +import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts"; + +export const QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS: ModelCatalog = + flattenModelCatalog("qwen-token-plan-individual", values); diff --git a/packages/ai/src/providers/qwen-token-plan-individual.ts b/packages/ai/src/providers/qwen-token-plan-individual.ts new file mode 100644 index 00000000000..a231d1b313d --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./qwen-token-plan-individual.models.ts"; + +export function qwenTokenPlanIndividualProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan-individual", + name: "Qwen Token Plan Individual", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan Individual API key", ["QWEN_TOKEN_PLAN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index c9fe6c349e9..8e2b179c28a 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,11 +1,10 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { loadXaiOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { XAI_MODELS } from "./xai.models.ts"; -export function xaiProvider(): Provider<"openai-completions" | "openai-responses"> { +export function xaiProvider(): Provider<"openai-responses"> { return createProvider({ id: "xai", name: "xAI", @@ -20,9 +19,6 @@ export function xaiProvider(): Provider<"openai-completions" | "openai-responses }), }, models: Object.values(XAI_MODELS), - api: { - "openai-completions": openAICompletionsApi(), - "openai-responses": openAIResponsesApi(), - }, + api: openAIResponsesApi(), }); } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 1cdacdbae0f..0127d323acf 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -33,6 +33,7 @@ export type KnownImagesApi = "openrouter-images"; export type ImagesApi = KnownImagesApi | (string & {}); export type KnownProvider = + | "aimlapi" | "amazon-bedrock" | "ant-ling" | "anthropic" @@ -68,6 +69,7 @@ export type KnownProvider = | "cloudflare-ai-gateway" | "qwen-token-plan" | "qwen-token-plan-cn" + | "qwen-token-plan-individual" | "xiaomi" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" @@ -78,6 +80,7 @@ export type KnownImagesProvider = "openrouter"; export type ImagesProviderId = KnownImagesProvider | string; +export type ToolChoice = "auto" | "none"; export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; export type ModelThinkingLevel = "off" | ThinkingLevel; export type ThinkingLevelMap = Partial>; @@ -87,10 +90,13 @@ export type ChatTemplateKwargValue = | boolean | null | { - $var: "thinking.enabled" | "thinking.effort"; + $var: "thinking.enabled" | "thinking.effort" | "thinking.budget"; omitWhenOff?: boolean; }; +/** Top-level request field used to cap reasoning tokens on OpenAI-compatible servers. */ +export type ThinkingTokenBudgetField = "thinking_token_budget" | "thinking_budget" | "thinking_budget_tokens"; + /** Token budgets for each thinking level (token-based providers only) */ export interface ThinkingBudgets { minimal?: number; @@ -299,8 +305,16 @@ export interface ImagesOptions extends ProviderRequestOptions; +export interface AnthropicAllowedFallbackModel { + provider: ProviderId; + model: string; + cost: ModelCost; +} + // Unified options with reasoning passed to streamSimple() and completeSimple() export interface SimpleStreamOptions extends StreamOptions { + /** Provider-neutral tool selection for simple requests. Default: "auto". */ + toolChoice?: ToolChoice; reasoning?: ThinkingLevel; /** Ask a capable provider to return a durable handle and continue the request asynchronously. */ deferred?: boolean | { window?: "15m" | "1h" | "24h" }; @@ -343,7 +357,7 @@ export interface TextContent { export interface ThinkingContent { type: "thinking"; thinking: string; - thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID + thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data /** When true, the thinking content was redacted by safety filters. The opaque * encrypted payload is stored in `thinkingSignature` so it can be passed back * to the API for multi-turn continuity. */ @@ -362,6 +376,8 @@ export interface ToolCall { name: string; arguments: Record; thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context + /** OpenAI Responses namespace for calls to dynamically loaded or namespaced tools. */ + namespace?: string; } export interface Usage { @@ -423,6 +439,11 @@ export interface AssistantMessage { deferred?: DeferredHandle; errorMessage?: string; rawStopReason?: string; + /** + * Provider indication of whether the model explicitly ended its turn. + * Preserved for debugging and does not currently affect agent control flow. + */ + endTurn?: boolean; timestamp: number; // Unix timestamp in milliseconds } @@ -568,9 +589,9 @@ export interface OpenAICompletionsCompat { | "qwen-chat-template" | "string-thinking" | "ant-ling"; - /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */ + /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */ chatTemplateKwargs?: Record; - /** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */ + /** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */ chatTemplateArgs?: Record; /** OpenRouter-compatible routing preferences sent as the `provider` request field. */ openRouterRouting?: OpenRouterRouting; @@ -578,7 +599,15 @@ export interface OpenAICompletionsCompat { vercelGatewayRouting?: VercelGatewayRouting; /** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */ zaiToolStream?: boolean; - /** Whether the provider supports top-level `thinking_token_budget` to cap reasoning tokens (vLLM). Reasoning and the answer share `max_tokens` on these endpoints, so without a budget a reasoning-heavy turn can consume the whole response and emit no answer. Default: false. */ + /** + * Top-level request field used to cap reasoning tokens from `thinkingBudgets`. + * Reasoning and the answer share `max_tokens` on these endpoints, so without a budget a + * reasoning-heavy turn can consume the whole response and emit no answer. + * `"thinking_token_budget"` is vLLM, `"thinking_budget"` is Qwen/DashScope/SGLang, + * `"thinking_budget_tokens"` is llama.cpp. Off by default; not set on the generated catalog. + */ + thinkingTokenBudgetField?: ThinkingTokenBudgetField; + /** Alias for `thinkingTokenBudgetField: "thinking_token_budget"` (vLLM). Prefer `thinkingTokenBudgetField`. Default: false. */ supportsThinkingTokenBudget?: boolean; /** Whether the provider supports OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */ supportsOpenAIGrammarTools?: boolean; @@ -608,6 +637,8 @@ export interface OpenAIResponsesCompat { supportsStrictMode?: boolean; /** Whether to emit OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */ supportsOpenAIGrammarTools?: boolean; + /** Whether the model supports message-anchored `additional_tools` input items. Default: false. */ + supportsAdditionalTools?: boolean; /** Whether the model supports client-executed tool search for deferred tools. Default: false. */ supportsToolSearch?: boolean; /** Whether the model accepts `prompt_cache_options` (OpenAI GPT-5.6+ explicit prompt caching). Older OpenAI models reject the parameter. Default: false. */ @@ -662,6 +693,13 @@ export interface AnthropicMessagesCompat { allowEmptySignature?: boolean; /** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */ supportsStrictTools?: boolean; + /** + * Models Anthropic accepts in `fallbacks` for server-side refusal fallback, + * with local pricing metadata for returned fallback responses. When absent or + * empty, callers must omit `fallbacks`; Anthropic rejects the field for models + * with no permitted fallback targets. + */ + allowedFallbackModels?: AnthropicAllowedFallbackModel[]; /** * Whether the provider supports deferred tools loaded by `tool_reference` * blocks in tool results. Default: true for first-party Anthropic models diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts new file mode 100644 index 00000000000..93b23dd3511 --- /dev/null +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -0,0 +1,19 @@ +import type * as NodeOs from "node:os"; + +type ProcessWithOsBuiltinModule = typeof process & { + getBuiltinModule?: (id: "node:os") => typeof NodeOs; +}; + +function loadNodeOs(): typeof NodeOs | null { + if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { + return null; + } + return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; +} + +// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds. +const nodeOs = loadNodeOs(); + +export function getPiUserAgent(): string { + return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; +} diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index 463b90329c6..b17bf091473 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -41,6 +41,7 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ // Wrapper/provider text for transient upstream failures, including OpenRouter // "Provider returned error" responses (#2264). "provider.?returned.?error", + "exceeded request buffer limit while retrying upstream", // Network, proxy, and fetch transport failures. This includes OpenAI Codex // raw-fetch failures such as "upstream connect", "connection refused", and diff --git a/packages/ai/src/utils/sleep.ts b/packages/ai/src/utils/sleep.ts new file mode 100644 index 00000000000..67b97aef90a --- /dev/null +++ b/packages/ai/src/utils/sleep.ts @@ -0,0 +1,14 @@ +export function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const onAbort = () => { + clearTimeout(timeout); + reject(signal.reason); + }; + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 17a00b8ec23..cf6902df263 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -9,6 +9,7 @@ const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); interface JsonSchemaObject { type?: string | string[]; properties?: Record; + required?: string[]; items?: JsonSchemaObject | JsonSchemaObject[]; additionalProperties?: boolean | JsonSchemaObject; allOf?: JsonSchemaObject[]; @@ -236,6 +237,37 @@ function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown return nextValue; } +function normalizeOptionalNulls(value: unknown, schema: JsonSchemaObject): void { + if (Array.isArray(value)) { + if (Array.isArray(schema.items)) { + for (let index = 0; index < value.length; index++) { + const itemSchema = schema.items[index]; + if (itemSchema) normalizeOptionalNulls(value[index], itemSchema); + } + } else if (schema.items) { + for (const item of value) normalizeOptionalNulls(item, schema.items); + } + return; + } + if (typeof value !== "object" || value === null || !schema.properties) return; + + const object = value as Record; + const required = new Set(schema.required ?? []); + for (const [key, propertySchema] of Object.entries(schema.properties)) { + if (!(key in object)) continue; + if ( + object[key] === null && + !required.has(key) && + typeof (propertySchema as { $ref?: unknown }).$ref !== "string" && + getSubSchemaValidator(propertySchema)?.Check(null) === false + ) { + delete object[key]; + } else { + normalizeOptionalNulls(object[key], propertySchema); + } + } +} + function getValidator(schema: Tool["parameters"]): ReturnType { const key = schema as object; const cached = validatorCache.get(key); @@ -284,6 +316,7 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { */ export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { const args = structuredClone(toolCall.arguments); + normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject); Value.Convert(tool.parameters, args); const validator = getValidator(tool.parameters); diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index dedee5cc36c..767be7d04d5 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -273,6 +273,18 @@ describe("AI Providers Abort Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Abort", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Abort", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts index e2780a1750f..2bad50181a4 100644 --- a/packages/ai/test/anthropic-auth-token.test.ts +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts"; @@ -51,6 +52,7 @@ vi.mock("@anthropic-ai/sdk", () => { return { default: FakeAnthropic }; }); +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; const neverAbortedSignal = new AbortController().signal; const context: Context = { @@ -71,6 +73,14 @@ const anthropicModel: Model<"anthropic-messages"> = { maxTokens: 4096, }; +const kimiCodingModel: Model<"anthropic-messages"> = { + ...anthropicModel, + id: "kimi-for-coding", + name: "Kimi For Coding", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", +}; + afterEach(() => { mockState.constructorOpts = undefined; mockState.createParams = undefined; @@ -185,3 +195,22 @@ describe("Anthropic auth token env", () => { expect(headers.Authorization).toBe("Bearer explicit-token"); }); }); + +describe("Anthropic-compatible user agents", () => { + it("uses pi's User-Agent by default for Anthropic Messages requests", async () => { + await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result(); + + const headers = mockState.constructorOpts?.defaultHeaders as Record; + expect(headers["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default Anthropic Messages User-Agent", async () => { + await streamAnthropic(kimiCodingModel, context, { + apiKey: "kimi-key", + headers: { "User-Agent": "custom-client" }, + }).result(); + + const headers = mockState.constructorOpts?.defaultHeaders as Record; + expect(headers["User-Agent"]).toBe("custom-client"); + }); +}); diff --git a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts index 39be3a9a1b4..37280cb49e3 100644 --- a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts @@ -39,7 +39,10 @@ const schemaCompatibilityTool: Tool = { const strictTool: Tool = { ...tool, - parameters: Type.Object({ value: Type.String() }, { additionalProperties: false, title: "StrictLookupInput" }), + parameters: Type.Object( + { value: Type.String(), optional: Type.Optional(Type.Number()) }, + { title: "StrictLookupInput" }, + ), constrainedSampling: { type: "json_schema", strict: "prefer" }, }; @@ -155,6 +158,8 @@ describe("Anthropic eager tool input streaming compatibility", () => { expect(getFirstTool(strictRequest.body).strict).toBe(true); expect(getFirstToolInputSchema(strictRequest.body)).toMatchObject({ additionalProperties: false, + required: ["value", "optional"], + properties: { optional: { anyOf: [{ type: "number" }, { type: "null" }] } }, title: "StrictLookupInput", }); }); diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 12e3fb25e6a..b317fd79f00 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts"; @@ -40,6 +41,8 @@ vi.mock("openai", () => { return { AzureOpenAI }; }); +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; @@ -92,6 +95,17 @@ async function captureClientBaseUrl(baseUrl: string): Promise { return azureMock.constructorCalls[0].baseURL; } +async function captureClientHeaders(headers?: Record): Promise> { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + headers, + }).result(); + expect(azureMock.constructorCalls).toHaveLength(1); + return azureMock.constructorCalls[0].defaultHeaders ?? {}; +} + describe("azure-openai-responses base URL normalization", () => { it("normalizes Cognitive Services root endpoints to /openai/v1", async () => { const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.cognitiveservices.azure.com"); @@ -201,3 +215,13 @@ describe("azure-openai-responses base URL normalization", () => { expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1"); }); }); + +describe("azure-openai-responses user agent", () => { + it("uses pi's User-Agent by default", async () => { + expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default User-Agent", async () => { + expect((await captureClientHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent"); + }); +}); diff --git a/packages/ai/test/azure-openai-tool-choice.test.ts b/packages/ai/test/azure-openai-tool-choice.test.ts new file mode 100644 index 00000000000..3ce768e1c63 --- /dev/null +++ b/packages/ai/test/azure-openai-tool-choice.test.ts @@ -0,0 +1,79 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { stream, streamSimple } from "../src/api/azure-openai-responses.ts"; +import type { Model } from "../src/types.ts"; + +const model: Model<"azure-openai-responses"> = { + id: "test-deployment", + name: "Test Deployment", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "http://127.0.0.1:9/openai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 10_000, + maxTokens: 1_000, +}; + +describe("Azure OpenAI tool choice", () => { + it("forwards provider-specific tool choice while preserving tool definitions", async () => { + let payload: unknown; + const result = stream( + model, + { + messages: [{ role: "user", content: "Summarize this", timestamp: 1 }], + tools: [ + { + name: "read", + description: "Read a file", + parameters: Type.Object({ path: Type.String() }), + }, + ], + }, + { + apiKey: "test-key", + toolChoice: "required", + onPayload: (requestPayload) => { + payload = requestPayload; + throw new Error("payload captured"); + }, + }, + ); + + await result.result(); + + expect(payload).toMatchObject({ tool_choice: "required" }); + expect((payload as { tools?: unknown[] }).tools).toHaveLength(1); + }); + + it("forwards provider-neutral tool choice from simple options", async () => { + let payload: unknown; + const result = streamSimple( + model, + { + messages: [{ role: "user", content: "Summarize this", timestamp: 1 }], + tools: [ + { + name: "read", + description: "Read a file", + parameters: Type.Object({ path: Type.String() }), + }, + ], + }, + { + apiKey: "test-key", + toolChoice: "none", + onPayload: (requestPayload) => { + payload = requestPayload; + throw new Error("payload captured"); + }, + }, + ); + + await result.result(); + + expect(payload).toMatchObject({ tool_choice: "none" }); + expect((payload as { tools?: unknown[] }).tools).toHaveLength(1); + }); +}); diff --git a/packages/ai/test/baseten-models.test.ts b/packages/ai/test/baseten-models.test.ts index 60e8cf74573..3157747fc96 100644 --- a/packages/ai/test/baseten-models.test.ts +++ b/packages/ai/test/baseten-models.test.ts @@ -31,7 +31,7 @@ describe("Baseten models", () => { xhigh: null, max: "max", }, - input: ["text"], + input: ["text", "image"], contextWindow: 1048576, maxTokens: 262144, cost: { diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts index 5d5b7c319e0..fbc4330438b 100644 --- a/packages/ai/test/bedrock-convert-messages.test.ts +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; const bedrockMock = vi.hoisted(() => ({ constructorCalls: [] as Array>, + streamEvents: undefined as unknown[] | undefined, })); vi.mock("@aws-sdk/client-bedrock-runtime", () => { @@ -13,7 +14,16 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { bedrockMock.constructorCalls.push(config); } - send(): Promise { + send(): Promise { + if (bedrockMock.streamEvents) { + const events = bedrockMock.streamEvents; + return Promise.resolve({ + $metadata: { httpStatusCode: 200 }, + stream: (async function* () { + yield* events; + })(), + }); + } return Promise.reject(new Error("mock send")); } } @@ -46,10 +56,29 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { }); import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; -import { getModel } from "../src/compat.ts"; -import type { Context, Message } from "../src/types.ts"; +import type { Context, Message, Model } from "../src/types.ts"; + +const baseModel: Model<"bedrock-converse-stream"> = { + id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 64000, + compat: { supportsStrictMode: true }, +}; -const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); +const novaModel: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "amazon.nova-lite-v1:0", + name: "Nova Lite", + reasoning: false, + compat: undefined, +}; async function capturePayload(context: Context, model = baseModel): Promise { let capturedPayload: unknown; @@ -85,7 +114,7 @@ describe("Bedrock constrained sampling", () => { expect(toolConfig.tools[0].toolSpec.strict).toBe(true); context.tools![0].constrainedSampling = { type: "json_schema", strict: "prefer" }; - const novaPayload = await capturePayload(context, getModel("amazon-bedrock", "amazon.nova-lite-v1:0")); + const novaPayload = await capturePayload(context, novaModel); const novaToolConfig = ( novaPayload as { toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> }; @@ -95,6 +124,55 @@ describe("Bedrock constrained sampling", () => { }); }); +describe("Bedrock tool arguments", () => { + it("preserves empty property names in streamed tool arguments", async () => { + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "tool-1", name: "edit" } }, + }, + }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { + toolUse: { + input: '{"path":"/workspace/foobar/file.js","edits":[{"oldText":"first","newText":"updated first"},{"oldText":"second","newText":"updated second","":""}]}', + }, + }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: "tool_use" } }, + ]; + + try { + const message = await streamBedrock( + baseModel, + { messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] }, + { cacheRetention: "none" }, + ).result(); + + expect(message.content[0]).toEqual({ + type: "toolCall", + id: "tool-1", + name: "edit", + arguments: { + path: "/workspace/foobar/file.js", + edits: [ + { oldText: "first", newText: "updated first" }, + { oldText: "second", newText: "updated second", "": "" }, + ], + }, + }); + } finally { + bedrockMock.streamEvents = undefined; + } + }); +}); + describe("bedrock convertMessages skips unknown content types", () => { it("skips unknown user content blocks instead of throwing", async () => { const messages: Message[] = [ @@ -271,4 +349,62 @@ describe("bedrock convertMessages skips unknown content types", () => { const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; expect(p.messages).toHaveLength(0); }); + + it("removes empty property names only from replayed Bedrock input", async () => { + const toolArguments = { + path: "/workspace/foobar/file.js", + edits: [ + { oldText: "first", newText: "updated first" }, + { oldText: "second", newText: "updated second", "": "" }, + ], + }; + const messages: Message[] = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "tool-1", + name: "edit", + arguments: toolArguments, + }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "edit", + content: [{ type: "text", text: "done" }], + isError: false, + timestamp: Date.now(), + }, + { role: "user", content: "Continue", timestamp: Date.now() }, + ]; + + const payload = await capturePayload({ messages }); + const p = payload as { + messages: Array<{ content: Array<{ toolUse?: { input: unknown } }> }>; + }; + expect(p.messages[0].content[0].toolUse?.input).toEqual({ + path: "/workspace/foobar/file.js", + edits: [ + { oldText: "first", newText: "updated first" }, + { oldText: "second", newText: "updated second" }, + ], + }); + expect(toolArguments.edits[1]).toEqual({ oldText: "second", newText: "updated second", "": "" }); + }); }); diff --git a/packages/ai/test/bedrock-redacted-reasoning.test.ts b/packages/ai/test/bedrock-redacted-reasoning.test.ts new file mode 100644 index 00000000000..86301e155ae --- /dev/null +++ b/packages/ai/test/bedrock-redacted-reasoning.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * OpenAI models served through Bedrock Converse (e.g. `global.openai.gpt-5.6-terra`) + * return encrypted reasoning as the opaque `redactedContent` member of + * `reasoningContent`, not as `reasoningText`. The AWS SDK decodes the wire blob to + * `Uint8Array`. + * @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlockDelta.html + */ +const bedrockMock = vi.hoisted(() => { + const redactedBase64 = "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVOaWtSUWJF"; + return { + redactedBase64, + redactedBytes: new Uint8Array(Buffer.from(redactedBase64, "base64")), + streamEvents: undefined as unknown[] | undefined, + }; +}); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + send(): Promise { + if (bedrockMock.streamEvents) { + const events = bedrockMock.streamEvents; + return Promise.resolve({ + $metadata: { httpStatusCode: 200 }, + stream: (async function* () { + yield* events; + })(), + }); + } + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; +import type { Context, Message, Model, ThinkingContent } from "../src/types.ts"; + +const gptModel: Model<"bedrock-converse-stream"> = { + id: "global.openai.gpt-5.6-terra", + name: "GPT-5.6 Terra (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.ap-northeast-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, +}; + +const emptyUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +/** Mirrors the ConverseStream frames GPT-5.6 emits: encrypted reasoning, then text. */ +function redactedReasoningEvents(): unknown[] { + return [ + { messageStart: { role: "assistant" } }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { contentBlockDelta: { contentBlockIndex: 1, delta: { text: "done" } } }, + { contentBlockStop: { contentBlockIndex: 1 } }, + { messageStop: { stopReason: "end_turn" } }, + ]; +} + +interface BedrockRequestPayload { + messages: Array<{ role: string; content: Array> }>; +} + +async function capturePayload(context: Context): Promise { + let capturedPayload: BedrockRequestPayload | undefined; + const s = streamBedrock(gptModel, context, { + cacheRetention: "none", + signal: AbortSignal.abort(), + onPayload: (payload) => { + capturedPayload = payload as BedrockRequestPayload; + return payload; + }, + }); + for await (const event of s) { + if (event.type === "error") break; + } + if (!capturedPayload) { + throw new Error("Expected Bedrock payload to be captured before request abort"); + } + return capturedPayload; +} + +describe("Bedrock redacted reasoning", () => { + beforeEach(() => { + bedrockMock.streamEvents = undefined; + }); + + it("does not fail the stream when reasoning arrives as redactedContent", async () => { + bedrockMock.streamEvents = redactedReasoningEvents(); + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + expect(response.stopReason, response.errorMessage).not.toBe("error"); + // Reasoning precedes the answer, matching the order Bedrock streamed it. + expect(response.content.map((c) => c.type)).toEqual(["thinking", "text"]); + expect(response.content[1]).toEqual({ type: "text", text: "done" }); + }); + + it("preserves the encrypted reasoning payload on the assistant message", async () => { + bedrockMock.streamEvents = redactedReasoningEvents(); + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking).toBeDefined(); + // Same representation Anthropic redacted thinking already uses: the opaque + // payload rides in `thinkingSignature` with `redacted: true`. + expect(thinking?.redacted).toBe(true); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // The byte buffer is streaming scratch state: persisting it would bloat the + // session, since a Uint8Array serializes to an index-keyed object. + expect("redactedChunks" in thinking!).toBe(false); + }); + + it("encodes the payload when the stream never sends contentBlockStop", async () => { + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + }, + }, + { messageStop: { stopReason: "end_turn" } }, + ]; + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // Streaming scratch state must not survive into the persisted message. + expect("redactedChunks" in thinking!).toBe(false); + expect("index" in thinking!).toBe(false); + }); + + it("joins encrypted reasoning split across deltas", async () => { + const [head, tail] = [bedrockMock.redactedBytes.slice(0, 7), bedrockMock.redactedBytes.slice(7)]; + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: head } } } }, + { contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: tail } } } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: "end_turn" } }, + ]; + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // The placeholder marks the block once, not once per delta. + expect(thinking?.thinking).toBe("[Reasoning redacted]"); + }); + + it("replays redacted reasoning as reasoningContent.redactedContent", async () => { + const messages: Message[] = [ + { role: "user", content: "hello", timestamp: Date.now() }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true }, + { type: "text", text: "done" }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: gptModel.id, + usage: emptyUsage, + stopReason: "stop", + timestamp: Date.now(), + }, + { role: "user", content: "continue", timestamp: Date.now() }, + ]; + + const payload = await capturePayload({ messages }); + + const assistant = payload.messages.find((m: any) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant!.content).toEqual([ + { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + { text: "done" }, + ]); + }); + + it("replays redacted reasoning before the toolUse block it belongs to", async () => { + // Bedrock rejects a tool continuation whose reasoning block is missing or + // reordered, so the opaque payload must land ahead of the matching toolUse. + const messages: Message[] = [ + { role: "user", content: "read the file", timestamp: Date.now() }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true }, + { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "/tmp/a.txt" } }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: gptModel.id, + usage: emptyUsage, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + content: [{ type: "text", text: "file body" }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const payload = await capturePayload({ messages }); + + const assistant = payload.messages.find((m: any) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant!.content).toEqual([ + { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + { toolUse: { toolUseId: "tool-1", name: "read", input: { path: "/tmp/a.txt" } } }, + ]); + }); +}); diff --git a/packages/ai/test/bedrock-response-headers.test.ts b/packages/ai/test/bedrock-response-headers.test.ts new file mode 100644 index 00000000000..82d264882f3 --- /dev/null +++ b/packages/ai/test/bedrock-response-headers.test.ts @@ -0,0 +1,63 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; +import { getModel } from "../src/compat.ts"; +import type { Model, ProviderResponse } from "../src/types.ts"; + +const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"; + +let server: Server | undefined; + +afterEach(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server?.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; +}); + +async function startBedrockResponseServer(): Promise { + server = createServer((_req, res) => { + res.writeHead(200, { + "content-type": "application/vnd.amazon.eventstream", + "x-bifrost-provider": "bedrock", + "x-bifrost-resolved-model": MODEL_ID, + "x-amzn-requestid": "req-123", + }); + res.end(); + }); + + await new Promise((resolve) => server?.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test server address"); + return `http://127.0.0.1:${address.port}`; +} + +describe("bedrock response headers", () => { + it("forwards raw Smithy response headers to onResponse", async () => { + const baseModel = getModel("amazon-bedrock", MODEL_ID) as Model<"bedrock-converse-stream">; + const model = { ...baseModel, baseUrl: await startBedrockResponseServer() }; + const responses: ProviderResponse[] = []; + + const result = await streamBedrock( + model, + { messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }, + { + cacheRetention: "none", + env: { AWS_BEDROCK_FORCE_HTTP1: "1", AWS_BEDROCK_SKIP_AUTH: "1" }, + onResponse: (response) => { + responses.push(response); + }, + }, + ).result(); + + // The fake server intentionally returns an empty event stream; this assertion + // documents that the header callback still fires before stream consumption. + expect(result.stopReason).toBe("error"); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].headers["x-amzn-requestid"]).toBe("req-123"); + expect(responses[0].headers["x-bifrost-provider"]).toBe("bedrock"); + expect(responses[0].headers["x-bifrost-resolved-model"]).toBe(MODEL_ID); + }); +}); diff --git a/packages/ai/test/cloudflare-gateway-binding.test.ts b/packages/ai/test/cloudflare-gateway-binding.test.ts new file mode 100644 index 00000000000..d59611a887c --- /dev/null +++ b/packages/ai/test/cloudflare-gateway-binding.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from "vitest"; +import { + type AiGatewayUniversalRequestLike, + CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL, + createGatewayBindingFetch, +} from "../src/api/cloudflare-gateway-binding.ts"; +import { streamSimple as streamOpenAICompletions } from "../src/api/openai-completions.ts"; +import type { Model } from "../src/types.ts"; + +const BASE_URL = "https://gateway.ai.cloudflare.com/v1/account-id/my-gateway"; + +interface CapturedRun { + gatewayId: string; + data: AiGatewayUniversalRequestLike; + options: { signal?: AbortSignal } | undefined; +} + +function fakeBinding(response?: Response) { + const runs: CapturedRun[] = []; + const binding = { + gateway: (gatewayId: string) => ({ + run: (data: AiGatewayUniversalRequestLike, options?: { signal?: AbortSignal }) => { + runs.push({ gatewayId, data, options }); + return Promise.resolve(response ?? new Response("{}")); + }, + }), + }; + return { binding, runs }; +} + +describe("createGatewayBindingFetch", () => { + it("derives provider and endpoint from gateway passthrough URLs", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: JSON.stringify({ model: "claude" }), + }); + await fetchFn(`${BASE_URL}/openai/responses`, { + method: "POST", + body: JSON.stringify({ model: "gpt" }), + }); + await fetchFn(`${BASE_URL}/workers-ai/v1/chat/completions`, { + method: "POST", + body: JSON.stringify({ model: "@cf/meta/llama" }), + }); + + expect(runs.map((run) => [run.data.provider, run.data.endpoint])).toEqual([ + ["anthropic", "v1/messages"], + ["openai", "responses"], + ["workers-ai", "v1/chat/completions"], + ]); + expect(runs.map((run) => run.gatewayId)).toEqual(["my-gateway", "my-gateway", "my-gateway"]); + expect(runs[0].data.query).toEqual({ model: "claude" }); + }); + + it("keeps the query string in the endpoint", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/openai/responses?beta=true`, { + method: "POST", + body: "{}", + }); + + expect(runs[0].data.endpoint).toBe("responses?beta=true"); + }); + + it("lowercases header names so case-variant duplicates collapse", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + headers: { "Anthropic-Version": "2023-06-01" }, + body: "{}", + }); + + expect(runs[0].data.headers).toEqual({ "anthropic-version": "2023-06-01" }); + }); + + it("lets init headers replace a Request input's headers, per the fetch spec", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn( + new Request(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + headers: { "x-from-request": "yes" }, + body: "{}", + }), + { headers: { "x-from-init": "yes" } }, + ); + + expect(runs[0].data.headers["x-from-init"]).toBe("yes"); + expect(runs[0].data.headers["x-from-request"]).toBeUndefined(); + }); + + it("strips gateway auth and derived headers, forwards the rest", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": "17", + "CF-AIG-Authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`, + "cf-aig-metadata": '{"user":"42"}', + "anthropic-version": "2023-06-01", + "x-api-key": "provider-key", + }, + body: "{}", + }); + + const headers = Object.fromEntries( + Object.entries(runs[0].data.headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + expect(headers["cf-aig-authorization"]).toBeUndefined(); + expect(headers["content-length"]).toBeUndefined(); + expect(headers["cf-aig-metadata"]).toBe('{"user":"42"}'); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + // Provider auth headers pass through: that is how request-supplied (BYOK) keys ride. + expect(headers["x-api-key"]).toBe("provider-key"); + }); + + it("accepts Request inputs and forwards their headers and body", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn( + new Request(`${BASE_URL}/openai/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ stream: true }), + }), + ); + + expect(runs).toHaveLength(1); + expect(runs[0].data.provider).toBe("openai"); + expect(runs[0].data.endpoint).toBe("chat/completions"); + expect(runs[0].data.query).toEqual({ stream: true }); + expect(runs[0].data.headers["content-type"]).toBe("application/json"); + }); + + it("forwards the abort signal", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const controller = new AbortController(); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: "{}", + signal: controller.signal, + }); + + expect(runs[0].options?.signal).toBe(controller.signal); + }); + + it("lets an explicit `signal: null` in init clear a Request input's signal, per the fetch spec", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const controller = new AbortController(); + + await fetchFn( + new Request(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: "{}", + signal: controller.signal, + }), + { signal: null }, + ); + + expect(runs).toHaveLength(1); + expect(runs[0].options?.signal).toBeUndefined(); + }); + + it("returns the binding response untouched, including streaming bodies", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: {}\n\n")); + controller.close(); + }, + }); + const bindingResponse = new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream", "cf-aig-log-id": "log-1" }, + }); + const { binding } = fakeBinding(bindingResponse); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + const response = await fetchFn(`${BASE_URL}/workers-ai/v1/chat/completions`, { + method: "POST", + body: "{}", + }); + + expect(response).toBe(bindingResponse); + expect(response.headers.get("cf-aig-log-id")).toBe("log-1"); + expect(await response.text()).toBe("data: {}\n\n"); + }); + + it("rejects in-prefix requests the universal endpoint cannot express", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await expect(fetchFn(`${BASE_URL}/anthropic/v1/messages`, { method: "GET" })).rejects.toThrow( + "cannot express GET", + ); + await expect(fetchFn(`${BASE_URL}/anthropic/v1/messages`, { method: "POST", body: "not json" })).rejects.toThrow( + "non-JSON body", + ); + await expect(fetchFn(`${BASE_URL}/anthropic`, { method: "POST", body: "{}" })).rejects.toThrow( + "missing provider/endpoint path", + ); + expect(runs).toHaveLength(0); + }); + + it("rejects URLs outside the gateway prefix: transport selection is the caller's", async () => { + // Silent passthrough would ship the auth sentinel to whatever host the URL names; a + // misconfigured baseUrl must fail loudly instead. + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await expect( + fetchFn("https://api.openai.com/v1/chat/completions", { method: "POST", body: "{}" }), + ).rejects.toThrow("outside the configured gateway prefix"); + // Same origin, different path (another account's gateway) is just as out-of-prefix. + await expect( + fetchFn("https://gateway.ai.cloudflare.com/v1/other-account/my-gateway/anthropic/v1/messages", { + method: "POST", + body: "{}", + }), + ).rejects.toThrow("outside the configured gateway prefix"); + expect(runs).toHaveLength(0); + }); + + it("matches and splits on the URL-normalized path, as real fetch would send it", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + // Dot segments normalize away before the provider/endpoint split, so a lexical variant + // routes exactly like its normal form (raw string prefixing would split it differently). + await fetchFn(`${BASE_URL}/anthropic/../anthropic/v1/./messages`, { + method: "POST", + body: JSON.stringify({ model: "claude" }), + }); + expect(runs.map((run) => [run.data.provider, run.data.endpoint])).toEqual([["anthropic", "v1/messages"]]); + + // A dot-segment URL that resolves outside the prefix is rejected even though it starts + // with the prefix as a raw string. + await expect( + fetchFn(`${BASE_URL}/../other-gateway/anthropic/v1/messages`, { method: "POST", body: "{}" }), + ).rejects.toThrow("outside the configured gateway prefix"); + expect(runs).toHaveLength(1); + }); + + it("consumes a one-shot stream body for the JSON probe", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const streamOf = (text: string) => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); + + // JSON stream body: consumed once, reaches the binding as the parsed query. + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: streamOf('{"model":"claude"}'), + duplex: "half", + } as RequestInit); + expect(runs).toHaveLength(1); + expect(runs[0].data.query).toEqual({ model: "claude" }); + + // Non-JSON stream body: rejects like any other non-JSON body (never replayed). + await expect( + fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: streamOf("not json"), + duplex: "half", + } as RequestInit), + ).rejects.toThrow("non-JSON body"); + expect(runs).toHaveLength(1); + }); + + it("keeps SDK placeholder auth out of entries when paired with null auth headers", async () => { + // The full header contract from the module docs: the sentinel satisfies pi's request-auth + // check, and the explicit nulls make the OpenAI SDK delete its own `Authorization: Bearer + // unused` placeholder before the request reaches the shim. + const { binding, runs } = fakeBinding( + Response.json({ error: { type: "bad_request", message: "stubbed" } }, { status: 400 }), + ); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const model: Model<"openai-completions"> = { + id: "test-model", + name: "Test Model", + api: "openai-completions", + provider: "openai", + baseUrl: `${BASE_URL}/openai`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 10_000, + maxTokens: 1_000, + }; + + const result = await streamOpenAICompletions( + model, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { + headers: { + "cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`, + Authorization: null, + "x-api-key": null, + }, + fetch: fetchFn, + maxRetries: 0, + }, + ).result(); + + expect(result.stopReason).toBe("error"); + expect(runs).toHaveLength(1); + expect(runs[0].data.provider).toBe("openai"); + const headerNames = Object.keys(runs[0].data.headers); + expect(headerNames).not.toContain("authorization"); + expect(headerNames).not.toContain("x-api-key"); + expect(headerNames).not.toContain("cf-aig-authorization"); + }); +}); diff --git a/packages/ai/test/constrained-sampling.test.ts b/packages/ai/test/constrained-sampling.test.ts index 9cf962e7022..f24edd1ff3e 100644 --- a/packages/ai/test/constrained-sampling.test.ts +++ b/packages/ai/test/constrained-sampling.test.ts @@ -1,7 +1,11 @@ import type { ResponseStreamEvent } from "openai/resources/responses/responses.js"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { appendGrammarToolInputJsonDelta } from "../src/api/constrained-sampling.ts"; +import { + appendGrammarToolInputJsonDelta, + makeStrictJsonSchema, + resolveJsonSchemaStrictSampling, +} from "../src/api/constrained-sampling.ts"; import { convertResponsesMessages, convertResponsesTools, @@ -114,6 +118,78 @@ describe("constrained tool sampling", () => { ); }); + it("derives strict provider schemas without changing tool definitions", () => { + const parameters = Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + metadata: Type.Object({ enabled: Type.Optional(Type.Boolean()) }), + nullable: Type.Optional(Type.Union([Type.String(), Type.Null()])), + }); + + const strict = makeStrictJsonSchema(parameters); + + expect(parameters).not.toHaveProperty("additionalProperties"); + expect(parameters.required).toEqual(["path", "metadata"]); + expect(strict).toMatchObject({ + additionalProperties: false, + required: ["path", "offset", "metadata", "nullable"], + properties: { + offset: { anyOf: [{ type: "number" }, { type: "null" }] }, + metadata: { + additionalProperties: false, + required: ["enabled"], + properties: { enabled: { anyOf: [{ type: "boolean" }, { type: "null" }] } }, + }, + nullable: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + }); + }); + + it("falls back or rejects schemas that cannot be safely converted", () => { + const cases: Array<{ parameters: Tool["parameters"]; error: string }> = [ + { + parameters: Type.Object({ metadata: Type.Object({}, { additionalProperties: Type.String() }) }), + error: "additionalProperties is unsupported", + }, + { + parameters: Type.Intersect([Type.Object({ a: Type.String() }), Type.Object({ b: Type.Number() })]), + error: "allOf schemas are unsupported", + }, + { + parameters: Type.Object({ + value: Type.Union([Type.Object({ nested: Type.String() }), Type.Null()]), + }), + error: "object and array unions are unsupported", + }, + { + parameters: { + type: "object", + properties: { child: { $ref: "https://example.com/child.json" } }, + required: ["child"], + } as Tool["parameters"], + error: "$ref schemas are unsupported", + }, + ]; + + for (const { parameters, error } of cases) { + const tool: Tool = { + ...makeTool(), + parameters, + constrainedSampling: { type: "json_schema", strict: "prefer" }, + }; + + expect(() => makeStrictJsonSchema(parameters)).toThrow(error); + expect(resolveJsonSchemaStrictSampling(tool, true)).toBeUndefined(); + expect(convertResponsesTools([tool], { supportsStrictMode: true })[0]).toMatchObject({ + strict: false, + parameters, + }); + + tool.constrainedSampling = { type: "json_schema", strict: "require" }; + expect(() => resolveJsonSchemaStrictSampling(tool, true)).toThrow(error); + } + }); + it("replays grammar calls as custom Responses items", () => { const replayedToolCall: ToolCall = { type: "toolCall", diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 6aefb26fa28..e66d219132c 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -203,8 +203,8 @@ describe("Context overflow error handling", () => { // ============================================================================= describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { - it("gemini-2.0-flash - should detect overflow via isContextOverflow", async () => { - const model = getModel("google", "gemini-2.0-flash"); + it("gemini-2.5-flash - should detect overflow via isContextOverflow", async () => { + const model = getModel("google", "gemini-2.5-flash"); const result = await testContextOverflow(model, process.env.GEMINI_API_KEY!); logResult(result); @@ -479,6 +479,18 @@ describe("Context overflow error handling", () => { }, 120000); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual", () => { + it("qwen3.8-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan-individual", "qwen3.8-max"); + const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/input length/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN)", () => { it("qwen3.7-max - should detect overflow via isContextOverflow", async () => { const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 1746411c726..ada2b536f85 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -135,6 +135,21 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ // Qwen Token Plan { provider: "qwen-token-plan", model: "qwen3.7-max", label: "qwen-token-plan-qwen3.7-max" }, { provider: "qwen-token-plan-cn", model: "qwen3.7-max", label: "qwen-token-plan-cn-qwen3.7-max" }, + { + provider: "qwen-token-plan-individual", + model: "qwen3.8-max", + label: "qwen-token-plan-individual-qwen3.8-max", + }, + { + provider: "qwen-token-plan-individual", + model: "deepseek-v4-flash-0731", + label: "qwen-token-plan-individual-deepseek-v4-flash-0731", + }, + { + provider: "qwen-token-plan-individual", + model: "glm-5.2", + label: "qwen-token-plan-individual-glm-5.2", + }, ]; // Cached context structure diff --git a/packages/ai/test/deferred-tools.test.ts b/packages/ai/test/deferred-tools.test.ts index 87e8e209338..455a14b95d3 100644 --- a/packages/ai/test/deferred-tools.test.ts +++ b/packages/ai/test/deferred-tools.test.ts @@ -45,9 +45,17 @@ interface OpenAIToolSearchOutput { tools: Array<{ type: string; name: string; defer_loading?: boolean }>; } +interface OpenAIAdditionalTools { + type: "additional_tools"; + role: "developer"; + tools: Array<{ type: string; name: string; defer_loading?: boolean }>; +} + interface OpenAIPayload { tools?: Array<{ name?: string; function?: { name: string } }>; - input?: Array; + input?: Array< + OpenAIAdditionalTools | OpenAIToolSearchCall | OpenAIToolSearchOutput | { type?: string; name?: string } + >; } interface KimiTool { @@ -394,9 +402,57 @@ describe("deferred tools", () => { expect(payload.messages.some((message) => message.tools !== undefined)).toBe(false); }); - it("loads an OpenAI Responses tool through client tool search", async () => { + it("loads an OpenAI Responses tool through additional_tools", async () => { const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); const payload = await capturePayload(getModel("openai", "gpt-5.4"), context); + const additionalTools = payload.input?.find( + (item): item is OpenAIAdditionalTools => item.type === "additional_tools", + ); + + expect(openAIToolNames(payload)).toEqual(["base_tool"]); + expect(additionalTools).toMatchObject({ role: "developer" }); + expect(additionalTools?.tools).toMatchObject([{ type: "function", name: "late_tool" }]); + expect(additionalTools?.tools.every((tool) => tool.defer_loading === undefined)).toBe(true); + expect(payload.input?.some((item) => item.type === "tool_search_call")).toBe(false); + expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); + }); + + it("preserves an additional_tools marker after the loaded tool is used", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const lateCall: AssistantMessage = { + ...makeAssistantToolCall(), + content: [{ type: "toolCall", id: "call_late|fc_late", name: "late_tool", arguments: {} }], + api: "openai-responses", + provider: "openai", + model: "gpt-5.4", + }; + context.messages.splice(3, 0, lateCall, { + ...makeToolResult(["late_tool"]), + toolCallId: "call_late|fc_late", + toolName: "late_tool", + }); + + const payload = await capturePayload(getModel("openai", "gpt-5.4"), context); + const additionalToolIndexes = (payload.input ?? []).flatMap((item, index) => + item.type === "additional_tools" ? [index] : [], + ); + const lateCallIndex = (payload.input ?? []).findIndex( + (item) => item.type === "function_call" && item.name === "late_tool", + ); + + expect(additionalToolIndexes).toHaveLength(1); + expect(additionalToolIndexes[0]).toBeLessThan(lateCallIndex); + expect(openAIToolNames(payload)).toEqual(["base_tool"]); + }); + + it("falls back to client tool search when additional_tools is unsupported", async () => { + const model: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "openai-proxy", + compat: { supportsAdditionalTools: false, supportsToolSearch: true }, + }; + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(model, context); const searchCall = payload.input?.find((item): item is OpenAIToolSearchCall => item.type === "tool_search_call"); const searchOutput = payload.input?.find( (item): item is OpenAIToolSearchOutput => item.type === "tool_search_output", @@ -406,6 +462,7 @@ describe("deferred tools", () => { expect(searchCall).toMatchObject({ execution: "client", status: "completed" }); expect(searchOutput?.call_id).toBe(searchCall?.call_id); expect(searchOutput?.tools).toMatchObject([{ type: "function", name: "late_tool", defer_loading: true }]); + expect(payload.input?.some((item) => item.type === "additional_tools")).toBe(false); }); it.each(["gpt-5.2", "gpt-5.4-nano", "gpt-5.5-pro"] as const)( @@ -432,23 +489,32 @@ describe("deferred tools", () => { expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); }); - it("uses tool search only for supported Codex models", async () => { + it("selects additional tools, tool search, or top-level tools for Codex models", async () => { const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); - const supported = await capturePayload( + const additionalTools = await capturePayload( + getModel("openai-codex", "gpt-5.6-sol"), + context, + makeCodexToken(), + ); + const toolSearch = await capturePayload( getModel("openai-codex", "gpt-5.4"), context, makeCodexToken(), ); - const unsupported = await capturePayload( + const topLevel = await capturePayload( getModel("openai-codex", "gpt-5.3-codex-spark"), context, makeCodexToken(), ); - expect(openAIToolNames(supported)).toEqual(["base_tool"]); - expect(supported.input?.some((item) => item.type === "tool_search_output")).toBe(true); - expect(openAIToolNames(unsupported)).toEqual(["base_tool", "late_tool"]); - expect(unsupported.input?.some((item) => item.type === "tool_search_output")).toBe(false); + expect(openAIToolNames(additionalTools)).toEqual(["base_tool"]); + expect(additionalTools.input?.some((item) => item.type === "additional_tools")).toBe(true); + expect(additionalTools.input?.some((item) => item.type === "tool_search_output")).toBe(false); + expect(openAIToolNames(toolSearch)).toEqual(["base_tool"]); + expect(toolSearch.input?.some((item) => item.type === "tool_search_output")).toBe(true); + expect(openAIToolNames(topLevel)).toEqual(["base_tool", "late_tool"]); + expect(topLevel.input?.some((item) => item.type === "additional_tools")).toBe(false); + expect(topLevel.input?.some((item) => item.type === "tool_search_output")).toBe(false); }); it("leaves providers without deferred loading unchanged", async () => { diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 310da05088c..b45ceed6858 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -576,6 +576,26 @@ describe("AI Providers Empty Message Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Empty Messages", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Empty Messages", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/generate-models-strict.test.ts b/packages/ai/test/generate-models-strict.test.ts new file mode 100644 index 00000000000..30d44a4db40 --- /dev/null +++ b/packages/ai/test/generate-models-strict.test.ts @@ -0,0 +1,86 @@ +import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("strict model generation", () => { + it("fails before mutating generated data when an Individual model loses tool support", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-generate-models-")); + temporaryRoots.push(fixtureRoot); + const isolatedPackageRoot = join(fixtureRoot, "package"); + mkdirSync(isolatedPackageRoot); + for (const entry of ["package.json", "scripts", "src"]) { + cpSync(join(packageRoot, entry), join(isolatedPackageRoot, entry), { recursive: true }); + } + const preloadPath = join(fixtureRoot, "mock-models-dev.mjs"); + const modelIds = [ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "deepseek-v4-pro-0813", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", + "qwen3.8-max-preview", + ]; + const sourceModels = Object.fromEntries( + modelIds.map((id) => [ + id, + { + id, + name: id, + tool_call: id !== "deepseek-v4-flash-0731", + }, + ]), + ); + const catalog = { "alibaba-token-plan": { models: sourceModels } }; + writeFileSync( + preloadPath, + `const catalog = ${JSON.stringify(catalog)};\n` + + `globalThis.fetch = async (input) => {\n` + + ` if (String(input) === "https://models.dev/api.json") {\n` + + ` return new Response(JSON.stringify(catalog), { status: 200 });\n` + + ` }\n` + + ` throw new Error(\`Unexpected fetch: \${String(input)}\`);\n` + + `};\n`, + ); + + const generatedPaths = [ + "src/models.generated.ts", + "src/providers/qwen-token-plan-individual.models.ts", + "src/providers/data/qwen-token-plan-individual.json", + "src/providers/data/.manifest.json", + ]; + const sourceBefore = generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8")); + const isolatedBefore = generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8")); + + const result = spawnSync( + process.execPath, + ["--import", pathToFileURL(preloadPath).href, "scripts/generate-models.ts", "--strict"], + { + cwd: isolatedPackageRoot, + encoding: "utf8", + timeout: 10_000, + }, + ); + + expect(result.status).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "qwen-token-plan-individual model IDs do not match (missing: deepseek-v4-flash-0731)", + ); + expect(generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8"))).toEqual( + isolatedBefore, + ); + expect(generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8"))).toEqual(sourceBefore); + }); +}); diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 38934347a8f..267216e4f3f 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -6,11 +6,15 @@ import { githubCopilotProvider } from "../src/providers/github-copilot.ts"; const neverAbortedSignal = new AbortController().signal; -function jsonResponse(body: unknown, status: number = 200): Response { +const testCopilotAccessToken = "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;"; +const testCopilotModelsUrl = "https://api.individual.githubcopilot.com/models"; + +function jsonResponse(body: unknown, status: number = 200, headers?: Record): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", + ...headers, }, }); } @@ -28,6 +32,37 @@ function getUrl(input: unknown): string { throw new Error(`Unsupported fetch input: ${String(input)}`); } +function stubGitHubCopilotLoginFetch(options: { + models: () => Response; + policy?: (modelId: string) => Response; +}): void { + const fetchMock = vi.fn(async (input: string | URL | Request): Promise => { + const url = getUrl(input); + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ token: testCopilotAccessToken, expires_at: 9999999999 }); + } + if (url === testCopilotModelsUrl) return options.models(); + if (url.startsWith(`${testCopilotModelsUrl}/`) && url.endsWith("/policy")) { + if (!options.policy) throw new Error(`Unexpected policy request: ${url}`); + return options.policy(url.slice(`${testCopilotModelsUrl}/`.length, -"/policy".length)); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +} + function loginGitHubCopilotForTest(options: { onDeviceCode(info: { userCode: string; @@ -180,6 +215,37 @@ describe("GitHub Copilot OAuth device flow", () => { expect(credentials.availableModelIds).toEqual([]); }); + it("does not retry model catalog throttling during credential refresh", async () => { + let catalogRequestCount = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ token: testCopilotAccessToken, expires_at: 9999999999 }); + } + if (url === testCopilotModelsUrl) { + catalogRequestCount += 1; + return jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "0" }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + githubCopilotOAuth.refresh( + { + type: "oauth", + access: "old-access-token", + refresh: "ghu_refresh_token", + expires: 0, + }, + neverAbortedSignal, + ), + ).rejects.toThrow("429"); + expect(catalogRequestCount).toBe(1); + }); + it("reports device-code details through onDeviceCode", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); @@ -239,6 +305,152 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); + it("updates only known, tool-capable, unconfigured account model policies", async () => { + vi.useFakeTimers(); + + let catalogRequestCount = 0; + const policyModelIds: string[] = []; + stubGitHubCopilotLoginFetch({ + models: () => { + catalogRequestCount += 1; + return jsonResponse({ + data: [ + { + id: "gpt-4.1", + model_picker_enabled: true, + policy: { state: "enabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "claude-sonnet-4.5", + model_picker_enabled: true, + policy: { state: "unconfigured" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "remote-only-model", + model_picker_enabled: true, + policy: { state: "unconfigured" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "gpt-5.4", + model_picker_enabled: true, + policy: { state: "unconfigured" }, + capabilities: { supports: { tool_calls: false } }, + }, + ], + }); + }, + policy: (modelId) => { + policyModelIds.push(modelId); + return new Response("", { status: 200 }); + }, + }); + + const loginPromise = loginGitHubCopilotForTest({ + onDeviceCode: () => {}, + onPrompt: async () => "", + }); + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + + expect(catalogRequestCount).toBe(1); + expect(policyModelIds).toEqual(["claude-sonnet-4.5"]); + }); + + it("retries a throttled policy update after Retry-After", async () => { + vi.useFakeTimers(); + + let policyRequestCount = 0; + stubGitHubCopilotLoginFetch({ + models: () => + jsonResponse({ + data: [{ id: "claude-sonnet-4.5", model_picker_enabled: true, policy: { state: "unconfigured" } }], + }), + policy: () => { + policyRequestCount += 1; + return policyRequestCount === 1 + ? jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "1" }) + : new Response("", { status: 200 }); + }, + }); + + const loginPromise = loginGitHubCopilotForTest({ + onDeviceCode: () => {}, + onPrompt: async () => "", + }); + await vi.advanceTimersByTimeAsync(1000); + expect(policyRequestCount).toBe(1); + await vi.advanceTimersByTimeAsync(999); + expect(policyRequestCount).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await loginPromise; + + expect(policyRequestCount).toBe(2); + }); + + it("continues policy updates after a transport failure", async () => { + vi.useFakeTimers(); + + const modelIds = ["gpt-4.1", "claude-sonnet-4.5"]; + const policyModelIds: string[] = []; + stubGitHubCopilotLoginFetch({ + models: () => + jsonResponse({ + data: modelIds.map((id) => ({ id, model_picker_enabled: true, policy: { state: "unconfigured" } })), + }), + policy: (modelId) => { + policyModelIds.push(modelId); + if (policyModelIds.length === 1) throw new Error("fetch failed"); + return new Response("", { status: 200 }); + }, + }); + + const loginPromise = loginGitHubCopilotForTest({ + onDeviceCode: () => {}, + onPrompt: async () => "", + }); + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + + expect(policyModelIds).toEqual(modelIds); + }); + + it("stops policy updates and persists authentication when the retry delay exceeds the login budget", async () => { + vi.useFakeTimers(); + + const policyModelIds: string[] = []; + stubGitHubCopilotLoginFetch({ + models: () => + jsonResponse({ + data: [ + { id: "gpt-4.1", model_picker_enabled: true, policy: { state: "unconfigured" } }, + { id: "claude-sonnet-4.5", model_picker_enabled: true, policy: { state: "unconfigured" } }, + ], + }), + policy: (modelId) => { + policyModelIds.push(modelId); + return jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "5" }); + }, + }); + + const store = new InMemoryCredentialStore(); + const models = createModels({ credentials: store }); + models.setProvider(githubCopilotProvider()); + const loginPromise = models.login("github-copilot", "oauth", { + signal: neverAbortedSignal, + prompt: async () => "", + notify: () => {}, + }); + + await vi.advanceTimersByTimeAsync(1000); + const credential = await loginPromise; + expect(credential).toMatchObject({ type: "oauth", access: testCopilotAccessToken }); + expect(policyModelIds).toEqual(["gpt-4.1"]); + expect(await store.read("github-copilot")).toEqual(credential); + }); + it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { // A malicious enterprise OAuth server could return a verification_uri that // the browser launcher would otherwise hand to the OS. Ensure such values diff --git a/packages/ai/test/google-raw-stop-reason.test.ts b/packages/ai/test/google-raw-stop-reason.test.ts index b485088ea40..245c0d7251a 100644 --- a/packages/ai/test/google-raw-stop-reason.test.ts +++ b/packages/ai/test/google-raw-stop-reason.test.ts @@ -1,11 +1,18 @@ +import { arch, platform, release } from "node:os"; import { describe, expect, it, vi } from "vitest"; const googleGenAiMock = vi.hoisted(() => ({ + constructorCalls: [] as Array>, finishReason: "MALFORMED_FUNCTION_CALL", + includeFunctionCall: false, })); vi.mock("@google/genai", () => { class GoogleGenAI { + constructor(config: Record) { + googleGenAiMock.constructorCalls.push(config); + } + models = { generateContentStream: async function* () { yield { @@ -13,6 +20,19 @@ vi.mock("@google/genai", () => { candidates: [ { finishReason: googleGenAiMock.finishReason, + ...(googleGenAiMock.includeFunctionCall && { + content: { + parts: [ + { + functionCall: { + id: "call-1", + name: "echo", + args: { value: "truncated" }, + }, + }, + ], + }, + }), }, ], usageMetadata: { @@ -70,13 +90,30 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts"; import { getModel } from "../src/compat.ts"; import type { Context } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; +async function captureGoogleHeaders(headers?: Record): Promise> { + googleGenAiMock.constructorCalls.length = 0; + googleGenAiMock.finishReason = "STOP"; + googleGenAiMock.includeFunctionCall = false; + await streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { + apiKey: "test-api-key", + headers, + }).result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + const httpOptions = googleGenAiMock.constructorCalls[0].httpOptions as { headers?: Record }; + return httpOptions.headers ?? {}; +} + describe("Google raw stop reasons", () => { it("preserves raw Gemini finish reasons for Google Generative AI errors", async () => { googleGenAiMock.finishReason = "MALFORMED_FUNCTION_CALL"; + googleGenAiMock.includeFunctionCall = false; const stream = streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { apiKey: "test-api-key", @@ -91,6 +128,7 @@ describe("Google raw stop reasons", () => { it("preserves raw Gemini finish reasons for Google Vertex errors", async () => { googleGenAiMock.finishReason = "SAFETY"; + googleGenAiMock.includeFunctionCall = false; const stream = streamGoogleVertex(getModel("google-vertex", "gemini-3-flash-preview"), context, { project: "test-project", @@ -103,4 +141,54 @@ describe("Google raw stop reasons", () => { expect(message.rawStopReason).toBe("SAFETY"); expect(message.errorMessage).toBe("Provider stopped with: SAFETY"); }); + + const adapters = [ + { + name: "Google Generative AI", + createStream: () => + streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { + apiKey: "test-api-key", + }), + }, + { + name: "Google Vertex", + createStream: () => + streamGoogleVertex(getModel("google-vertex", "gemini-3-flash-preview"), context, { + project: "test-project", + location: "us-central1", + }), + }, + ]; + + it.each(adapters)("preserves MAX_TOKENS with a tool call as length for $name", async ({ createStream }) => { + googleGenAiMock.finishReason = "MAX_TOKENS"; + googleGenAiMock.includeFunctionCall = true; + + const message = await createStream().result(); + + expect(message.stopReason).toBe("length"); + expect(message.rawStopReason).toBe("MAX_TOKENS"); + expect(message.content.some((block) => block.type === "toolCall")).toBe(true); + }); + + it.each(adapters)("maps STOP with a tool call to toolUse for $name", async ({ createStream }) => { + googleGenAiMock.finishReason = "STOP"; + googleGenAiMock.includeFunctionCall = true; + + const message = await createStream().result(); + + expect(message.stopReason).toBe("toolUse"); + expect(message.rawStopReason).toBe("STOP"); + expect(message.content.some((block) => block.type === "toolCall")).toBe(true); + }); +}); + +describe("Google Generative AI user agent", () => { + it("uses pi's User-Agent by default", async () => { + expect((await captureGoogleHeaders())["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default User-Agent", async () => { + expect((await captureGoogleHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent"); + }); }); diff --git a/packages/ai/test/google-thinking-level-map.test.ts b/packages/ai/test/google-thinking-level-map.test.ts new file mode 100644 index 00000000000..957c9b14b22 --- /dev/null +++ b/packages/ai/test/google-thinking-level-map.test.ts @@ -0,0 +1,170 @@ +import type { GenerateContentParameters } from "@google/genai"; +import { describe, expect, it } from "vitest"; +import { streamSimple as streamSimpleGoogle } from "../src/api/google-generative-ai.ts"; +import { resolveGoogleThinkingLevel } from "../src/api/google-shared.ts"; +import { streamSimple as streamSimpleVertex } from "../src/api/google-vertex.ts"; +import type { + Context, + Model, + ModelThinkingLevel, + ThinkingBudgets, + ThinkingLevel, + ThinkingLevelMap, +} from "../src/types.ts"; + +const context: Context = { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], +}; + +function googleModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-generative-ai"> { + return { + id, + name: id, + api: "google-generative-ai", + provider: "test-google", + baseUrl: "https://example.invalid/v1beta", + reasoning: true, + thinkingLevelMap, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +function vertexModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-vertex"> { + return { + id, + name: id, + api: "google-vertex", + provider: "test-vertex", + baseUrl: "https://example.invalid/v1", + reasoning: true, + thinkingLevelMap, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +async function captureGooglePayload( + model: Model<"google-generative-ai">, + reasoning: ThinkingLevel, + thinkingBudgets?: ThinkingBudgets, +): Promise { + let payload: GenerateContentParameters | undefined; + const result = await streamSimpleGoogle(model, context, { + apiKey: "test", + reasoning, + thinkingBudgets, + onPayload: (request) => { + payload = request as GenerateContentParameters; + throw new Error("payload captured"); + }, + }).result(); + + expect(result.errorMessage).toContain("payload captured"); + if (!payload) throw new Error("Google payload was not captured"); + return payload; +} + +async function captureVertexPayload( + model: Model<"google-vertex">, + reasoning: ThinkingLevel, + thinkingBudgets?: ThinkingBudgets, +): Promise { + let payload: GenerateContentParameters | undefined; + const result = await streamSimpleVertex(model, context, { + apiKey: "test", + reasoning, + thinkingBudgets, + onPayload: (request) => { + payload = request as GenerateContentParameters; + throw new Error("payload captured"); + }, + }).result(); + + expect(result.errorMessage).toContain("payload captured"); + if (!payload) throw new Error("Vertex payload was not captured"); + return payload; +} + +describe("Google thinking level maps", () => { + it("exhaustively resolves supported logical levels and mapping values", () => { + const defaultExpectations = { + off: "high", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + } as const satisfies Partial>; + for (const [level, expected] of Object.entries(defaultExpectations)) { + expect(resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), level as ModelThinkingLevel)).toBe( + expected, + ); + } + + const mappedExpectations = { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + MINIMAL: "minimal", + LOW: "low", + MEDIUM: "medium", + HIGH: "high", + } as const; + for (const [mapped, expected] of Object.entries(mappedExpectations)) { + const model = googleModel("gemini-3.7-flash", { high: mapped, xhigh: mapped, max: mapped }); + expect(resolveGoogleThinkingLevel(model, "high")).toBe(expected); + expect(resolveGoogleThinkingLevel(model, "xhigh")).toBe(expected); + expect(resolveGoogleThinkingLevel(model, "max")).toBe(expected); + } + + const invalidModel = googleModel("gemini-3.7-flash", { xhigh: "extreme" }); + expect(() => resolveGoogleThinkingLevel(invalidModel, "xhigh")).toThrow( + "Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: xhigh -> extreme", + ); + expect(() => resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), "max")).toThrow( + "Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: max -> undefined", + ); + }); + + it.each(["xhigh", "max"] as const)("maps Google Generative AI %s to a supported level", async (reasoning) => { + const payload = await captureGooglePayload( + googleModel("gemini-3.7-flash", { xhigh: "high", max: "high" }), + reasoning, + ); + + expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } }); + }); + + it("honors uppercase provider values for standard Google Generative AI levels", async () => { + const payload = await captureGooglePayload(googleModel("gemini-3.7-flash", { high: "LOW" }), "high"); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingLevel: "LOW" } } }); + }); + + it("uses mapped Google Generative AI levels for token budgets", async () => { + const payload = await captureGooglePayload(googleModel("gemini-2.5-flash", { xhigh: "high" }), "xhigh", { + high: 1234, + }); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 1234 } } }); + }); + + it("maps Google Vertex extended levels", async () => { + const payload = await captureVertexPayload(vertexModel("gemini-3.7-flash", { xhigh: "high" }), "xhigh"); + + expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } }); + }); + + it("uses mapped Google Vertex levels for token budgets", async () => { + const payload = await captureVertexPayload(vertexModel("gemini-2.5-flash", { max: "high" }), "max", { + high: 4321, + }); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 4321 } } }); + }); +}); diff --git a/packages/ai/test/google-vertex-api-key-resolution.test.ts b/packages/ai/test/google-vertex-api-key-resolution.test.ts index 46f24a773c8..3937b3feb24 100644 --- a/packages/ai/test/google-vertex-api-key-resolution.test.ts +++ b/packages/ai/test/google-vertex-api-key-resolution.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const googleGenAiMock = vi.hoisted(() => ({ @@ -49,6 +50,7 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts"; import { getModel } from "../src/compat.ts"; import type { Context, Model } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; const model = getModel("google-vertex", "gemini-3-flash-preview"); const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], @@ -154,7 +156,24 @@ describe("google-vertex api key resolution", () => { await stream.result(); expect(googleGenAiMock.constructorCalls).toHaveLength(1); - expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toBeUndefined(); + expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({ + headers: { "User-Agent": PI_USER_AGENT }, + }); + }); + + it("lets explicit headers override the default User-Agent", async () => { + const stream = streamGoogleVertex(model, context, { + project: "test-project", + location: "us-central1", + headers: { "User-Agent": "custom-agent" }, + }); + + await stream.result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({ + headers: { "User-Agent": "custom-agent" }, + }); }); it("forwards custom baseUrl to the ADC client", async () => { diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 8b77e793fd4..a331f2a343f 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -406,6 +406,18 @@ describe("Tool Results with Images", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider (qwen3.8-max)", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider (qwen3.7-max)", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/lazy-module-load.test.ts b/packages/ai/test/lazy-module-load.test.ts index dd5169625cf..cb0875c9ee7 100644 --- a/packages/ai/test/lazy-module-load.test.ts +++ b/packages/ai/test/lazy-module-load.test.ts @@ -8,13 +8,7 @@ const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href; const compatEntryUrl = new URL("../src/compat.ts", import.meta.url).href; const providersAllUrl = new URL("../src/providers/all.ts", import.meta.url).href; -const SDK_SPECIFIERS = [ - "@anthropic-ai/sdk", - "openai", - "@google/genai", - "@mistralai/mistralai", - "@aws-sdk/client-bedrock-runtime", -] as const; +const SDK_SPECIFIERS = ["@anthropic-ai/sdk", "openai", "@google/genai", "@aws-sdk/client-bedrock-runtime"] as const; type ProbeResult = { loadedSpecifiers: string[]; diff --git a/packages/ai/test/mistral-http-transport.test.ts b/packages/ai/test/mistral-http-transport.test.ts new file mode 100644 index 00000000000..3622fb56cf7 --- /dev/null +++ b/packages/ai/test/mistral-http-transport.test.ts @@ -0,0 +1,432 @@ +import { arch, platform, release } from "node:os"; +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { stream as streamMistral } from "../src/api/mistral-conversations.ts"; +import { getModel } from "../src/compat.ts"; +import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts"; + +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + +function createSseResponse(events: unknown[], headers?: Record): Response { + const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`; + return new Response(body, { + headers: { "content-type": "text/event-stream", ...headers }, + }); +} + +function createBytewiseSseResponse(event: unknown): Response { + const bytes = new TextEncoder().encode(`data: ${JSON.stringify(event)}\r\n\r\ndata: [DONE]\r\n\r\n`); + return new Response( + new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); + controller.close(); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); +} + +function createTerminalEvent(finishReason = "stop") { + return { + id: "mistral-response-id", + model: "mistral-large-latest", + choices: [{ index: 0, finish_reason: finishReason, delta: {} }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; +} + +describe("Mistral HTTP transport", () => { + it("serializes SDK-style payloads to the Mistral wire format", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + systemPrompt: "Be precise", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + timestamp: 1, + }, + ], + tools: [ + { + name: "lookup", + description: "Look something up", + parameters: Type.Object({ query: Type.String() }), + }, + ], + }; + let requestUrl: string | undefined; + let requestInit: RequestInit | undefined; + let callbackPayload: Record | undefined; + let callbackResponse: ProviderResponse | undefined; + const fetch: FetchFunction = async (input, init) => { + requestUrl = String(input); + requestInit = init; + return createSseResponse([createTerminalEvent()], { "x-request-id": "request-1" }); + }; + + const message = await streamMistral(model, context, { + apiKey: "secret", + fetch, + headers: { "x-custom": "value" }, + maxTokens: 123, + promptMode: "reasoning", + reasoningEffort: "high", + toolChoice: { type: "function", function: { name: "lookup" } }, + sessionId: "session-1", + onPayload: (payload) => { + callbackPayload = payload as Record; + return { + ...callbackPayload, + topP: 0.9, + randomSeed: 42, + responseFormat: { + type: "json_schema", + jsonSchema: { + name: "result", + schemaDefinition: { + type: "object", + properties: { maxTokens: { type: "number" } }, + }, + }, + }, + presencePenalty: 0.1, + frequencyPenalty: 0.2, + parallelToolCalls: true, + safePrompt: true, + }; + }, + onResponse: (response) => { + callbackResponse = response; + }, + }).result(); + + expect(message.stopReason).toBe("stop"); + expect(requestUrl).toBe("https://api.mistral.ai/v1/chat/completions"); + const headers = new Headers(requestInit?.headers); + expect(headers.get("authorization")).toBe("Bearer secret"); + expect(headers.get("accept")).toBe("text/event-stream"); + expect(headers.get("x-affinity")).toBe("session-1"); + expect(headers.get("x-custom")).toBe("value"); + expect(headers.get("user-agent")).toBe(PI_USER_AGENT); + expect(callbackPayload?.maxTokens).toBe(123); + expect(callbackPayload?.promptMode).toBe("reasoning"); + expect(callbackPayload?.promptCacheKey).toBe("session-1"); + expect(callbackResponse).toEqual({ + status: 200, + headers: { "content-type": "text/event-stream", "x-request-id": "request-1" }, + }); + + const wirePayload = JSON.parse(String(requestInit?.body)) as Record; + expect(wirePayload.max_tokens).toBe(123); + expect(wirePayload.prompt_mode).toBe("reasoning"); + expect(wirePayload.reasoning_effort).toBe("high"); + expect(wirePayload.tool_choice).toEqual({ type: "function", function: { name: "lookup" } }); + expect(wirePayload.prompt_cache_key).toBe("session-1"); + expect(wirePayload.top_p).toBe(0.9); + expect(wirePayload.random_seed).toBe(42); + expect(wirePayload.presence_penalty).toBe(0.1); + expect(wirePayload.frequency_penalty).toBe(0.2); + expect(wirePayload.parallel_tool_calls).toBe(true); + expect(wirePayload.safe_prompt).toBe(true); + expect(wirePayload.response_format).toEqual({ + type: "json_schema", + json_schema: { + name: "result", + schema: { + type: "object", + properties: { maxTokens: { type: "number" } }, + }, + }, + }); + expect(wirePayload).not.toHaveProperty("maxTokens"); + expect(wirePayload).not.toHaveProperty("promptMode"); + expect(wirePayload).not.toHaveProperty("promptCacheKey"); + expect(wirePayload.messages).toEqual([ + { role: "system", content: "Be precise" }, + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" }, + ], + }, + ]); + }); + + it("serializes assistant thinking, tool calls, and tool results for replay", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [ + { + role: "assistant", + api: "mistral-conversations", + provider: "mistral", + model: model.id, + content: [ + { type: "thinking", thinking: "reason" }, + { type: "text", text: "answer" }, + { type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "abc123456", + toolName: "lookup", + content: [ + { type: "text", text: "found" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + isError: false, + timestamp: 2, + }, + ], + }; + let requestInit: RequestInit | undefined; + const fetch: FetchFunction = async (_input, init) => { + requestInit = init; + return createSseResponse([createTerminalEvent()]); + }; + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("stop"); + const wirePayload = JSON.parse(String(requestInit?.body)) as { messages: unknown[] }; + expect(wirePayload.messages).toEqual([ + { + role: "assistant", + prefix: false, + content: [ + { type: "thinking", thinking: [{ type: "text", text: "reason" }] }, + { type: "text", text: "answer" }, + ], + tool_calls: [ + { + id: "abc123456", + type: "function", + function: { name: "lookup", arguments: '{"query":"pi"}' }, + index: 0, + }, + ], + }, + { + role: "tool", + tool_call_id: "abc123456", + name: "lookup", + content: [ + { type: "text", text: "found" }, + { type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" }, + ], + }, + ]); + }); + + it("parses native thinking, text, tool calls, and cached-token usage", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const events = [ + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: null, + delta: { content: [{ type: "thinking", thinking: [{ type: "text", text: "reason" }] }] }, + }, + ], + }, + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: null, + delta: { content: [{ type: "text", text: "answer" }] }, + }, + ], + }, + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: null, + delta: { + tool_calls: [ + { + id: "abc123456", + index: 0, + function: { name: "lookup", arguments: '{"query":' }, + }, + ], + }, + }, + ], + }, + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: "tool_calls", + delta: { + tool_calls: [ + { + id: "abc123456", + index: 0, + function: { name: "lookup", arguments: '"pi"}' }, + }, + ], + }, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 4, + total_tokens: 14, + prompt_tokens_details: { cached_tokens: 3 }, + }, + }, + ]; + const fetch: FetchFunction = async () => createSseResponse(events); + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("toolUse"); + expect(message.rawStopReason).toBe("tool_calls"); + expect(message.responseId).toBe("response-1"); + expect(message.content).toEqual([ + { type: "thinking", thinking: "reason" }, + { type: "text", text: "answer" }, + { type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } }, + ]); + expect(message.usage).toMatchObject({ input: 7, output: 4, cacheRead: 3, cacheWrite: 0, totalTokens: 14 }); + }); + + it("parses SSE and UTF-8 sequences split across transport chunks", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const fetch: FetchFunction = async () => + createBytewiseSseResponse({ + id: "response-bytewise", + model: model.id, + choices: [{ index: 0, finish_reason: "stop", delta: { content: "héllo 🌍" } }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }); + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("stop"); + expect(message.content).toEqual([{ type: "text", text: "héllo 🌍" }]); + }); + + it("honors case-insensitive header overrides and explicit affinity suppression", async () => { + const model = { + ...getModel("mistral", "mistral-large-latest"), + headers: { Authorization: "Bearer model-key", "X-Affinity": "model-affinity" }, + }; + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + let requestHeaders: Headers | undefined; + const fetch: FetchFunction = async (_input, init) => { + requestHeaders = new Headers(init?.headers); + return createSseResponse([createTerminalEvent()]); + }; + + await streamMistral(model, context, { + apiKey: "request-key", + fetch, + sessionId: "automatic-affinity", + headers: { authorization: null, "x-affinity": null, "User-Agent": "custom-agent" }, + }).result(); + + expect(requestHeaders?.has("authorization")).toBe(false); + expect(requestHeaders?.has("x-affinity")).toBe(false); + expect(requestHeaders?.get("user-agent")).toBe("custom-agent"); + }); + + it("aborts while waiting for an SSE chunk", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const controller = new AbortController(); + const fetch: FetchFunction = async () => + new Response( + new ReadableStream({ + start() {}, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + + const result = streamMistral(model, context, { + apiKey: "test", + fetch, + signal: controller.signal, + }).result(); + controller.abort(); + const message = await result; + + expect(message.stopReason).toBe("aborted"); + }); + + it("applies the request timeout while waiting for an SSE chunk", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const fetch: FetchFunction = async () => + new Response( + new ReadableStream({ + start() {}, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + + const message = await streamMistral(model, context, { + apiKey: "test", + fetch, + timeoutMs: 5, + }).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toMatch(/timeout/i); + }); + + it("preserves HTTP status and response bodies in errors", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const fetch: FetchFunction = async () => + new Response('{"message":"blocked by gateway"}', { status: 403, statusText: "Forbidden" }); + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toBe('Mistral API error (403): {"message":"blocked by gateway"}'); + }); +}); diff --git a/packages/ai/test/mistral-raw-stop-reason.test.ts b/packages/ai/test/mistral-raw-stop-reason.test.ts index 782e057a2c5..512d92c244e 100644 --- a/packages/ai/test/mistral-raw-stop-reason.test.ts +++ b/packages/ai/test/mistral-raw-stop-reason.test.ts @@ -1,54 +1,39 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mistralMock = vi.hoisted(() => ({ - finishReason: "stop" as string, -})); - -vi.mock("@mistralai/mistralai", () => { - class HTTPClient {} - - class Mistral { - chat = { - stream: async function* () { - yield { - data: { - id: "mistral-response-id", - choices: [ - { - finishReason: mistralMock.finishReason, - delta: {}, - }, - ], - usage: { - promptTokens: 1, - completionTokens: 0, - totalTokens: 1, - }, - }, - }; - }, - }; - } - - return { HTTPClient, Mistral }; -}); - +import { describe, expect, it } from "vitest"; import { stream as streamMistral } from "../src/api/mistral-conversations.ts"; import { getModel } from "../src/compat.ts"; -import type { Context } from "../src/types.ts"; +import type { Context, FetchFunction } from "../src/types.ts"; const model = getModel("mistral", "devstral-medium-latest"); const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; -describe("Mistral raw stop reasons", () => { - beforeEach(() => { - mistralMock.finishReason = "stop"; - }); +function createFetch(finishReason: string): FetchFunction { + return async () => + new Response( + `data: ${JSON.stringify({ + id: "mistral-response-id", + model: model.id, + choices: [ + { + index: 0, + finish_reason: finishReason, + delta: {}, + }, + ], + usage: { + prompt_tokens: 1, + completion_tokens: 0, + total_tokens: 1, + }, + })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); +} +describe("Mistral raw stop reasons", () => { it("preserves raw Mistral finish reasons for successful stops", async () => { - const message = await streamMistral(model, context, { apiKey: "test" }).result(); + const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("stop") }).result(); expect(message.stopReason).toBe("stop"); expect(message.rawStopReason).toBe("stop"); @@ -56,9 +41,7 @@ describe("Mistral raw stop reasons", () => { }); it("preserves raw Mistral finish reasons for provider error stops", async () => { - mistralMock.finishReason = "error"; - - const message = await streamMistral(model, context, { apiKey: "test" }).result(); + const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("error") }).result(); expect(message.stopReason).toBe("error"); expect(message.rawStopReason).toBe("error"); @@ -66,9 +49,10 @@ describe("Mistral raw stop reasons", () => { }); it("treats unknown Mistral finish reasons as provider error stops", async () => { - mistralMock.finishReason = "unmapped_error"; - - const message = await streamMistral(model, context, { apiKey: "test" }).result(); + const message = await streamMistral(model, context, { + apiKey: "test", + fetch: createFetch("unmapped_error"), + }).result(); expect(message.stopReason).toBe("error"); expect(message.rawStopReason).toBe("unmapped_error"); diff --git a/packages/ai/test/model-catalog-types.test.ts b/packages/ai/test/model-catalog-types.test.ts index 0facda3dbc8..0918228fe8c 100644 --- a/packages/ai/test/model-catalog-types.test.ts +++ b/packages/ai/test/model-catalog-types.test.ts @@ -6,7 +6,9 @@ it("derives model API, ID, and provider literals from grouped model data", () => expectTypeOf(XAI_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">(); expectTypeOf(XAI_MODELS["grok-4.5"].id).toEqualTypeOf<"grok-4.5">(); expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">(); - expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-completions">(); + expectTypeOf(XAI_MODELS["grok-4.6"].api).toEqualTypeOf<"openai-responses">(); + expectTypeOf(XAI_MODELS["grok-4.6"].id).toEqualTypeOf<"grok-4.6">(); + expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">(); }); it("routes GitHub Copilot Grok 4.5 through the Responses API", () => { diff --git a/packages/ai/test/model-data-validation.test.ts b/packages/ai/test/model-data-validation.test.ts index fa84fd1ff89..f0e1b9fbcfc 100644 --- a/packages/ai/test/model-data-validation.test.ts +++ b/packages/ai/test/model-data-validation.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + assertExactModelIds, createModelDataManifest, MODEL_DATA_MANIFEST_FILE, MODEL_DATA_SCHEMA_VERSION, @@ -77,6 +78,18 @@ function writeFixtureData( } describe("generated model data validation", () => { + it("rejects a missing upstream model from an exact generated allowlist", () => { + expect(() => assertExactModelIds("qwen-token-plan-individual", ["model-a", "model-b"], ["model-a"])).toThrow( + "qwen-token-plan-individual model IDs do not match (missing: model-b)", + ); + }); + + it("rejects an unexpected model from an exact generated allowlist", () => { + expect(() => assertExactModelIds("test-provider", ["model-a"], ["model-a", "model-b"])).toThrow( + "test-provider model IDs do not match (extra: model-b)", + ); + }); + it("reads and validates API-grouped model data", () => { const { dataDir, packageRoot, structure } = createFixture(); expect(readModelDataStructure(packageRoot)).toEqual(structure); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index a8423c887d9..1ef93f3be50 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { arch, platform, release, tmpdir } from "node:os"; import { join } from "node:path"; import { zstdDecompressSync } from "node:zlib"; import { Type } from "typebox"; @@ -49,9 +49,11 @@ function decodeCodexRequestBody(body: RequestInit["body"] | undefined): Record { expect(headers?.get("chatgpt-account-id")).toBe("acc_test"); expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental"); expect(headers?.get("originator")).toBe("pi"); + expect(headers?.get("User-Agent")).toBe(`pi (${platform()} ${release()}; ${arch()})`); expect(headers?.get("accept")).toBe("text/event-stream"); expect(headers?.has("x-api-key")).toBe(false); return new Response(stream, { @@ -210,7 +214,7 @@ describe("openai-codex streaming", () => { process.env.PI_CODING_AGENT_DIR = tempDir; const token = mockToken(); const encoder = new TextEncoder(); - const sse = buildSSEPayload({ status: "completed", includeDone: true }); + const sse = buildSSEPayload({ status: "completed", includeDone: true, endTurn: false }); const stream = new ReadableStream({ start(controller) { @@ -263,6 +267,7 @@ describe("openai-codex streaming", () => { expect(result.content.find((c) => c.type === "text")?.text).toBe("Hello"); expect(result.stopReason).toBe("stop"); + expect(result.endTurn).toBe(false); }); it("maps response.incomplete to stopReason length even when the SSE body stays open", async () => { @@ -1273,6 +1278,7 @@ describe("openai-codex streaming", () => { type: "response.completed", response: { status: "completed", + end_turn: false, usage: { input_tokens: 5, output_tokens: 3, @@ -1317,12 +1323,13 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: 1 }], }; - await streamSimpleOpenAICodexResponses(model, context, { + const result = await streamSimpleOpenAICodexResponses(model, context, { apiKey: token, sessionId: "session-auto", transport: "auto", }).result(); + expect(result.endTurn).toBe(false); expect(sentBodies).toHaveLength(1); expect(capturedWebSocketHeaders?.["session-id"]).toBe("session-auto"); expect(capturedWebSocketHeaders?.session_id).toBeUndefined(); diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index 88d42874488..a76c3b1dd8e 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -38,6 +38,21 @@ vi.mock("openai", () => { }); const reasoningDetail = { type: "reasoning.encrypted", id: "call_1", data: "encrypted-signature" }; +const signedReasoningTextDetail = { + type: "reasoning.text", + text: "I should call the read tool.", + signature: "sha256:signed-text", + id: "reasoning-text-1", + format: "anthropic-claude-v1", + index: 0, +}; +const reasoningSummaryDetail = { + type: "reasoning.summary", + summary: "Decided to inspect the requested file.", + id: "reasoning-summary-1", + format: "anthropic-claude-v1", + index: 1, +}; const readTool: Tool = { name: "read", description: "Read a file", @@ -84,9 +99,11 @@ async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Pr return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result(); } -function getAssistantPayload(payload: unknown): { reasoning_details?: unknown } | undefined { - const messages = (payload as { messages?: Array<{ role?: string; reasoning_details?: unknown }> }).messages ?? []; - return messages.find((message) => message.role === "assistant"); +function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined { + const messages = ( + payload as { messages?: Array<{ role?: string; reasoning?: unknown; reasoning_details?: unknown }> } + ).messages; + return messages?.find((message) => message.role === "assistant"); } describe("openai-completions reasoning_details streaming", () => { @@ -95,24 +112,140 @@ describe("openai-completions reasoning_details streaming", () => { mockState.payloads = []; }); - it("preserves reasoning_details that arrive before their matching tool call", async () => { + it("preserves reasoning_details in the thinking signature", async () => { mockState.chunkSets = [ [chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")], [chunk({ content: "ok" }), chunk({}, "stop")], ]; const assistantMessage = await runOpenAICompletionsStream(); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: "", + thinkingSignature: JSON.stringify([reasoningDetail]), + }); const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); - expect(toolCall).toMatchObject({ + expect(toolCall).toEqual({ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" }, - thoughtSignature: JSON.stringify(reasoningDetail), }); await runOpenAICompletionsStream([assistantMessage]); expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]); }); + + it("falls back to encrypted tool-call signatures for older stored assistant messages", async () => { + mockState.chunkSets = [ + [chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + assistantMessage.content = assistantMessage.content.filter((block) => block.type !== "thinking"); + const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); + if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected tool call"); + toolCall.thoughtSignature = JSON.stringify(reasoningDetail); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]); + }); + + it("preserves signed text and summary reasoning_details in their original sequence", async () => { + mockState.chunkSets = [ + [ + chunk({ reasoning: signedReasoningTextDetail.text, reasoning_details: [signedReasoningTextDetail] }), + chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }), + toolCallChunk(), + chunk({}, "tool_calls"), + ], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail]; + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: signedReasoningTextDetail.text, + thinkingSignature: JSON.stringify(expectedReasoningDetails), + }); + + await runOpenAICompletionsStream([assistantMessage]); + + const payload = getAssistantPayload(mockState.payloads[1]); + expect(payload?.reasoning_details).toEqual(expectedReasoningDetails); + expect(payload?.reasoning).toBeUndefined(); + }); + + it("merges consecutive text and summary reasoning_details deltas before replay", async () => { + const textDelta = { type: "reasoning.text", text: "The", index: 0 }; + const textDeltaWithSignature = { + type: "reasoning.text", + text: " user wants the time.", + signature: "sha256:text-signature", + format: "openai-responses-v1", + index: 0, + }; + const summaryDelta = { type: "reasoning.summary", summary: "Looked", index: 0 }; + const summaryDeltaWithFormat = { + type: "reasoning.summary", + summary: " up time.", + format: "openai-responses-v1", + index: 0, + }; + const laterSummaryDelta = { + type: "reasoning.summary", + summary: "After encrypted block.", + format: "openai-responses-v1", + index: 0, + }; + const expectedReasoningDetails = [ + { + type: "reasoning.text", + text: "The user wants the time.", + index: 0, + signature: "sha256:text-signature", + format: "openai-responses-v1", + }, + { + type: "reasoning.summary", + summary: "Looked up time.", + index: 0, + format: "openai-responses-v1", + }, + reasoningDetail, + laterSummaryDelta, + ]; + + mockState.chunkSets = [ + [ + chunk({ reasoning_details: [textDelta] }), + chunk({ reasoning_details: [textDeltaWithSignature] }), + chunk({ reasoning_details: [summaryDelta] }), + chunk({ reasoning_details: [summaryDeltaWithFormat] }), + chunk({ reasoning_details: [reasoningDetail] }), + chunk({ reasoning_details: [laterSummaryDelta] }), + toolCallChunk(), + chunk({}, "tool_calls"), + ], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: "", + thinkingSignature: JSON.stringify(expectedReasoningDetails), + }); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + }); }); diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index 2e451f6b715..44e14416b31 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -39,15 +39,20 @@ const compat = { chatTemplateArgs: {}, zaiToolStream: false, supportsThinkingTokenBudget: false, + thinkingTokenBudgetField: undefined, supportsStrictMode: true, supportsOpenAIGrammarTools: false, cacheControlFormat: undefined, sendSessionAffinityHeaders: false, sessionAffinityFormat: "openai", supportsLongCacheRetention: true, -} satisfies Omit, "cacheControlFormat" | "deferredToolsMode"> & { +} satisfies Omit< + Required, + "cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField" +> & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; + thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; }; function buildModel(baseUrl = "http://127.0.0.1:1"): Model<"openai-completions"> { diff --git a/packages/ai/test/openai-completions-thinking-token-budget.test.ts b/packages/ai/test/openai-completions-thinking-token-budget.test.ts index 2ca0c3ce03e..3f5cfa4ea5d 100644 --- a/packages/ai/test/openai-completions-thinking-token-budget.test.ts +++ b/packages/ai/test/openai-completions-thinking-token-budget.test.ts @@ -41,21 +41,35 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); -// vLLM-served reasoning model: reasoning and the answer share max_tokens. -const vllmModel: Model<"openai-completions"> = { - id: "zai-org/glm-5.2", - name: "GLM 5.2 (local vLLM)", - api: "openai-completions", - provider: "local-vllm", - baseUrl: "http://localhost:8000/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 16384, - compat: { thinkingFormat: "zai", supportsThinkingTokenBudget: true }, +type CapturedParams = { + thinking_token_budget?: number; + thinking_budget?: number; + thinking_budget_tokens?: number; + thinking?: unknown; + chat_template_kwargs?: Record; }; +function vllmModel( + compat: Model<"openai-completions">["compat"] = { + thinkingFormat: "zai", + supportsThinkingTokenBudget: true, + }, +): Model<"openai-completions"> { + return { + id: "zai-org/glm-5.2", + name: "GLM 5.2 (local vLLM)", + api: "openai-completions", + provider: "local-vllm", + baseUrl: "http://localhost:8000/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 16384, + compat, + }; +} + async function capture( model: Model<"openai-completions">, options?: { @@ -63,7 +77,7 @@ async function capture( thinkingBudgets?: ThinkingBudgets; maxTokens?: number; }, -): Promise<{ thinking_token_budget?: number; thinking?: unknown }> { +): Promise { let payload: unknown; await streamSimple( @@ -80,45 +94,109 @@ async function capture( }, ).result(); - return (payload ?? mockState.lastParams) as { thinking_token_budget?: number; thinking?: unknown }; + return (payload ?? mockState.lastParams) as CapturedParams; } -describe("openai-completions thinking_token_budget", () => { +describe("openai-completions thinking token budget", () => { beforeEach(() => { mockState.lastParams = undefined; }); it("sends the configured budget for the requested level", async () => { - const params = await capture(vllmModel, { reasoning: "medium", thinkingBudgets: { medium: 4096 } }); + const params = await capture(vllmModel(), { reasoning: "medium", thinkingBudgets: { medium: 4096 } }); expect(params.thinking_token_budget).toBe(4096); }); - it("omits the budget when the compat flag is not set", async () => { - const model = { ...vllmModel, compat: { thinkingFormat: "zai" } } as Model<"openai-completions">; - const params = await capture(model, { reasoning: "medium", thinkingBudgets: { medium: 4096 } }); + it("omits the budget when neither the field nor the alias is set", async () => { + const params = await capture(vllmModel({ thinkingFormat: "zai" }), { + reasoning: "medium", + thinkingBudgets: { medium: 4096 }, + }); expect(params.thinking_token_budget).toBeUndefined(); + expect(params.thinking_budget).toBeUndefined(); + expect(params.thinking_budget_tokens).toBeUndefined(); }); it("omits the budget when thinking is off", async () => { - const params = await capture(vllmModel, { reasoning: undefined, thinkingBudgets: { high: 8192 } }); + const params = await capture(vllmModel(), { reasoning: undefined, thinkingBudgets: { high: 8192 } }); expect(params.thinking_token_budget).toBeUndefined(); }); it("clamps xhigh and max to the high budget", async () => { - const xhigh = await capture(vllmModel, { reasoning: "xhigh", thinkingBudgets: { high: 8192 } }); - const max = await capture(vllmModel, { reasoning: "max", thinkingBudgets: { high: 8192 } }); + const xhigh = await capture(vllmModel(), { reasoning: "xhigh", thinkingBudgets: { high: 8192 } }); + const max = await capture(vllmModel(), { reasoning: "max", thinkingBudgets: { high: 8192 } }); expect(xhigh.thinking_token_budget).toBe(8192); expect(max.thinking_token_budget).toBe(8192); }); it("leaves room for the answer when the budget meets the response ceiling", async () => { - // Default high budget (16384) equals the model ceiling, which would leave no answer. - const params = await capture(vllmModel, { reasoning: "high" }); + const params = await capture(vllmModel(), { reasoning: "high" }); expect(params.thinking_token_budget).toBe(16384 - 1024); }); it("uses the caller max_tokens as the ceiling when it is lower than the model cap", async () => { - const params = await capture(vllmModel, { reasoning: "high", thinkingBudgets: { high: 8192 }, maxTokens: 4096 }); + const params = await capture(vllmModel(), { + reasoning: "high", + thinkingBudgets: { high: 8192 }, + maxTokens: 4096, + }); expect(params.thinking_token_budget).toBe(4096 - 1024); }); + + it.each(["thinking_budget", "thinking_budget_tokens"] as const)( + "sends %s when thinkingTokenBudgetField is set", + async (field) => { + const params = await capture(vllmModel({ thinkingFormat: "qwen", thinkingTokenBudgetField: field }), { + reasoning: "medium", + thinkingBudgets: { medium: 4096 }, + }); + expect(params[field]).toBe(4096); + expect(params.thinking_token_budget).toBeUndefined(); + }, + ); + + it("lets thinkingTokenBudgetField win over the boolean alias", async () => { + const params = await capture( + vllmModel({ + thinkingFormat: "zai", + supportsThinkingTokenBudget: true, + thinkingTokenBudgetField: "thinking_budget", + }), + { reasoning: "medium", thinkingBudgets: { medium: 4096 } }, + ); + expect(params.thinking_budget).toBe(4096); + expect(params.thinking_token_budget).toBeUndefined(); + }); + + it("puts the clamped budget in chat_template_kwargs when $var is thinking.budget", async () => { + const params = await capture( + vllmModel({ + thinkingFormat: "chat-template", + chatTemplateKwargs: { + enable_thinking: { $var: "thinking.enabled" }, + thinking_budget: { $var: "thinking.budget" }, + }, + }), + { reasoning: "high" }, + ); + expect(params.chat_template_kwargs).toEqual({ + enable_thinking: true, + thinking_budget: 16384 - 1024, + }); + expect(params.thinking_token_budget).toBeUndefined(); + }); + + it("omits thinking.budget from chat_template_kwargs when thinking is off", async () => { + const params = await capture( + vllmModel({ + thinkingFormat: "chat-template", + chatTemplateKwargs: { + enable_thinking: { $var: "thinking.enabled" }, + thinking_budget: { $var: "thinking.budget" }, + }, + }), + { reasoning: undefined }, + ); + expect(params.chat_template_kwargs).toEqual({ enable_thinking: false }); + }); }); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index f21b748599e..c453f3d5403 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -295,15 +295,31 @@ describe("openai-completions tool_choice", () => { expect(getModel("zai", "glm-5.2")?.compat?.zaiToolStream).toBe(true); }); - it("stores z.ai GLM-5.2 effort metadata", () => { + it("stores z.ai effort metadata", () => { for (const provider of ["zai", "zai-coding-cn"] as const) { - const model = getModel(provider, "glm-5.2")!; - expect(model.compat?.supportsReasoningEffort).toBe(true); - expect(model.thinkingLevelMap).toEqual({ + for (const modelId of ["glm-5.2", "glm-5.2-highspeed"] as const) { + const model = getModel(provider, modelId)!; + expect(model.compat?.supportsReasoningEffort).toBe(true); + expect(model.thinkingLevelMap).toEqual({ + off: "none", + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", + }); + } + + const glm53 = getModel(provider, "glm-5.3")!; + expect(glm53.compat?.supportsReasoningEffort).toBe(true); + expect(glm53.thinkingLevelMap).toEqual({ + off: null, minimal: null, - low: "high", - medium: "high", + low: "low", + medium: null, high: "high", + xhigh: null, max: "max", }); } @@ -1127,7 +1143,7 @@ describe("openai-completions tool_choice", () => { }); it("stores Qwen Token Plan reasoning replay compat in built-in metadata", () => { - const providers = ["qwen-token-plan", "qwen-token-plan-cn"] as const; + const providers = ["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const; for (const provider of providers) { const model = getModel(provider, "qwen3.7-max")!; @@ -1403,7 +1419,7 @@ describe("openai-completions tool_choice", () => { }); it("sends max_tokens for OpenCode completions models", async () => { - const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const; + const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "kimi-k2.6")!] as const; for (const model of cases) { let payload: unknown; @@ -1429,6 +1445,53 @@ describe("openai-completions tool_choice", () => { } }); + it("sends max_tokens for built-in and custom DeepSeek API models", async () => { + const customModel = { + ...localOpenAICompletionsModel, + id: "custom-deepseek-model", + name: "Custom DeepSeek Model", + provider: "custom-deepseek", + baseUrl: "https://api.deepseek.com", + } satisfies Model<"openai-completions">; + const customUppercaseModel = { + ...customModel, + id: "custom-uppercase-deepseek-model", + name: "Custom Uppercase DeepSeek Model", + baseUrl: "https://API.DeepSeek.COM", + } satisfies Model<"openai-completions">; + const nativeModels = [ + getModel("deepseek", "deepseek-v4-flash")!, + getModel("deepseek", "deepseek-v4-pro")!, + ] as const; + const cases = [...nativeModels, customModel, customUppercaseModel] as const; + + for (const model of nativeModels) { + expect(model.compat?.maxTokensField).toBe("max_tokens"); + } + + for (const model of cases) { + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + maxTokens: 123, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { max_tokens?: number; max_completion_tokens?: number }; + expect(params.max_tokens).toBe(123); + expect(params.max_completion_tokens).toBeUndefined(); + } + }); + it("sends max_tokens for Z.AI completions models", async () => { const cases = [getModel("zai", "glm-5-turbo")!, getModel("zai", "glm-5.2")!] as const; diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 6beb7f284e1..de692a66518 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -19,8 +19,9 @@ const emptyUsage: Usage = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; -const compat: Omit, "deferredToolsMode"> & { +const compat: Omit, "deferredToolsMode" | "thinkingTokenBudgetField"> & { deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; + thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; } = { supportsStore: true, supportsDeveloperRole: true, @@ -39,6 +40,7 @@ const compat: Omit, "deferredToolsMode"> & { chatTemplateArgs: {}, zaiToolStream: false, supportsThinkingTokenBudget: false, + thinkingTokenBudgetField: undefined, supportsStrictMode: true, supportsOpenAIGrammarTools: false, cacheControlFormat: "anthropic", diff --git a/packages/ai/test/openai-responses-compat.test.ts b/packages/ai/test/openai-responses-compat.test.ts index cabab8258c5..c98b2ed5562 100644 --- a/packages/ai/test/openai-responses-compat.test.ts +++ b/packages/ai/test/openai-responses-compat.test.ts @@ -9,6 +9,7 @@ type CapturedHeaders = Headers | string[][] | Record; } function getHeader(headers: CapturedHeaders, name: string): string | null { @@ -153,6 +154,57 @@ describe("openai-responses provider defaults", () => { }); }); + it("sets strict mode explicitly for Cloudflare OpenAI Responses tools", async () => { + const model = getModel("cloudflare-ai-gateway", "gpt-5.6-sol"); + let capturedPayload: CapturedResponsesPayload | undefined; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + model, + { + messages: [{ role: "user", content: "Use a tool.", timestamp: Date.now() }], + tools: [ + { + name: "ordinary", + description: "An ordinary tool", + parameters: Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + }), + }, + { + name: "constrained", + description: "A constrained tool", + parameters: Type.Object({ value: Type.String() }), + constrainedSampling: { type: "json_schema", strict: "prefer" }, + }, + ], + }, + { + apiKey: "test-key", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(model.compat?.supportsStrictMode).toBe(true); + expect(capturedPayload?.tools).toEqual([ + expect.objectContaining({ name: "ordinary", strict: false }), + expect.objectContaining({ name: "constrained", strict: true }), + ]); + }); + it.each([ "gpt-5.1", "gpt-5.2", diff --git a/packages/ai/test/openai-responses-namespace.test.ts b/packages/ai/test/openai-responses-namespace.test.ts new file mode 100644 index 00000000000..75fc4eac19d --- /dev/null +++ b/packages/ai/test/openai-responses-namespace.test.ts @@ -0,0 +1,224 @@ +import type { ResponseStreamEvent } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import { convertResponsesMessages, processResponsesStream } from "../src/api/openai-responses-shared.ts"; +import type { Api, AssistantMessage, Model, ToolCall } from "../src/types.ts"; +import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; + +const model: Model<"openai-responses"> = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, +}; + +function createOutput(): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: Date.now(), + }; +} + +async function* createFunctionCallEvents(): AsyncIterable { + yield { + type: "response.output_item.added", + sequence_number: 0, + output_index: 0, + item: { + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "lookup", + arguments: "", + }, + } as ResponseStreamEvent; + yield { + type: "response.output_item.done", + sequence_number: 1, + output_index: 0, + item: { + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "lookup", + arguments: '{"value":"hello"}', + namespace: "dynamic_tools", + }, + } as ResponseStreamEvent; + yield { + type: "response.completed", + sequence_number: 2, + response: { id: "resp_test", status: "completed" }, + } as ResponseStreamEvent; +} + +async function* createCustomToolCallEvents(): AsyncIterable { + yield { + type: "response.output_item.added", + sequence_number: 0, + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_test", + call_id: "call_test", + name: "query", + input: "", + }, + } as ResponseStreamEvent; + yield { + type: "response.output_item.done", + sequence_number: 1, + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_test", + call_id: "call_test", + name: "query", + input: "hello", + namespace: "dynamic_tools", + }, + } as ResponseStreamEvent; + yield { + type: "response.completed", + sequence_number: 2, + response: { id: "resp_test", status: "completed" }, + } as ResponseStreamEvent; +} + +function getToolCall(output: AssistantMessage): ToolCall { + const block = output.content[0]; + if (!block || block.type !== "toolCall") throw new Error("Expected toolCall block"); + return block; +} + +describe("OpenAI Responses tool-call namespaces", () => { + it("round-trips a function namespace received only on output_item.done", async () => { + const output = createOutput(); + await processResponsesStream(createFunctionCallEvents(), output, new AssistantMessageEventStream(), model); + + const toolCall = getToolCall(output); + expect(toolCall).toMatchObject({ + id: "call_test|fc_test", + name: "lookup", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }); + + const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"])).find( + (item) => item.type === "function_call", + ); + expect(replayed).toMatchObject({ + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "lookup", + arguments: '{"value":"hello"}', + namespace: "dynamic_tools", + }); + }); + + it("round-trips a custom-tool namespace received only on output_item.done", async () => { + const output = createOutput(); + const grammarToolInputProperties = new Map([["query", "input"]]); + await processResponsesStream(createCustomToolCallEvents(), output, new AssistantMessageEventStream(), model, { + grammarToolInputProperties, + }); + + const toolCall = getToolCall(output); + expect(toolCall).toMatchObject({ + id: "call_test|ctc_test", + name: "query", + arguments: { input: "hello" }, + namespace: "dynamic_tools", + }); + + const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"]), { + grammarToolInputProperties, + }).find((item) => item.type === "custom_tool_call"); + expect(replayed).toMatchObject({ + type: "custom_tool_call", + id: "ctc_test", + call_id: "call_test", + name: "query", + input: "hello", + namespace: "dynamic_tools", + }); + }); + + it("drops namespaces when the target cannot replay their load items", () => { + const output = createOutput(); + output.content.push( + { + type: "toolCall", + id: "call_function|fc_test", + name: "lookup", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }, + { + type: "toolCall", + id: "call_custom|ctc_test", + name: "query", + arguments: { input: "hello" }, + namespace: "dynamic_tools", + }, + ); + const targetModels: Model[] = [ + { ...model, id: "gpt-5.2", name: "GPT-5.2" }, + { ...model, provider: "azure-openai-responses" }, + { + ...model, + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + }, + ]; + + for (const targetModel of targetModels) { + const replayed = convertResponsesMessages(targetModel, { messages: [output] }, new Set(["openai"]), { + grammarToolInputProperties: new Map([["query", "input"]]), + }); + const functionCall = replayed.find((item) => item.type === "function_call"); + const customToolCall = replayed.find((item) => item.type === "custom_tool_call"); + expect(functionCall).toBeDefined(); + expect(functionCall).not.toHaveProperty("namespace"); + expect(customToolCall).toBeDefined(); + expect(customToolCall).not.toHaveProperty("namespace"); + } + }); + + it("does not add a namespace to ordinary function calls", () => { + const output = createOutput(); + output.content.push({ + type: "toolCall", + id: "call_test|fc_test", + name: "lookup", + arguments: { value: "hello" }, + }); + + const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"])).find( + (item) => item.type === "function_call", + ); + expect(replayed).toBeDefined(); + expect(replayed).not.toHaveProperty("namespace"); + }); +}); diff --git a/packages/ai/test/pi-messages.test.ts b/packages/ai/test/pi-messages.test.ts index f0c49a962db..62f7fae434d 100644 --- a/packages/ai/test/pi-messages.test.ts +++ b/packages/ai/test/pi-messages.test.ts @@ -165,13 +165,13 @@ describe("pi-messages", () => { const model = createModel(baseUrl); let observedHeaders: Record | undefined; - const options: PiMessagesOptions = { + const options = { apiKey: "test-key", debug: true, onResponse: (response) => { observedHeaders = response.headers; }, - }; + } satisfies PiMessagesOptions; const message = await streamSimple(model, context, options).result(); expect(message.stopReason).toBe("stop"); diff --git a/packages/ai/test/qwen-token-plan-models.test.ts b/packages/ai/test/qwen-token-plan-models.test.ts index 98c31cc4a57..d1dcef09c81 100644 --- a/packages/ai/test/qwen-token-plan-models.test.ts +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { getModels, streamSimple } from "../src/compat.ts"; +import { findEnvKeys } from "../src/env-api-keys.ts"; vi.mock("openai", () => { class FakeOpenAI { @@ -56,6 +57,17 @@ const TEXT_MODELS = [ "qwen3.8-max", ]; +const INDIVIDUAL_TEXT_MODELS = [ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "deepseek-v4-pro-0813", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", +]; + const IMAGE_MODELS = ["qwen-image-2.0", "qwen-image-2.0-pro", "wan2.7-image", "wan2.7-image-pro"]; const QWEN_THINKING_MODELS = [ @@ -75,17 +87,43 @@ const QWEN_THINKING_MODELS = [ "qwen3.8-max", ] as const; -const QWEN_THINKING_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => - QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })), -); +type QwenTokenPlanProvider = "qwen-token-plan" | "qwen-token-plan-cn" | "qwen-token-plan-individual"; +type QwenTokenPlanModelCase = { provider: QwenTokenPlanProvider; modelId: string }; + +const QWEN_THINKING_MODEL_CASES: QwenTokenPlanModelCase[] = [ + ...(["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })), + ), + ...INDIVIDUAL_TEXT_MODELS.map((modelId) => ({ provider: "qwen-token-plan-individual" as const, modelId })), +]; const QWEN_REASONING_EFFORT_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1", "glm-5.2"] as const; -const QWEN_REASONING_EFFORT_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => - QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })), -); +const QWEN_REASONING_EFFORT_MODEL_CASES: QwenTokenPlanModelCase[] = [ + ...(["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })), + ), + ...["deepseek-v4-flash-0731", "deepseek-v4-pro", "deepseek-v4-pro-0813", "glm-5.2"].map((modelId) => ({ + provider: "qwen-token-plan-individual" as const, + modelId, + })), +]; describe("Qwen Token Plan models", () => { + it("exposes exactly the documented Individual text models", () => { + const modelIds = getModels("qwen-token-plan-individual") + .map((model) => model.id) + .sort(); + + expect(modelIds).toEqual([...INDIVIDUAL_TEXT_MODELS].sort()); + }); + + it("reuses the international Token Plan environment variable", () => { + expect(findEnvKeys("qwen-token-plan-individual", { QWEN_TOKEN_PLAN_API_KEY: "test" })).toEqual([ + "QWEN_TOKEN_PLAN_API_KEY", + ]); + }); + it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)("exposes all text models on %s", (provider) => { const modelIds = getModels(provider).map((model) => model.id); for (const expected of TEXT_MODELS) { @@ -152,7 +190,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "exposes qwen3.8 reasoning_effort levels on %s", (provider) => { const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max"); @@ -170,7 +208,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "omits retired qwen3.8-max-preview on %s", (provider) => { const modelIds = getModels(provider).map((model) => model.id); @@ -210,7 +248,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "sends qwen3.8 max reasoning_effort on %s", async (provider) => { const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max"); diff --git a/packages/ai/test/retry.test.ts b/packages/ai/test/retry.test.ts index fc79f6916b8..0d8875e08c0 100644 --- a/packages/ai/test/retry.test.ts +++ b/packages/ai/test/retry.test.ts @@ -40,6 +40,17 @@ describe("provider retry classification", () => { ).toBe(true); }); + it("matches upstream request buffer exhaustion wording", () => { + expect( + isRetryableAssistantError( + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "Error: exceeded request buffer limit while retrying upstream", + }), + ), + ).toBe(true); + }); + it.each([ wrappedDnsLookupError, "connect ENOTFOUND api.example.com", diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index f4e5b4890aa..2fe1604ad43 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -542,7 +542,7 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Completions)", () => { + describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Responses)", () => { const llm = getModel("xai", "grok-4.3"); it("should complete basic text generation", { retry: 3 }, async () => { @@ -702,9 +702,9 @@ describe("Generate E2E Tests", () => { ); describe.skipIf(!hasCloudflareAiGatewayCredentials() || !process.env.ANTHROPIC_API_KEY)( - "Cloudflare AI Gateway → Anthropic BYOK (claude-sonnet-4-5 via /anthropic messages)", + "Cloudflare AI Gateway → Anthropic BYOK (claude-sonnet-4.5 via /anthropic messages)", () => { - const llm = getModel("cloudflare-ai-gateway", "claude-sonnet-4-5"); + const llm = getModel("cloudflare-ai-gateway", "claude-sonnet-4.5"); const options = { headers: { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` } }; const thinkingOptions = { ...options, @@ -988,13 +988,13 @@ describe("Generate E2E Tests", () => { }); it("should handle thinking mode", { retry: 3 }, async () => { - const llm = getModel("mistral", "magistral-medium-latest"); - await handleThinking(llm, { promptMode: "reasoning" }); + const llm = getModel("mistral", "mistral-small-2603"); + await handleThinking(llm, { reasoningEffort: "high" }); }); it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - const llm = getModel("mistral", "magistral-medium-latest"); - await multiTurn(llm, { promptMode: "reasoning" }); + const llm = getModel("mistral", "mistral-small-2603"); + await multiTurn(llm, { reasoningEffort: "high" }); }); }); @@ -1224,6 +1224,37 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)( + "Qwen Token Plan Individual Provider (Qwen3.8-Max, international)", + () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }, + ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Provider (Qwen3.7-Max, CN region)", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); const thinkingOptions = { diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 29e16fa8280..e2e428a0a9f 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -82,16 +82,16 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["medium", "high", "xhigh"]); }); - it("includes only high/max plus off for DeepSeek V4 Flash on the DeepSeek provider", () => { + it("includes low/high/max plus off for DeepSeek V4 Flash on the DeepSeek provider", () => { const model = getModel("deepseek", "deepseek-v4-flash"); expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "max"]); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); - it("includes only high/max plus off for DeepSeek V4 Flash on opencode-go", () => { + it("includes low/high/max plus off for DeepSeek V4 Flash on opencode-go", () => { const model = getModel("opencode-go", "deepseek-v4-flash"); expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "max"]); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); it("includes only high plus off for OpenCode Go Kimi K2.6", () => { @@ -147,6 +147,12 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("max"); }); + it("includes xhigh but not off or max for xAI Grok 4.6", () => { + const model = getModel("xai", "grok-4.6"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["low", "medium", "high", "xhigh"]); + }); + it("includes xhigh and max but not off for Bedrock Claude Fable 5", () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); expect(model).toBeDefined(); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 57930f01230..95a64c3d194 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -292,6 +292,14 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index ec59c13cd73..57945ccadb9 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -270,6 +270,14 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider", () => { + const model = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index f6785f2234e..fabff7f6962 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -222,10 +222,10 @@ describe("totalTokens field", () => { describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { it( - "gemini-2.0-flash - should return totalTokens equal to sum of components", + "gemini-2.5-flash - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("google", "gemini-2.0-flash"); + const llm = getModel("google", "gemini-2.5-flash"); console.log(`\nGoogle / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm); @@ -605,6 +605,31 @@ describe("totalTokens field", () => { ); }); + // ========================================================================= + // Qwen Token Plan Individual + // ========================================================================= + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual", () => { + it( + "qwen3.8-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + console.log(`\nQwen Token Plan Individual / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.QWEN_TOKEN_PLAN_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + // ========================================================================= // Qwen Token Plan CN // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index a2b47ba2c6b..b9113c38182 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -728,6 +728,22 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Unicode Handling", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { + await testUnpairedHighSurrogate(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Unicode Handling", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/validation.test.ts b/packages/ai/test/validation.test.ts index 39f070dd3d7..1a212ac1276 100644 --- a/packages/ai/test/validation.test.ts +++ b/packages/ai/test/validation.test.ts @@ -98,6 +98,51 @@ describe("validateToolArguments", () => { } }); + it("treats null as omission for optional non-nullable properties", () => { + const tool: Tool = { + name: "echo", + description: "Echo tool", + parameters: Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + nullable: Type.Optional(Type.Union([Type.String(), Type.Null()])), + metadata: Type.Object({ enabled: Type.Optional(Type.Boolean()) }), + }), + }; + const toolCall: ToolCall = { + type: "toolCall", + id: "tool-1", + name: "echo", + arguments: { path: "file.txt", offset: null, nullable: null, metadata: { enabled: null } }, + }; + + expect(validateToolArguments(tool, toolCall)).toEqual({ + path: "file.txt", + nullable: null, + metadata: {}, + }); + }); + + it("preserves optional nulls whose referenced schema is nullable", () => { + const tool: Tool = { + name: "echo", + description: "Echo tool", + parameters: { + type: "object", + properties: { value: { $ref: "#/$defs/value" } }, + $defs: { value: { anyOf: [{ type: "number" }, { type: "null" }] } }, + } as Tool["parameters"], + }; + const toolCall: ToolCall = { + type: "toolCall", + id: "tool-1", + name: "echo", + arguments: { value: null }, + }; + + expect(validateToolArguments(tool, toolCall)).toEqual({ value: null }); + }); + it("preserves a value that already matches a nullable union arm", () => { const tool: Tool = { name: "echo", diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 92e46420379..601b5359d6d 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -1,10 +1,15 @@ +import { arch, platform, release } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts"; +import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; import { getSupportedThinkingLevels } from "../src/models.ts"; import { XAI_MODELS } from "../src/providers/xai.models.ts"; import { xaiProvider } from "../src/providers/xai.ts"; import type { Context, Model } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + type CapturedRequest = { url: string; headers: Headers; @@ -33,6 +38,53 @@ function completedResponse(): Response { }); } +const customCompletionsModel: Model<"openai-completions"> = { + id: "grok-custom", + name: "Grok Custom", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, +}; + +async function captureCompletionsUserAgent(headers?: Record): Promise { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + const chunks = [ + { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, + { + id: "chatcmpl-ua", + choices: [{ delta: {}, finish_reason: "stop", index: 0 }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const result = await streamOpenAICompletions( + customCompletionsModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + return userAgent; +} + async function captureRequest( model: Model<"openai-responses">, context: Context, @@ -72,10 +124,14 @@ describe("xAI Responses provider", () => { } }); - it("uses Responses with low/medium/high efforts only for Grok 4.5", () => { - expect(XAI_MODELS["grok-4.5"].api).toBe("openai-responses"); + it("routes every built-in xAI model through Responses", () => { + for (const model of Object.values(XAI_MODELS)) { + expect(model.api, model.id).toBe("openai-responses"); + } expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]); - expect(XAI_MODELS["grok-4.3"].api).toBe("openai-completions"); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.6"])).toEqual(["low", "medium", "high", "xhigh"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.3"])).toEqual(["off", "low", "medium", "high"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-build-0.1"])).toEqual(["low", "medium", "high"]); }); it("uses /responses with bearer auth and xAI-compatible request fields", async () => { @@ -95,6 +151,7 @@ describe("xAI Responses provider", () => { expect(captured.url).toBe("https://api.x.ai/v1/responses"); expect(captured.headers.get("authorization")).toBe("Bearer xai-test-token"); + expect(captured.headers.get("user-agent")).toBe(PI_USER_AGENT); expect(captured.headers.get("session_id")).toBe("pi-session-123"); expect(captured.body).toMatchObject({ model: "grok-4.5", @@ -114,4 +171,103 @@ describe("xAI Responses provider", () => { ]), ); }); + + it("requests encrypted reasoning without an effort override", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token" }, + ); + + expect(captured.body).toMatchObject({ + model: "grok-4.5", + store: false, + include: ["reasoning.encrypted_content"], + }); + expect(captured.body).not.toHaveProperty("reasoning"); + }); + + it("uses /responses for Grok 4.6 with xhigh effort and encrypted reasoning", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.6"], + { + systemPrompt: "You are a careful coding assistant.", + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "xhigh", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.6", + store: false, + stream: true, + reasoning: { effort: "xhigh" }, + include: ["reasoning.encrypted_content"], + }); + }); + + it("uses /responses for Grok 4.3", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.3"], + { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "low", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.3", + store: false, + include: ["reasoning.encrypted_content"], + reasoning: { effort: "low" }, + }); + }); + + it("uses pi's User-Agent by default for Responses requests", async () => { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + return completedResponse(); + }); + + const openaiModel: Model<"openai-responses"> = { + ...XAI_MODELS["grok-4.5"], + provider: "openai", + baseUrl: "https://api.openai.com/v1", + }; + const result = await streamOpenAIResponses( + openaiModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "test-token" }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(userAgent).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default Responses User-Agent", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } }, + ); + + expect(captured.headers.get("user-agent")).toBe("custom-agent"); + }); + + it("uses pi's User-Agent by default for Completions requests", async () => { + expect(await captureCompletionsUserAgent()).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default Completions User-Agent", async () => { + expect(await captureCompletionsUserAgent({ "User-Agent": "custom-agent" })).toBe("custom-agent"); + }); }); diff --git a/packages/ai/test/xiaomi-models.test.ts b/packages/ai/test/xiaomi-models.test.ts index d39cf8718cd..992a15c4310 100644 --- a/packages/ai/test/xiaomi-models.test.ts +++ b/packages/ai/test/xiaomi-models.test.ts @@ -1,17 +1,18 @@ import { describe, expect, it } from "vitest"; -import { getModel, getModels } from "../src/compat.ts"; +import { getModels } from "../src/compat.ts"; + +const XIAOMI_PROVIDERS = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const; +const DEPRECATED_MODEL_IDS = ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2-pro"] as const; +const REPLACEMENT_MODEL_IDS = ["mimo-v2.5", "mimo-v2.5-pro"] as const; describe("Xiaomi MiMo models", () => { - it.each(["mimo-v2-flash", "mimo-v2-omni"] as const)("keeps %s on the API billing provider", (modelId) => { - expect(getModel("xiaomi", modelId)).toBeDefined(); + it.each(XIAOMI_PROVIDERS)("omits deprecated models from %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const modelId of DEPRECATED_MODEL_IDS) expect(modelIds).not.toContain(modelId); }); - it.each(["xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const)( - "omits API-billing-only models from %s", - (provider) => { - const modelIds = getModels(provider).map((model) => model.id); - expect(modelIds).not.toContain("mimo-v2-flash"); - expect(modelIds).not.toContain("mimo-v2-omni"); - }, - ); + it.each(XIAOMI_PROVIDERS)("keeps replacement models on %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const modelId of REPLACEMENT_MODEL_IDS) expect(modelIds).toContain(modelId); + }); }); diff --git a/packages/ai/test/zai-coding-plan-models.test.ts b/packages/ai/test/zai-coding-plan-models.test.ts new file mode 100644 index 00000000000..2d0ef25b3f4 --- /dev/null +++ b/packages/ai/test/zai-coding-plan-models.test.ts @@ -0,0 +1,60 @@ +import { expect, it } from "vitest"; +import { getBuiltinModel } from "../src/providers/all.ts"; + +it("exposes GLM-4.6V on the China Coding Plan catalog", () => { + const model = getBuiltinModel("zai-coding-cn", "glm-4.6v"); + + expect(model).toMatchObject({ + id: "glm-4.6v", + provider: "zai-coding-cn", + api: "openai-completions", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + reasoning: true, + input: ["text", "image"], + cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 32768, + compat: { + maxTokensField: "max_tokens", + thinkingFormat: "zai", + zaiToolStream: true, + }, + }); +}); + +it("uses API-equivalent reference costs for Coding Plan models", () => { + expect(getBuiltinModel("zai", "glm-5.2").cost).toEqual({ + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }); + expect(getBuiltinModel("zai-coding-cn", "glm-5.1").cost).toEqual({ + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }); + expect(getBuiltinModel("zai-coding-cn", "glm-5v-turbo").cost).toEqual({ + input: 1.2, + output: 4, + cacheRead: 0.24, + cacheWrite: 0, + }); + for (const provider of ["zai", "zai-coding-cn"] as const) { + expect(getBuiltinModel(provider, "glm-5.3").cost).toEqual({ + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }); + } +}); + +it("keeps zero costs for Coding Plan models without a matching API price", () => { + const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + + for (const provider of ["zai", "zai-coding-cn"] as const) { + expect(getBuiltinModel(provider, "glm-5.2-highspeed").cost).toEqual(zeroCost); + } +}); diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 7d57807ad48..726fa233d9e 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +## [0.84.2] - 2026-08-14 + +## [0.84.1] - 2026-08-07 + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/client/package.json b/packages/client/package.json index 961eb1377f4..5ca649b9151 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-client", - "version": "0.84.0", + "version": "0.84.3", "description": "Transport-neutral client for remote pi sessions over framed CBOR bytes", "type": "module", "main": "./dist/index.js", @@ -47,7 +47,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f75f9b8865e..d3885a628f5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,11 +2,181 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +### New Features + +- **PowerShell tool** — Use optional native PowerShell command execution on Windows. See [PowerShell Tool](docs/windows.md#powershell-tool). +- **Safer managed updates** — Stage, verify, and atomically activate updates for installer-managed installations. See [Install and Manage](docs/packages.md#install-and-manage). +- **Model and thinking controls** — Select thinking levels with `/thinking`, search defaults, keep selections session-scoped, and persist them explicitly with Ctrl+S. See [Models and Thinking](docs/keybindings.md#models-and-thinking). + +### Breaking Changes + +- Renamed the inherited `GoogleThinkingLevel` type to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels. + +### Added + +- Added an optional `powershell` tool for Windows, configurable through `defaultTools` and the SDK. See [PowerShell Tool](docs/windows.md#powershell-tool). +- Added a `/thinking` selector and searchable default choices to the model and thinking selectors; Ctrl+S saves the selected model as the global default. See [Models and Thinking](docs/keybindings.md#models-and-thinking). +- Added optional routing session IDs to exported compaction summary helpers so callers can preserve provider routing without enabling prompt cache writes. +- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). +- Added inherited provider-neutral `toolChoice` support to simple stream requests. +- Added inherited automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). +- Added inherited configurable OpenAI-compatible thinking-token budget fields for vLLM, Qwen/SGLang, and llama.cpp servers. See [OpenAI Compatibility](docs/models.md#openai-compatibility) ([#8275](https://github.com/earendil-works/pi/pull/8275) by [@bnsd55](https://github.com/bnsd55)). +- Added inherited China-specific ZAI Coding Plan models, including GLM-4.6V vision support and API-equivalent usage cost estimates ([#8220](https://github.com/earendil-works/pi/issues/8220)). +- Added inherited `deepseek-v4-pro-0813` support to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). + +### Changed + +- Changed experimental installer-managed installations so `pi update` stages, verifies, and atomically activates the selected release in place. See [Install and Manage](docs/packages.md#install-and-manage). +- Changed inherited built-in xAI models to use the Responses API with encrypted reasoning replay and made Grok 4.6 the default xAI model ([#8124](https://github.com/earendil-works/pi/pull/8124) by [@Jaaneek](https://github.com/Jaaneek)). +- Changed inherited Anthropic, Azure OpenAI, Google, Mistral, and OpenAI adapters to send Pi's default `User-Agent` unless overridden ([#8305](https://github.com/earendil-works/pi/issues/8305)). +- Changed Windows and WSL keybinding defaults to avoid terminal-reserved shortcuts for image paste, model cycling, editor undo, fullscreen transcript navigation and search, and message queueing ([#8372](https://github.com/earendil-works/pi/issues/8372)). +- Changed Bun release archives to ship the native clipboard binary only inside the wrapper package, removing a duplicate platform package from each archive. +- Changed package resource glob expansion to use Node.js's built-in implementation with deterministic visible-path matching, reducing the installed runtime dependency tree. +- Changed the bundled Node.js runtime to load jiti only when importing an extension and Babel only when uncached source needs transformation, reducing CLI startup time and bundle size. +- Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. +- Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. +- Changed session sharing to render clickable terminal links, display only the canonical Radius artifact URL, and include the current system prompt and active tool definitions in Radius session shares. + +### Fixed + +- Fixed failed extension factories leaving event subscriptions, provider registrations, and default flag state active ([#8424](https://github.com/earendil-works/pi/pull/8424) by [@acmerfight](https://github.com/acmerfight)). +- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8487](https://github.com/earendil-works/pi/pull/8487) by [@petrroll](https://github.com/petrroll)). +- Fixed `/model` and `/thinking` selections being persisted globally unless explicitly saved with Ctrl+S ([#5263](https://github.com/earendil-works/pi/issues/5263)). +- Fixed JSON and RPC `toolcall_start` events omitting the tool call id and name ([#7953](https://github.com/earendil-works/pi/pull/7953) by [@christianklotz](https://github.com/christianklotz)). +- Fixed extensions failing to load when the Node.js CLI runs as a single-executable application ([#8237](https://github.com/earendil-works/pi/issues/8237)). +- Fixed nested Markdown skills inside `.agents/skills/` grouping directories not being discovered. +- Fixed compaction and branch summarization requests exposing tools to providers. +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). +- Fixed the default Cerebras model referencing an unavailable Z.AI model. +- Fixed inherited OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). +- Fixed inherited Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). +- Fixed inherited GitHub Copilot login triggering model-policy rate limits by limiting policy updates, retrying model discovery once, and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). +- Fixed inherited Amazon Bedrock dropping and failing to replay opaque redacted reasoning from non-Anthropic models ([#8314](https://github.com/earendil-works/pi/pull/8314) by [@seiji](https://github.com/seiji)). +- Fixed inherited Z.AI Coding Plan models deriving incomplete reasoning-effort metadata, including missing GLM-5.3 low, high, and max levels ([#8336](https://github.com/earendil-works/pi/issues/8336)). +- Fixed inherited DeepSeek V4 Flash on OpenCode and OpenCode Go omitting its supported low thinking level ([#8181](https://github.com/earendil-works/pi/pull/8181) by [@tianshuang](https://github.com/tianshuang)). +- Fixed inherited Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. +- Fixed inherited Amazon Bedrock response hooks receiving only a synthesized request id instead of the raw response headers ([#8234](https://github.com/earendil-works/pi/issues/8234)). +- Fixed inherited Kimi usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). +- Fixed inherited Google custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). +- Fixed writes to `auth.json` and `models-store.json` overriding administrator-managed file permissions and ACLs ([#7779](https://github.com/earendil-works/pi/issues/7779)). +- Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). +- Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). +- Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). +- Fixed truncated compaction and branch summaries being persisted when generation reaches its output token limit ([#7048](https://github.com/earendil-works/pi/issues/7048)). +- Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). +- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models plus autoloadable unloaded presets in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). +- Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). +- Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). +- Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). +- Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). +- Fixed inherited padded text exceeding narrow terminal widths ([#8252](https://github.com/earendil-works/pi/issues/8252)). +- Fixed inherited wrapped Markdown table links leaking color into borders and neighboring cells, including tables inside blockquotes ([#8335](https://github.com/earendil-works/pi/issues/8335)). +- Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). +- Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). +- Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). +- Fixed branch summary entries recording the navigation destination in `fromId` instead of the pre-navigation source leaf. +- Fixed threshold auto-compaction being skipped when providers omit streaming usage data ([#8328](https://github.com/earendil-works/pi/issues/8328)). +- Fixed dash-prefixed prompts being parsed as options by supporting `--` as an end-of-options delimiter ([#7269](https://github.com/earendil-works/pi/issues/7269)). + +## [0.84.2] - 2026-08-14 + +### New Features + +- **Fullscreen transcript search** — Search and navigate matches in fullscreen mode. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport). +- **Configurable default tools** — Choose startup built-in tools globally or per project. See [Tools](docs/settings.md#tools). +- **Configurable fullscreen exit output** — Print the transcript or only a resume hint on exit. See [Interactive Mode](docs/usage.md#interactive-mode). + +### Added + +- Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. +- Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`. +- Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. +- Added the `defaultTools` setting for configuring the initial built-in tool selection globally or per project. +- Added `--use-theme ` to choose an initial per-run interactive theme without changing saved settings ([#7722](https://github.com/earendil-works/pi/pull/7722) by [@rwachtler](https://github.com/rwachtler)). +- Added `expandPromptTemplates` to extension `pi.sendUserMessage()` options for explicitly dispatching commands and expanding skills and prompt templates. See [`pi.sendUserMessage()`](docs/extensions.md#pisendusermessagecontent-options) ([#7857](https://github.com/earendil-works/pi/pull/7857) by [@mrexodia](https://github.com/mrexodia)). +- Added inherited `createGatewayBindingFetch()` for routing Cloudflare AI Gateway requests through a Workers AI binding without an API token ([#7901](https://github.com/earendil-works/pi/pull/7901) by [@Maximo-Guk](https://github.com/Maximo-Guk)). +- Added inherited `AssistantMessage.endTurn` to preserve OpenAI Codex's terminal `end_turn` signal for diagnostics ([#7766](https://github.com/earendil-works/pi/pull/7766)). +- Added inherited unbound single-line transcript scrolling actions for fullscreen mode. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport) ([#7903](https://github.com/earendil-works/pi/pull/7903) by [@midastruth](https://github.com/midastruth)). + +### Changed + +- Changed inherited Kimi Coding requests to use pi's runtime `User-Agent` header. +- Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. +- Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)). +- Changed inherited OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Reduced inherited fullscreen rendering allocation churn by painting full-width layout rows directly instead of recompositing them on every frame. + +### Fixed + +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). +- Fixed managed-tool downloads delaying TUI startup and hiding diagnostics in fullscreen mode by mounting the TUI first and showing download progress and warnings inside it. +- Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh. +- Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). +- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. +- Fixed inherited required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). +- Updated the transitive `nanoid` development dependency to address a denial-of-service vulnerability. +- Fixed fallback rendering for extension tool results to collapse long output and honor tool expansion ([#7979](https://github.com/earendil-works/pi/issues/7979)). +- Fixed JSON and RPC `message_update` events dropping cumulative usage during streaming. See [JSON Event Mode](docs/json.md) and [RPC `message_update`](docs/rpc.md#message_update-streaming) ([#7982](https://github.com/earendil-works/pi/pull/7982) by [@christianklotz](https://github.com/christianklotz)). +- Fixed `pi.sendMessage(..., { triggerTurn: false })` steering an active run instead of only recording the custom message ([#8022](https://github.com/earendil-works/pi/pull/8022) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Fixed the `defaultTools` setting dropping extension and SDK custom tools when selecting built-in defaults. +- Fixed the subagent example rejecting YAML array syntax for the `tools` frontmatter field ([#7598](https://github.com/earendil-works/pi/pull/7598) by [@alexsavio](https://github.com/alexsavio)). +- Fixed the subagent example dropping parent session model, thinking, and tool configuration ([#7897](https://github.com/earendil-works/pi/pull/7897) by [@virtuald](https://github.com/virtuald)). +- Fixed custom system prompts concatenating the current working directory with later appended prompt content ([#7887](https://github.com/earendil-works/pi/pull/7887) by [@distributedlock](https://github.com/distributedlock)). +- Fixed inherited OpenAI Responses function and custom tool calls losing namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Fixed inherited upstream request buffer failures not triggering automatic assistant retries. +- Fixed inherited built-in and custom DeepSeek API models sending output limits through an unsupported field. +- Fixed inherited Amazon Bedrock replay rejecting tool arguments that contain empty object keys while preserving all valid nested values ([#7882](https://github.com/earendil-works/pi/pull/7882) by [@muyiyr](https://github.com/muyiyr)). +- Fixed inherited DeepSeek compatibility detection for base URLs whose hostname contains uppercase letters ([#7933](https://github.com/earendil-works/pi/pull/7933) by [@yearth](https://github.com/yearth)). +- Fixed inherited Google Generative AI and Vertex AI responses with tool calls incorrectly treating output-limit or provider-error stops as normal tool use ([#8059](https://github.com/earendil-works/pi/issues/8059)). +- Fixed inherited fullscreen mouse drag selection and OSC 8 link activation in terminals that report generic SGR mouse release button codes ([#7963](https://github.com/earendil-works/pi/issues/7963)). +- Fixed inherited focused fullscreen overlays not receiving mouse wheel or viewport scroll keys such as PageUp and PageDown ([#7894](https://github.com/earendil-works/pi/issues/7894)). +- Fixed inherited LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. +- Fixed split `Alt+Enter` input over SSH being misread as Escape, added `PI_TUI_ESC_TIMEOUT` for high-latency terminals, and limited that timeout to lone Escape input ([#7899](https://github.com/earendil-works/pi/pull/7899) by [@powerfooI](https://github.com/powerfooI)). +- Fixed inherited idle fullscreen sessions repainting and clearing text selection when the terminal loses focus ([#7892](https://github.com/earendil-works/pi/pull/7892) by [@terrorobe](https://github.com/terrorobe)). +- Fixed fullscreen selection copy to use the host clipboard and report failure instead of claiming success when OSC 52 is unsupported ([#8110](https://github.com/earendil-works/pi/pull/8110) by [@Panoplos](https://github.com/Panoplos)). + +## [0.84.1] - 2026-08-07 + +### New Features + +- **Qwen Token Plan Individual** — Use the built-in provider for models documented for Individual subscriptions. See [API Keys](docs/providers.md#api-keys). +- **Authentication readiness checks** — Use `pi auth check` to verify provider or model credentials, optionally emitting the resolved credential. +- **Improved fullscreen interaction** — Select words and paragraphs with multiple clicks and configure half-page transcript scrolling. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport). +- **Terminating blocked tool calls** — Extension `tool_call` handlers can stop all-terminating batches without another model call. See [Tool Events](docs/extensions.md#tool-events). + +### Added + +- Added Qwen Token Plan Individual as a built-in provider with its documented subscription model catalog and the shared international `QWEN_TOKEN_PLAN_API_KEY`. See [API Keys](docs/providers.md#api-keys) ([#7659](https://github.com/earendil-works/pi/pull/7659) by [@arasovic](https://github.com/arasovic)). +- Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)). +- Added `terminate` support to blocked extension `tool_call` events so all-terminating batches can skip the automatic follow-up model call. See [Tool Events](docs/extensions.md#tool-events) ([#7715](https://github.com/earendil-works/pi/pull/7715) by [@muyiyr](https://github.com/muyiyr)). +- Added inherited double-click word and whitespace selection, granularity-aware drag selection, and triple-click paragraph selection in fullscreen mode ([#7725](https://github.com/earendil-works/pi/issues/7725), [#7733](https://github.com/earendil-works/pi/pull/7733) by [@volsa](https://github.com/volsa)). +- Added inherited unbound half-page transcript scrolling actions for fullscreen mode. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport) ([#7735](https://github.com/earendil-works/pi/issues/7735)). + +### Changed + +- Softened the bash tool's `PI_*` environment guideline in an attempt to reduce unnecessary inspection commands ([#7128](https://github.com/earendil-works/pi/issues/7128)). +- Reduced worst-case automatic terminal theme detection delay from 200 ms to 100 ms by probing color-scheme and background support concurrently. + +### Fixed + +- Fixed Bun standalone binaries crashing on startup when the cwd contains a `bunfig.toml` with `preload` by compiling with `--no-compile-autoload-bunfig` ([#7685](https://github.com/earendil-works/pi/pull/7685) by [@geril07](https://github.com/geril07)). +- Fixed extension TUI method wrappers recursing indefinitely when delegating to the original method ([#7731](https://github.com/earendil-works/pi/issues/7731)). +- Fixed right-click not pasting clipboard text in fullscreen mode on Windows. +- Fixed inherited `Agent.reset()` clearing transcript and runtime state during active runs; it now rejects until the agent is idle ([#7717](https://github.com/earendil-works/pi/pull/7717) by [@wesleyzhangwq](https://github.com/wesleyzhangwq)). +- Fixed inherited LaTeX relation, multiplication, and named-operator spacing, and matrix composition with stacked fractions, operator limits, and adjacent matrices. +- Reduced inherited fullscreen mouse event volume under tmux, Zellij, and GNU Screen by using button-motion tracking instead of all-motion tracking. + ## [0.84.0] - 2026-08-06 ### New Features -- **Fullscreen TUI mode** — Switch between regular and fullscreen modes at runtime, with a sticky editor and footer, independently scrollable transcript, and draggable scrollbars. See [UI & Display](docs/settings.md#ui--display). +- **Fullscreen TUI mode** — Switch between regular and fullscreen modes at runtime, with a sticky editor and footer, independently scrollable transcript, and draggable scrollbars. See [UI & Display](docs/settings.md#ui-display). - **Mermaid and LaTeX rendering** — Render Mermaid diagrams and terminal-friendly Unicode math in interactive transcripts. See [Markdown settings](docs/settings.md#markdown) and [TUI Markdown](../tui/README.md#markdown). - **Per-directory context overrides** — Use `AGENTS.override.md` to replace context files for a specific directory. See [Context Files](docs/usage.md#context-files). - **Advanced custom model sampling** — Configure arbitrary OpenAI-compatible `samplingParams` and opt-in vLLM `thinking_token_budget` values. See [Sampling Parameters](docs/models.md#sampling-parameters). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e4e76e29626..0a6c1e14fb4 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -514,7 +514,7 @@ Read the [blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/) ## CLI Reference ```bash -pi [options] [@files...] [messages...] +pi [options] [--] [@files...] [messages...] ``` ### Package Commands @@ -584,7 +584,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools by default | -Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +Available built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls` ### Resource Options @@ -609,9 +609,11 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | | `--tui-mode ` | TUI mode: `regular` (default) or experimental `fullscreen` | +| `--use-theme ` | Set the initial interactive theme for this run without changing settings | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | +| `--` | Stop option parsing; remaining arguments are prompts or `@file` inputs | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | @@ -634,6 +636,9 @@ pi "List all .ts files in src/" # Non-interactive pi -p "Summarize this codebase" +# Prompt beginning with a dash +pi -p -- "- Summarize these points" + # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" @@ -677,7 +682,7 @@ pi --thinking high "Solve this complex problem" | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | | `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere | -Commands run by the LLM-callable bash tool also receive current session metadata: +Commands run by the LLM-callable `bash` and `powershell` tools also receive current session metadata: | Variable | Description | |----------|-------------| @@ -687,7 +692,7 @@ Commands run by the LLM-callable bash tool also receive current session metadata | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level | -These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#bash-tool-session-environment) for semantics, examples, and custom-tool opt-out. +These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#shell-tool-session-environment) for semantics, examples, and custom-tool opt-out. --- diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index de9d02d470d..3a9dff1c63c 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -1,6 +1,6 @@ # Compaction & Branch Summarization -LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization. +LLMs have limited context windows. When conversations grow too long, Pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization. **Source files** ([pi-mono](https://github.com/earendil-works/pi-mono)): - [`packages/coding-agent/src/core/compaction/compaction.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) - Auto-compaction logic @@ -42,13 +42,13 @@ You can also trigger manually with `/compact [instructions]`, where optional ins 2. **Extract messages**: Collect messages from the previous kept boundary (or session start) up to the cut point 3. **Generate summary**: Call LLM to summarize with structured format, passing the previous summary as iterative context when present 4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId` -5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards +5. **Rebuilds context**: Session rebuilds the context for the next request, using summary + messages from `firstKeptEntryId` onwards ``` Before compaction: entry: 0 1 2 3 4 5 6 7 8 9 - ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐ + ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘ └────────┬───────┘ └──────────────┬──────────────┘ @@ -59,7 +59,7 @@ Before compaction: After compaction (new entry appended): entry: 0 1 2 3 4 5 6 7 8 9 10 - ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐ + ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘ └──────────┬──────┘ └──────────────────────┬───────────────────┘ @@ -102,7 +102,7 @@ Split turn (one huge turn exceeds budget): turnPrefixMessages = [usr, ass, tool, ass, tool, tool] ``` -For split turns, pi generates two summaries and merges them: +For split turns, Pi generates two summaries and merges them: 1. **History summary**: Previous context (if any) 2. **Turn prefix summary**: The early part of the split turn @@ -149,7 +149,7 @@ See [`prepareCompaction()`](https://github.com/earendil-works/pi-mono/blob/main/ ### When It Triggers -When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch. +When you use `/tree` to navigate to a different branch, Pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch. ### How It Works @@ -346,6 +346,21 @@ pi.on("session_before_compact", async (event, ctx) => { See [custom-compaction.ts](../examples/extensions/custom-compaction.ts) for a complete example using a different model. +### session_compact_failed + +Fired when manual or automatic compaction fails or is aborted. This is useful for telemetry extensions that need to pair `session_before_compact` attempts with terminal outcomes. + +```typescript +pi.on("session_compact_failed", async (event, ctx) => { + const { reason, errorMessage, aborted, willRetry, fromExtension } = event; + // reason - "manual" (/compact), "threshold", or "overflow" + // errorMessage - present for non-abort failures + // aborted - true for cancelled/aborted compactions + // willRetry - whether the aborted turn would have retried after compaction + // fromExtension - whether extension-provided compaction content was being used +}); +``` + ### session_before_tree Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary. diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 027aa6115c6..cdf823eb08f 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -227,7 +227,7 @@ The `api` field determines which streaming implementation is used: | `openai-responses` | OpenAI Responses API | | `azure-openai-responses` | Azure OpenAI Responses API | | `openai-codex-responses` | OpenAI Codex Responses API | -| `mistral-conversations` | Mistral SDK Conversations/Chat streaming | +| `mistral-conversations` | Native Mistral Chat Completions streaming | | `google-generative-ai` | Google Generative AI API | | `google-vertex` | Google Vertex AI API | | `bedrock-converse-stream` | Amazon Bedrock Converse API | @@ -752,8 +752,10 @@ interface ProviderModelConfig { requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "baseten" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; - chatTemplateKwargs?: Record; - chatTemplateArgs?: Record; + chatTemplateKwargs?: Record; + chatTemplateArgs?: Record; + thinkingTokenBudgetField?: "thinking_token_budget" | "thinking_budget" | "thinking_budget_tokens"; + supportsThinkingTokenBudget?: boolean; cacheControlFormat?: "anthropic"; sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter"; sendSessionAffinityHeaders?: boolean; @@ -771,4 +773,5 @@ interface ProviderModelConfig { ``` `openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`. Use `thinkingFormat: "baseten"` with `chatTemplateArgs` when the provider expects toggle values under `chat_template_args` and optionally supports top-level `reasoning_effort`. +`thinkingTokenBudgetField` sends a clamped per-level thinking budget as a top-level request field (`thinking_token_budget` on vLLM, `thinking_budget` on Qwen/SGLang, `thinking_budget_tokens` on llama.cpp). `supportsThinkingTokenBudget: true` is an alias for the vLLM field name. Do not combine it with `reasoning_effort` on DashScope Qwen models. `cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 072744ca86b..fe9cafa8b14 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -3,18 +3,23 @@ Pi uses environment variables in three ways: - Variables such as `PI_OFFLINE` configure the Pi process. -- Pi sets `PI_CODING_AGENT` so child processes can detect that they run inside Pi. -- Commands run by the LLM-callable bash tool receive `PI_*` variables describing the current session. +- Pi sets process markers so child processes can identify Pi as the launching agent. +- Commands run by the LLM-callable shell tools receive `PI_*` variables describing the current session. Provider API-key variables are documented separately in [Providers](providers.md#environment-variables-or-auth-file). ## Process Marker -The CLI and RPC entry points set `PI_CODING_AGENT=true`. Child processes inherit it and can use it to detect that they run inside Pi. It is not session-specific and is not set automatically when Pi is embedded through the SDK. +The CLI and RPC entry points set two process markers: -## Bash Tool Session Environment +- `AI_AGENT=pi` is a generic marker that lets tooling identify Pi as the agent that launched the process. +- `PI_CODING_AGENT=true` is Pi-specific and lets child processes detect that they run inside Pi. -Commands run by the bash tool receive the current Pi session state: +Child processes inherit both markers. They are not session-specific and are not set automatically when Pi is embedded through the SDK. + +## Shell Tool Session Environment + +Commands run by the `bash` and `powershell` tools receive the current Pi session state: | Variable | Description | |----------|-------------| @@ -24,7 +29,7 @@ Commands run by the bash tool receive the current Pi session state: | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | -The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next bash command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. +The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next shell command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. When asked which model or provider is running, inspect these variables instead of inferring the answer from the system prompt: @@ -41,11 +46,11 @@ if [ -n "$PI_SESSION_FILE" ]; then fi ``` -These variables are injected into the LLM-callable bash tool. They are not injected into user-entered `!` or `!!` commands. +These variables are injected into the LLM-callable `bash` and `powershell` tools. They are not injected into user-entered `!` or `!!` commands. -### Custom Bash Tools +### Custom Shell Tools -Bash tools created with `createBashTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: +Tools created with `createBashTool()` or `createPowerShellTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: ```typescript const bashTool = createBashTool(cwd, { @@ -59,7 +64,7 @@ const bashTool = createBashTool(cwd, { Disable session metadata independently of the spawn hook: ```typescript -const bashTool = createBashTool(cwd, { +const powershellTool = createPowerShellTool(cwd, { exposeSessionEnvironment: false, spawnHook: (ctx) => ctx, }); @@ -82,6 +87,7 @@ These variables are read by Pi itself: | `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | | `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` | | `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | +| `PI_TUI_ESC_TIMEOUT` | How long to wait after a lone ESC before treating it as Escape, in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | | `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset | | `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 48adbba9c80..7643856be8e 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -330,7 +330,8 @@ user sends another prompt ◄───────────────── /compact or auto-compaction ├─► session_before_compact (can cancel or customize) - └─► session_compact + ├─► session_compact (success) + └─► session_compact_failed (failure or abort) /tree navigation ├─► session_before_tree (can cancel or customize) @@ -448,7 +449,7 @@ pi.on("session_before_fork", async (event, ctx) => { After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. -#### session_before_compact / session_compact +#### session_before_compact / session_compact / session_compact_failed Fired on compaction. See [compaction.md](compaction.md) for details. @@ -479,6 +480,14 @@ pi.on("session_compact", async (event, ctx) => { // event.reason - "manual" (/compact), "threshold", or "overflow" // event.willRetry - whether the aborted turn is retried after compaction (overflow recovery) }); + +pi.on("session_compact_failed", async (event, ctx) => { + // event.reason - "manual" (/compact), "threshold", or "overflow" + // event.errorMessage - present for non-abort failures + // event.aborted - true for cancelled/aborted compactions + // event.willRetry - whether the aborted turn would have retried after compaction + // event.fromExtension - whether extension-provided compaction content was being used +}); ``` #### session_before_tree / session_tree @@ -762,7 +771,8 @@ Behavior guarantees: - Mutations to `event.input` affect the actual tool execution - Later `tool_call` handlers see mutations made by earlier handlers - No re-validation is performed after your mutation -- Return values from `tool_call` only control blocking via `{ block: true, reason?: string }` +- Return values from `tool_call` control blocking via `{ block: true, reason?: string, terminate?: boolean }` +- `terminate` only applies to a blocked call; the agent stops early only when every finalized result in the batch is terminating ```typescript import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; @@ -778,7 +788,7 @@ pi.on("tool_call", async (event, ctx) => { event.input.command = `source ~/.profile\n${event.input.command}`; if (event.input.command.includes("rm -rf")) { - return { block: true, reason: "Dangerous command" }; + return { block: true, reason: "Dangerous command", terminate: true }; } } @@ -1425,12 +1435,16 @@ pi.sendUserMessage([ // During streaming - must specify delivery mode pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); pi.sendUserMessage("And then summarize", { deliverAs: "followUp" }); + +// Opt in to extension command dispatch and skill/prompt template expansion +pi.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true }); ``` **Options:** - `deliverAs` - Required when agent is streaming: - `"steer"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls - `"followUp"` - Waits for agent to finish all tools +- `expandPromptTemplates` - Dispatch extension commands and expand skill commands and prompt templates. Defaults to `false`. When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error. @@ -2045,7 +2059,7 @@ pi.registerTool({ ### Overriding Built-in Tools -Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. +Extensions can override built-in tools (`read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. ```bash # Extension's read tool replaces built-in read @@ -2069,6 +2083,7 @@ See [examples/extensions/tool-override.ts](../examples/extensions/tool-override. Built-in tool implementations: - [read.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` - [bash.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` +- [powershell.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/powershell.ts) - `PowerShellToolDetails` - [edit.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) - [write.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) - [grep.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` @@ -2104,11 +2119,11 @@ pi.registerTool({ }); ``` -**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `PowerShellOperations`, `LsOperations`, `GrepOperations`, `FindOperations` For `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination. -The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution: +The `bash` and `powershell` tools also support a spawn hook to adjust the command, cwd, or env before execution: ```typescript import { createBashTool } from "@earendil-works/pi-coding-agent"; @@ -2122,7 +2137,7 @@ const bashTool = createBashTool(cwd, { }); ``` -`createBashTool()` exposes the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: +`createBashTool()` and `createPowerShellTool()` expose the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: ```typescript const bashTool = createBashTool(cwd, { @@ -2130,7 +2145,7 @@ const bashTool = createBashTool(cwd, { }); ``` -See [Bash tool session environment](environment-variables.md#bash-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. +See [Shell tool session environment](environment-variables.md#shell-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. ### Output Truncation diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index f497f340640..2d538802e7c 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -15,11 +15,16 @@ except that streaming message updates omit cumulative snapshots: ```typescript type WithoutPartial = T extends { partial: unknown } ? Omit : T; +type JsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; + type JsonAgentSessionEvent = | Exclude | { type: "message_update"; - assistantMessageEvent: WithoutPartial; + usage: Usage; + assistantMessageEvent: JsonAssistantMessageEvent; }; ``` @@ -73,16 +78,18 @@ Followed by events as they occur: {"type":"agent_start"} {"type":"turn_start"} {"type":"message_start","message":{"role":"assistant","content":[],...}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} {"type":"message_end","message":{...}} {"type":"turn_end","message":{...},"toolResults":[]} {"type":"agent_end","messages":[...]} ``` `message_update` records are delta-only. They omit both the cumulative `message` field and -`assistantMessageEvent.partial` to keep stream size linear. Use `contentIndex` and `delta` -to assemble live text, thinking, or tool-call arguments if needed. `message_end` contains -the final authoritative message. +`assistantMessageEvent.partial` to keep stream size linear. The top-level `usage` field contains +the latest cumulative provider-reported usage and may remain zero when a provider only reports +usage at completion. Use `contentIndex` and `delta` to assemble live text, thinking, or tool-call +arguments if needed. A `toolcall_start` event also includes the constant-sized `id` and `toolName` +fields. `message_end` contains the final authoritative message. ## Example diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 5f5acc6a789..9da435172f9 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -10,7 +10,7 @@ After editing `keybindings.json`, run `/reload` in pi to apply the changes witho ## Key Format -`modifier+key` where modifiers are `ctrl`, `shift`, `alt` (combinable) and keys are: +`modifier+key` where modifiers are `ctrl`, `shift`, `alt`, `super` (combinable) and keys are: - **Letters:** `a-z` - **Digits:** `0-9` @@ -18,7 +18,9 @@ After editing `keybindings.json`, run `/reload` in pi to apply the changes witho - **Function:** `f1`-`f12` - **Symbols:** `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?` -Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1`, etc. +Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `super+k`, `ctrl+super+k`, `ctrl+1`, etc. + +`super` bindings require a terminal that reports the modifier separately, typically through the Kitty keyboard protocol. They may not work in terminals without that support. ## All Actions @@ -68,7 +70,7 @@ The dedicated history actions always change history entries, regardless of the c |--------|---------|-------------| | `tui.editor.yank` | `ctrl+y` | Paste most recently deleted text | | `tui.editor.yankPop` | `alt+y` | Cycle through deleted text after yank | -| `tui.editor.undo` | `ctrl+-` | Undo last edit | +| `tui.editor.undo` | `ctrl+-` (`ctrl+z` on Windows; `alt+z` on WSL) | Undo last edit | ### TUI Clipboard and Selection @@ -84,7 +86,7 @@ The dedicated history actions always change history entries, regardless of the c ### TUI Fullscreen Viewport -These actions apply when interactive mode uses `--tui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. +These actions apply when interactive mode uses `--tui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. See [Terminal setup](terminal-setup.md) for terminal-specific mouse and trackpad behavior. Fullscreen transcript bindings take precedence over editor bindings. The default unmodified navigation keys therefore control the transcript in fullscreen mode, while their `ctrl` variants continue to control the editor. Outside fullscreen mode, both variants control the editor. @@ -95,14 +97,22 @@ Fullscreen transcript bindings take precedence over editor bindings. The default | `pageUp`, `pageDown` | Editor | Transcript | | `ctrl+pageUp`, `ctrl+pageDown` | Editor | Editor | -This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. +This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Bind `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown` for half-page steps, or bind `tui.altScreen.lineUp` and `tui.altScreen.lineDown` for single-line steps. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. | Keybinding id | Default | Description | |--------|---------|-------------| | `tui.altScreen.pageUp` | `pageUp` | Scroll the transcript up by one page | | `tui.altScreen.pageDown` | `pageDown` | Scroll the transcript down by one page | -| `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message | -| `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message | +| `tui.altScreen.halfPageUp` | *(none)* | Scroll the transcript up by half a page | +| `tui.altScreen.halfPageDown` | *(none)* | Scroll the transcript down by half a page | +| `tui.altScreen.lineUp` | *(none)* | Scroll the transcript up by one line | +| `tui.altScreen.lineDown` | *(none)* | Scroll the transcript down by one line | +| `tui.altScreen.previousPrompt` | `ctrl+shift+up`, `ctrl+up` (`ctrl+up` only on Windows and WSL) | Jump to the previous marked message | +| `tui.altScreen.nextPrompt` | `ctrl+shift+down`, `ctrl+down` (`ctrl+down` only on Windows and WSL) | Jump to the next marked message | +| `tui.altScreen.search` | `ctrl+shift+f` (`ctrl+f` on Windows and WSL) | Search the rendered transcript | +| `tui.altScreen.searchNext` | `enter`, `ctrl+g` | Select the next search match while searching | +| `tui.altScreen.searchPrevious` | `shift+enter`, `ctrl+shift+g` | Select the previous search match while searching | +| `tui.altScreen.searchClose` | `escape` | Close transcript search | | `tui.altScreen.top` | `home` | Scroll to the beginning of the transcript | | `tui.altScreen.bottom` | `end` | Scroll to the transcript end and follow new output | @@ -111,11 +121,11 @@ This routing remains configurable through the ordinary action bindings. For exam | Keybinding id | Default | Description | |--------|---------|-------------| | `app.interrupt` | `escape` | Cancel / abort | -| `app.clear` | `ctrl+c` | Clear editor | +| `app.clear` | `ctrl+c` | Clear editor (first) / exit (second) | | `app.exit` | `ctrl+d` | Exit (when editor empty) | | `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background | | `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) | -| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard | +| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows and WSL) | Paste image or text from clipboard | ### Sessions @@ -138,7 +148,7 @@ This routing remains configurable through the ordinary action bindings. For exam |--------|---------|-------------| | `app.model.select` | `ctrl+l` | Open model selector | | `app.model.cycleForward` | `ctrl+p` | Cycle to next model | -| `app.model.cycleBackward` | `shift+ctrl+p` | Cycle to previous model | +| `app.model.cycleBackward` | `shift+ctrl+p` (`alt+p` on Windows and WSL) | Cycle to previous model | | `app.thinking.cycle` | `shift+tab` | Cycle thinking level | | `app.thinking.toggle` | `ctrl+t` | Collapse or expand thinking blocks | @@ -148,8 +158,8 @@ This routing remains configurable through the ordinary action bindings. For exam |--------|---------|-------------| | `app.tools.expand` | `ctrl+o` | Collapse or expand tool output | | `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` | -| `app.message.followUp` | `alt+enter` | Queue follow-up message | -| `app.message.dequeue` | `alt+up` | Restore queued messages to editor | +| `app.message.followUp` | `alt+enter` (`ctrl+q` on Windows and WSL) | Queue follow-up message | +| `app.message.dequeue` | `alt+up` (`alt+q` on Windows and WSL) | Restore queued messages to editor | ### Tree Navigation diff --git a/packages/coding-agent/docs/llama-cpp.md b/packages/coding-agent/docs/llama-cpp.md index 13f314c9e06..bffa13f423a 100644 --- a/packages/coding-agent/docs/llama-cpp.md +++ b/packages/coding-agent/docs/llama-cpp.md @@ -53,6 +53,8 @@ Start Pi and configure the provider: Enter the router URL and optional API key. The default URL is `http://127.0.0.1:8080`. +If you start the router with `--no-models-autoload`, `/login llama.cpp` only stores the connection. Run `/llama` to load a model, then `/model` to select the loaded model for the current session. + Environment variables can configure the same values without `/login`: ```bash diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 53c702e137b..76a5bf291f7 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -254,6 +254,8 @@ Current behavior: Only OpenAI-compatible APIs apply it (`openai-completions`, `openai-responses`, `azure-openai-responses`); other APIs ignore it. Keys override pi's named request fields (for example a `temperature` key here beats the request-level temperature), so prefer it as the single source of sampling truth for a model. In `modelOverrides`, `samplingParams` merges per key with the base model's value. +A constant thinking-token cap can go here too, but it will not follow `thinkingBudgets` or leave room for the answer. Prefer `compat.thinkingTokenBudgetField` (or the `supportsThinkingTokenBudget` alias) for that. + ### Thinking Level Map Use `thinkingLevelMap` on a model to describe model-specific thinking controls. Keys are pi thinking levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Maps may contain holes; for example, a model can expose `high` and `max` without exposing `xhigh`. @@ -467,8 +469,10 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `requiresThinkingAsText` | Convert thinking blocks to plain text | | `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | | `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `baseten`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | -| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | -| `chatTemplateArgs` | `chat_template_args` values for `thinkingFormat: "baseten"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | +| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values | +| `chatTemplateArgs` | `chat_template_args` values for `thinkingFormat: "baseten"`; use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values | +| `thinkingTokenBudgetField` | Top-level request field used to cap reasoning tokens from `thinkingBudgets`, clamped so at least 1024 tokens remain for the answer. `"thinking_token_budget"` (vLLM), `"thinking_budget"` (Qwen/DashScope/SGLang), `"thinking_budget_tokens"` (llama.cpp). Off by default; not set on the generated catalog. | +| `supportsThinkingTokenBudget` | Alias for `thinkingTokenBudgetField: "thinking_token_budget"` (vLLM). Prefer `thinkingTokenBudgetField`. Default: `false`. | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. | | `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. | | `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. | @@ -481,6 +485,8 @@ For providers with partial OpenAI compatibility, use the `compat` field. `openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }` for DeepSeek V3.x templates. Use `thinkingFormat: "baseten"` with `chatTemplateArgs` for providers that expose toggle controls through `chat_template_args` and optionally support top-level `reasoning_effort`. +`thinkingTokenBudgetField` is independent of `thinkingFormat`. Do not enable it on the generated Qwen catalog: those models already send `reasoning_effort`, and DashScope rejects `thinking_budget` together with `reasoning_effort`. + `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. Example: diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index b2f493b4e64..cadeb18673a 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -38,7 +38,7 @@ pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` -These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). +These commands manage pi packages and `pi update` can update the pi CLI installation. For experimental installer-managed installations, `pi update` installs the exact checked version into a staged, lockfile-backed release and activates it only after verification, leaving the current release intact if the update fails. Managed installations do not support `--force`; rerun the installer to repair one. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted. @@ -130,7 +130,7 @@ Add a `pi` manifest to `package.json` or use conventional directories. Include t } ``` -Paths are relative to the package root. Arrays support glob patterns and `!exclusions`. +Paths are relative to the package root. Arrays support glob patterns and `!exclusions`. Positive manifest globs discover visible paths in lexical order. List dot-prefixed paths directly. If a glob would need to continue through a symlink, list the symlinked resource root directly. ### Gallery Metadata diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index cac9dc202d6..e86f99ce34b 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -96,7 +96,8 @@ pi | Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` | | MiniMax | `MINIMAX_API_KEY` | `minimax` | | MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` | -| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | +| Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | +| Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan-individual` | | Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | `qwen-token-plan-cn` | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` | @@ -121,6 +122,7 @@ Store credentials in `~/.pi/agent/auth.json`: "opencode-go": { "type": "api_key", "key": "..." }, "together": { "type": "api_key", "key": "..." }, "qwen-token-plan": { "type": "api_key", "key": "sk-sp-..." }, + "qwen-token-plan-individual": { "type": "api_key", "key": "sk-sp-..." }, "qwen-token-plan-cn": { "type": "api_key", "key": "sk-sp-..." }, "xiaomi": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-cn": { "type": "api_key", "key": "..." }, @@ -129,6 +131,11 @@ Store credentials in `~/.pi/agent/auth.json`: } ``` +`qwen-token-plan-individual` uses the same international endpoint and `QWEN_TOKEN_PLAN_API_KEY` as +`qwen-token-plan`, but limits the picker to the models documented for Individual subscriptions. The existing +provider keeps its broader catalog for backward compatibility. When using `auth.json`, store the +credential under the provider you select; an environment variable is shared by both international providers. + The file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables. API key credentials can also include provider-scoped environment values. These values are used before process environment variables when resolving the credential key, provider/model headers, and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`. diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 499ca673b76..f92c12e8e62 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -919,6 +919,14 @@ Emitted during streaming of assistant messages. Contains a delta event without a ```json { "type": "message_update", + "usage": { + "input": 100, + "output": 1, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 101, + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0} + }, "assistantMessageEvent": { "type": "text_delta", "contentIndex": 0, @@ -937,23 +945,32 @@ The `assistantMessageEvent` field contains one of these delta types: | `thinking_start` | Thinking block started | | `thinking_delta` | Thinking content chunk | | `thinking_end` | Thinking block ended | -| `toolcall_start` | Tool call started | +| `toolcall_start` | Tool call started (includes `id` and `toolName`) | | `toolcall_delta` | Tool call arguments chunk | | `toolcall_end` | Tool call ended (includes full `toolCall` object) | Example streaming a text response: ```json -{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}} -{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_start","contentIndex":0}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}} +``` + +The top-level `usage` field contains the latest cumulative provider-reported usage. It may remain +zero until completion when a provider does not report usage during streaming. + +Example starting a tool call: +```json +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":1,"id":"call_abc123","toolName":"write"}} ``` `message_update` intentionally omits the former cumulative `message` field and `assistantMessageEvent.partial`. Clients that need a live partial message must assemble it from `message_start` and subsequent events using `contentIndex`. Treat `message_end.message` -as authoritative. For tool calls, buffer `toolcall_delta.delta`; `toolcall_end.toolCall` -contains the completed call. +as authoritative. For tool calls, `toolcall_start` provides the call `id` and `toolName`; +buffer `toolcall_delta.delta` for arguments. `toolcall_end.toolCall` contains the completed +call. ### bash_execution_update diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index edced918629..5b75b2f8a93 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -372,6 +372,13 @@ import { ModelRuntime } from "@earendil-works/pi-coding-agent"; const modelRuntime = await ModelRuntime.create(); +// create() restores cached catalogs but does not refresh them from pi.dev by default. +// Opt in to a create-time network refresh and bound how long it may take: +const refreshedRuntime = await ModelRuntime.create({ + allowModelNetwork: true, + modelRefreshTimeoutMs: 15_000, +}); + // Find specific built-in model (doesn't check if API key exists) const opus = getModel("anthropic", "claude-opus-4-5"); if (!opus) throw new Error("Model not found"); @@ -402,6 +409,8 @@ If no model is provided: 2. Uses default from settings 3. Falls back to first available model +Remote catalogs are persisted locally so later runtimes can restore them without a network request. The default file is `~/.pi/agent/models-store.json`; set `modelsStorePath` to choose another location, or inject `modelsStore` to control persistence. Network refreshes are throttled to once per provider every four hours unless forced. To force an immediate refresh, call `await modelRuntime.refresh({ allowNetwork: true, force: true, signal })`. Setting `PI_OFFLINE` disables model network access. + To match CLI model parsing, use the exported resolver helpers: ```typescript @@ -510,7 +519,7 @@ const { session } = await createAgentSession({ resourceLoader: loader }); Specify which built-in tools to enable: -- Built-in tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +- Built-in tool names: `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls` - Default built-ins: `read`, `bash`, `edit`, `write` - `noTools: "all"` disables all tools - `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled @@ -531,6 +540,11 @@ const { session } = await createAgentSession({ tools: ["read", "bash", "grep"], }); +// Use PowerShell instead of Bash on Windows +const { session } = await createAgentSession({ + tools: ["read", "powershell", "edit", "write"], +}); + // Disable one tool while keeping the rest available const { session } = await createAgentSession({ excludeTools: ["ask_question"], @@ -1187,7 +1201,7 @@ SettingsManager // Tool factories createCodingTools createReadOnlyTools -createReadTool, createBashTool, createEditTool, createWriteTool +createReadTool, createBashTool, createPowerShellTool, createEditTool, createWriteTool createGrepTool, createFindTool, createLsTool // Types diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 761a1941e24..d0ded5f6857 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -31,8 +31,8 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `defaultModel` | string | - | Default model ID | | `defaultThinkingLevel` | string | - | `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | | `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output | -| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses | -| `thinkingBudgets` | object | - | Custom token budgets per thinking level | +| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage | +| `thinkingBudgets` | object | - | Custom token budgets per thinking level. Anthropic, Google, and Bedrock use these natively. OpenAI-compatible models use them when `compat.thinkingTokenBudgetField` (or `supportsThinkingTokenBudget`) is set. | #### thinkingBudgets @@ -66,6 +66,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | | `tuiMode` | string | `"regular"` | Interactive TUI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup | +| `fullscreenExitOutput` | string | `"transcript"` | Fullscreen exit output: `"transcript"` prints the final transcript and resume hint, while `"resume-hint"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode | | `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling, `"always"` reserves the rightmost column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode | For VS Code, include `--wait` so pi resumes after the editor exits: @@ -191,6 +192,20 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic | `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `"shopt -s expand_aliases"`) | | `npmCommand` | string[] | - | Command argv used for npm package lookup/install operations (e.g., `["mise", "exec", "node@20", "--", "npm"]`) | +Windows paths in JSON must use forward slashes or escaped backslashes: + +```json +{ + "shellPath": "C:/Program Files/Git/bin/bash.exe" +} +``` + +```json +{ + "shellPath": "C:\\Program Files\\Git\\bin\\bash.exe" +} +``` + ```json { "npmCommand": ["mise", "exec", "node@20", "--", "npm"] @@ -199,6 +214,30 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic `npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. User-scoped npm packages install under `~/.pi/agent/npm/`; project-scoped npm packages install under `.pi/npm/`. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers. +### Tools + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `defaultTools` | string[] | - | Built-in tools enabled initially. When omitted, Pi uses its standard defaults | + +`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled. Available built-ins are `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`: + +```json +{ + "defaultTools": ["bash", "edit", "write"] +} +``` + +On Windows, select `powershell` instead of `bash`, or include both: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + +An empty array starts with no built-in tools while preserving extension and SDK custom tools. `--tools` replaces this behavior with a strict allowlist for all tools, `--no-tools` disables all tools, and `--no-builtin-tools` disables the built-in defaults. `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. + ### Sessions | Setting | Type | Default | Description | diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index d3ffeea8937..8905b7ff9a3 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -34,9 +34,10 @@ Pi loads skills from: - CLI: `--skill ` (repeatable, additive even with `--no-skills`) Discovery rules: -- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills +- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills when they have valid skill frontmatter with a non-empty `description` - In all skill locations, directories containing `SKILL.md` are discovered recursively -- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored +- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored, but nested `.md` files in grouping folders are discovered when they declare skill frontmatter +- Root Markdown files other than `SKILL.md` that do not look like skills are ignored silently Disable discovery with `--no-skills` (explicit `--skill` paths still load). @@ -183,7 +184,7 @@ Pi validates skills against the Agent Skills standard. Most issues produce warni Unknown frontmatter fields are ignored. -**Exception:** Skills with missing description are not loaded. +Declared skills with missing descriptions are not loaded. Malformed `SKILL.md` files and `SKILL.md` files without a description produce warnings and are not loaded. Other Markdown files without valid skill frontmatter are ignored. Name collisions (same name from different locations) warn and keep the first skill found. diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index d2a8fb92cca..5db077828ca 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -2,9 +2,26 @@ Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration. -## Kitty, iTerm2 +## Kitty -Work out of the box. +Works out of the box. + +## iTerm2 + +### Regular TUI mode + +Works out of the box. + +### Fullscreen TUI mode + +Pi owns the viewport, so iTerm2 sends mouse-wheel reports instead of scrolling its native scrollback. With iTerm2's default fast-trackpad behavior, those reports can lose most of an accelerated wheel delta, making fullscreen scrolling much slower than regular scrolling. + +If fast mouse-wheel gestures move only about one line at a time in fullscreen mode: + +1. Open **iTerm2 → Settings → Advanced**. +2. Search for **Trackpad scrolls fast?** and set it to **No**. + +This is an iTerm2-wide workaround and may also change native trackpad scrolling. The underlying behavior is tracked in [iTerm2 issue 9619](https://gitlab.com/gnachman/iterm2/-/work_items/9619). ## Apple Terminal @@ -32,6 +49,10 @@ If Claude Code 2.x or newer is the only reason you added that mapping, you can r Pi binds `Ctrl+J` as a default newline alias, so `Shift+Enter` keeps working in tmux via that remap without extra pi configuration. +### Fullscreen TUI mode + +In fullscreen mode, links remain clickable, but Ghostty does not show its hover underline or lower-left URL preview while pi captures mouse input. Hold `Shift+Command` on macOS or `Shift+Ctrl` on Linux to use Ghostty's native link handling. + ## WezTerm WezTerm usually works out of the box for `Shift+Enter` via xterm modifyOtherKeys. To use the Kitty keyboard protocol explicitly, create `~/.wezterm.lua`: @@ -99,7 +120,15 @@ Add to `keybindings.json`: ## Windows Terminal -Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward the modified Enter keys pi uses: +Pi uses Windows-style keybindings when running natively on Windows or in WSL: + +- `Alt+V` pastes an image or clipboard text. +- `Ctrl+F` searches the transcript in fullscreen mode, and `Ctrl+Up`/`Ctrl+Down` jump between marked messages. +- `Alt+P` cycles to the previous model. +- `Ctrl+Z` undoes editing on native Windows; WSL uses `Alt+Z` so `Ctrl+Z` can suspend pi. +- `Ctrl+Q` queues a follow-up message and `Alt+Q` restores queued messages. + +Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward `Shift+Enter` for inserting a new line: ```json { @@ -107,20 +136,14 @@ Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward { "command": { "action": "sendInput", "input": "\u001b[13;2u" }, "keys": "shift+enter" - }, - { - "command": { "action": "sendInput", "input": "\u001b[13;3u" }, - "keys": "alt+enter" } ] } ``` -- `Shift+Enter` inserts a new line. -- Windows Terminal binds `Alt+Enter` to fullscreen by default. That prevents pi from receiving `Alt+Enter` for follow-up queueing. -- Remapping `Alt+Enter` to `sendInput` forwards the real key chord to pi instead. +Windows Terminal binds `Alt+Enter` to fullscreen by default. To use it instead of pi's `Ctrl+Q` default for follow-up queueing, configure Windows Terminal to send the key and bind `app.message.followUp` to `alt+enter` in pi. -If you already have an `actions` array, add the objects to it. If the old fullscreen behavior persists, fully close and reopen Windows Terminal. +If you already have an `actions` array, add the object to it. Fully close and reopen Windows Terminal after changing its settings. ## xfce4-terminal, terminator diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 2a00dee3708..99a2b949663 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -39,6 +39,23 @@ Select a theme via `/settings` or in `settings.json`: On first run, pi detects your terminal background and defaults to `dark` or `light`. +### Initial Theme + +Start an interactive run with a theme without changing the saved setting: + +```bash +pi --use-theme light +``` + +To follow terminal appearance, use `lightTheme/darkTheme` syntax: + +```bash +pi --use-theme light/dark +``` + +The CLI value is the initial theme for that run. Choosing another theme later in `/settings` applies it immediately +and saves it normally. + ## Creating a Custom Theme 1. Create a theme file: @@ -72,6 +89,8 @@ vim ~/.pi/agent/themes/my-theme.json "thinkingText": "secondary", "selectedBg": "#2d2d30", "scrollbarThumb": "#555566", + "searchMatchBg": "#2d2d30", + "searchMatchText": "", "userMessageBg": "#2d2d30", "userMessageText": "", "customMessageBg": "#2d2d30", @@ -141,13 +160,13 @@ vim ~/.pi/agent/themes/my-theme.json - `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. -- `colors` must define all 51 required tokens. `thinkingMax` is optional and falls back to `thinkingXhigh`; `scrollbarThumb` is optional and falls back to `selectedBg`. +- `colors` must define all 51 required tokens. `thinkingMax`, `scrollbarThumb`, and the two search highlight tokens are optional and use the fallbacks listed below. The `$schema` field enables editor auto-completion and validation. ## Color Tokens -Every theme must define all 51 required color tokens. `thinkingMax` and `scrollbarThumb` are optional for compatibility with existing themes; when omitted, they use `thinkingXhigh` and `selectedBg`, respectively. +Every theme must define all 51 required color tokens. The optional tokens preserve compatibility with existing themes: `thinkingMax` falls back to `thinkingXhigh`, `scrollbarThumb` and `searchMatchBg` fall back to `selectedBg`, and `searchMatchText` falls back to `text`. Other search matches use `searchMatchText` on `searchMatchBg` with an underline; the current match reverses that foreground/background pair and uses bold text. ### Core UI (11 colors) @@ -165,12 +184,14 @@ Every theme must define all 51 required color tokens. `thinkingMax` and `scrollb | `text` | Default text (usually `""`) | | `thinkingText` | Thinking block text | -### Backgrounds & Content (11 required, 1 optional) +### Backgrounds & Content (11 required, 3 optional) | Token | Purpose | |-------|---------| | `selectedBg` | Selected line background | | `scrollbarThumb` | Fullscreen scrollbar thumb background; optional, falls back to `selectedBg` | +| `searchMatchBg` | Transcript search match background and current-match text; optional, falls back to `selectedBg` | +| `searchMatchText` | Transcript search match text and current-match background; optional, falls back to `text` | | `userMessageBg` | User message background | | `userMessageText` | User message text | | `customMessageBg` | Extension message background | diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index d9f789296aa..31dbf58b448 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -431,7 +431,7 @@ renderResult(result, options, theme, context) { | Category | Colors | |----------|--------| -| General | `text`, `accent`, `muted`, `dim` | +| General | `text`, `accent`, `muted`, `dim`, `searchMatchText` | | Status | `success`, `error`, `warning` | | Borders | `border`, `borderAccent`, `borderMuted` | | Messages | `userMessageText`, `customMessageText`, `customMessageLabel` | @@ -444,7 +444,7 @@ renderResult(result, options, theme, context) { **Background colors** (`theme.bg(color, text)`): -`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` +`selectedBg`, `searchMatchBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` **For Markdown**, use `getMarkdownTheme()`: diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index d09e37f8330..4db22b8c5a1 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -142,7 +142,7 @@ If you use pi for open source work and want to publish sessions for model, promp ## CLI Reference ```bash -pi [options] [@files...] [messages...] +pi [options] [--] [@files...] [messages...] ``` ### Package Commands @@ -213,7 +213,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools | -Built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`. +Built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls`. ### Resource Options @@ -242,15 +242,17 @@ pi --no-extensions -e ./my-extension.ts | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | | `--tui-mode ` | TUI mode: `regular` (default) or experimental `fullscreen` | +| `--use-theme ` | Set the initial interactive theme for this run without changing settings | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | +| `--` | Stop option parsing; remaining arguments are prompts or `@file` inputs | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | -In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally. +In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally. See [Terminal setup](terminal-setup.md) for terminal-specific settings and workarounds. -Set **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. +Set **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. **Fullscreen exit output** controls whether exiting fullscreen prints the final transcript or restores the previous screen and prints only the session resume hint. ### File Arguments @@ -271,6 +273,9 @@ pi "List all .ts files in src/" # Non-interactive pi -p "Summarize this codebase" +# Prompt beginning with a dash +pi -p -- "- Summarize these points" + # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 007f649c125..2517fd946d5 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,6 +1,6 @@ # Windows Setup -Pi requires a bash shell on Windows. Checked locations (in order): +Pi uses Git Bash by default on Windows. Checked locations (in order): 1. Custom path from `~/.pi/agent/settings.json` 2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) @@ -8,7 +8,29 @@ Pi requires a bash shell on Windows. Checked locations (in order): For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient. -## Custom Shell Path +## PowerShell Tool + +The optional `powershell` tool runs commands through `pwsh.exe` when available, otherwise Windows PowerShell. It starts PowerShell with `-NoProfile -NonInteractive -ExecutionPolicy Bypass`. Administrator-enforced execution policies can still take precedence. + +Use `defaultTools` to replace the model-facing `bash` tool: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + +Or enable both while comparing behavior: + +```json +{ + "defaultTools": ["read", "bash", "powershell", "edit", "write"] +} +``` + +The `!` and `!!` editor commands still use Bash. + +## Custom Bash Path ```json { diff --git a/packages/coding-agent/examples/extensions/border-status-editor.ts b/packages/coding-agent/examples/extensions/border-status-editor.ts index dce9e59596a..8be7f94571e 100644 --- a/packages/coding-agent/examples/extensions/border-status-editor.ts +++ b/packages/coding-agent/examples/extensions/border-status-editor.ts @@ -92,7 +92,7 @@ export default function (pi: ExtensionAPI) { activeTui?.requestRender(); }); - pi.on("agent_end", () => { + pi.on("agent_settled", () => { isWorking = false; stopSpinner(); activeTui?.requestRender(); diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 41a9e45fad4..a5ce47769f9 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.84.0", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.84.0", + "version": "0.84.3", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 21dd6a46a5e..9de50e63bbd 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.84.0", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 6008711e372..247c4d5b4d3 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.84.0", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/git-checkpoint.ts b/packages/coding-agent/examples/extensions/git-checkpoint.ts index 7ee5e6af886..6f26cff0a8a 100644 --- a/packages/coding-agent/examples/extensions/git-checkpoint.ts +++ b/packages/coding-agent/examples/extensions/git-checkpoint.ts @@ -46,8 +46,8 @@ export default function (pi: ExtensionAPI) { } }); - pi.on("agent_end", async () => { - // Clear checkpoints after agent completes + pi.on("agent_settled", async () => { + // Clear checkpoints after the full agent run completes checkpoints.clear(); }); } diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 5d4a5ee32b0..1beb731ef47 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.84.0", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.84.0", + "version": "0.84.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index a9b1cc116b3..eac937af065 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.84.0", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/notify.ts b/packages/coding-agent/examples/extensions/notify.ts index f7b1f819adb..9e91400f2af 100644 --- a/packages/coding-agent/examples/extensions/notify.ts +++ b/packages/coding-agent/examples/extensions/notify.ts @@ -49,7 +49,9 @@ function notify(title: string, body: string): void { } export default function (pi: ExtensionAPI) { - pi.on("agent_end", async () => { + // `agent_end` fires after each low-level run; Pi may still retry, compact, + // or continue with queued follow-ups. Notify only after the full run settles. + pi.on("agent_settled", async () => { notify("Pi", "Ready for input"); }); } diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 450b5b5d799..6a422b32bff 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.14.0", + "version": "1.14.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.14.0", + "version": "1.14.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index df95cdfd081..2d37b5c82fa 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.14.0", + "version": "1.14.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/subagent/README.md b/packages/coding-agent/examples/extensions/subagent/README.md index f98b64586a3..da74f32660f 100644 --- a/packages/coding-agent/examples/extensions/subagent/README.md +++ b/packages/coding-agent/examples/extensions/subagent/README.md @@ -62,7 +62,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust. -When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable. +When running interactively, the tool prompts for confirmation before running project-local agents in untrusted projects. Trusted projects skip the additional prompt. Set `confirmProjectAgents: false` to disable confirmation. ## Usage @@ -137,6 +137,8 @@ model: claude-haiku-4-5 System prompt for the agent goes here. ``` +When `model` is omitted, the subagent inherits the dispatching session's active model and thinking level. + **Locations:** - `~/.pi/agent/agents/*.md` - User-level (always loaded) - `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`) diff --git a/packages/coding-agent/examples/extensions/subagent/agents.ts b/packages/coding-agent/examples/extensions/subagent/agents.ts index c41ef579c5d..b8a36598c61 100644 --- a/packages/coding-agent/examples/extensions/subagent/agents.ts +++ b/packages/coding-agent/examples/extensions/subagent/agents.ts @@ -23,6 +23,42 @@ export interface AgentDiscoveryResult { projectAgentsDir: string | null; } +/** + * Raw agent frontmatter. Values are `unknown` because `parseFrontmatter` runs a + * real YAML parser, so any scalar or collection can appear here. + * + * A type alias rather than an interface: `parseFrontmatter` constrains its + * parameter to `Record`, and only an alias picks up the + * implicit index signature that satisfies it. + */ +type AgentFrontmatter = { + name?: unknown; + description?: unknown; + tools?: unknown; + model?: unknown; +}; + +/** + * Normalize a frontmatter `tools` value to a list of tool names. + * + * Both spellings are valid YAML and both are in use: + * + * tools: read, bash # string + * tools: [read, bash] # array + * + * so accept either. Anything else (a number, a map, a nested list) yields no + * tools rather than throwing: this runs inside agent discovery, where a single + * bad file must not take down every other agent in the same directory. + */ +function parseToolList(value: unknown): string[] | undefined { + const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; + const tools = raw + .filter((t): t is string => typeof t === "string") + .map((t) => t.trim()) + .filter(Boolean); + return tools.length > 0 ? tools : undefined; +} + function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] { const agents: AgentConfig[] = []; @@ -49,22 +85,17 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig continue; } - const { frontmatter, body } = parseFrontmatter>(content); + const { frontmatter, body } = parseFrontmatter(content); - if (!frontmatter.name || !frontmatter.description) { + if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") { continue; } - const tools = frontmatter.tools - ?.split(",") - .map((t: string) => t.trim()) - .filter(Boolean); - agents.push({ name: frontmatter.name, description: frontmatter.description, - tools: tools && tools.length > 0 ? tools : undefined, - model: frontmatter.model, + tools: parseToolList(frontmatter.tools), + model: typeof frontmatter.model === "string" ? frontmatter.model : undefined, systemPrompt: body, source, filePath, diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index 832dcc7423d..71b1a33dc75 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -16,7 +16,7 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Message } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai"; import { @@ -264,8 +264,14 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } { type OnUpdateCallback = (partial: AgentToolResult) => void; +interface DispatchDefaults { + model?: string; + thinkingLevel?: ThinkingLevel; +} + async function runSingleAgent( defaultCwd: string, + dispatchDefaults: DispatchDefaults, agents: AgentConfig[], agentName: string, task: string, @@ -292,7 +298,12 @@ async function runSingleAgent( } const args: string[] = ["--mode", "json", "-p", "--no-session"]; - if (agent.model) args.push("--model", agent.model); + const inheritsDispatchConfig = !agent.model; + const model = agent.model ?? dispatchDefaults.model; + if (model) args.push("--model", model); + if (inheritsDispatchConfig && dispatchDefaults.thinkingLevel) { + args.push("--thinking", dispatchDefaults.thinkingLevel); + } if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); let tmpPromptDir: string | null = null; @@ -306,7 +317,7 @@ async function runSingleAgent( messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, - model: agent.model, + model, step, }; @@ -471,6 +482,10 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, params, signal, onUpdate, ctx) { const agentScope: AgentScope = params.agentScope ?? "user"; + const dispatchDefaults: DispatchDefaults = { + model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, + thinkingLevel: ctx.thinkingLevel, + }; const discovery = discoverAgents(ctx.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? true; @@ -502,7 +517,12 @@ export default function (pi: ExtensionAPI) { }; } - if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { + if ( + (agentScope === "project" || agentScope === "both") && + confirmProjectAgents && + ctx.hasUI && + !ctx.isProjectTrusted() + ) { const requestedAgentNames = new Set(); if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); @@ -552,6 +572,7 @@ export default function (pi: ExtensionAPI) { const result = await runSingleAgent( ctx.cwd, + dispatchDefaults, agents, step.agent, taskWithContext, @@ -624,6 +645,7 @@ export default function (pi: ExtensionAPI) { const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { const result = await runSingleAgent( ctx.cwd, + dispatchDefaults, agents, t.agent, t.task, @@ -666,6 +688,7 @@ export default function (pi: ExtensionAPI) { if (params.agent && params.task) { const result = await runSingleAgent( ctx.cwd, + dispatchDefaults, agents, params.agent, params.task, diff --git a/packages/coding-agent/examples/extensions/titlebar-spinner.ts b/packages/coding-agent/examples/extensions/titlebar-spinner.ts index 51467acb6db..530471d584a 100644 --- a/packages/coding-agent/examples/extensions/titlebar-spinner.ts +++ b/packages/coding-agent/examples/extensions/titlebar-spinner.ts @@ -48,7 +48,7 @@ export default function (pi: ExtensionAPI) { startAnimation(ctx); }); - pi.on("agent_end", async (_event, ctx) => { + pi.on("agent_settled", async (_event, ctx) => { stopAnimation(ctx); }); diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index d0299608b05..b0d0855aad7 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.84.0", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.84.0", + "version": "0.84.3", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 8ac0a700338..3532ad7a724 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.84.0", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/rpc-extension-ui.ts b/packages/coding-agent/examples/rpc-extension-ui.ts index ba055d6ae27..996199d1138 100644 --- a/packages/coding-agent/examples/rpc-extension-ui.ts +++ b/packages/coding-agent/examples/rpc-extension-ui.ts @@ -576,7 +576,7 @@ async function main() { return; } - if (data.type === "agent_end") { + if (data.type === "agent_settled") { isStreaming = false; hideLoading(); outputLog.append(""); diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts index 6a8e6a11a08..1efdb140bb8 100644 --- a/packages/coding-agent/examples/sdk/06-extensions.ts +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) { }); pi.on("agent_end", async (event) => { - console.log(\`[Extension] Done, \${event.messages.length} messages\`); + console.log(\`[Extension] Low-level run ended, \${event.messages.length} messages\`); }); // Register a custom tool diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index f4e4dbcda94..467cc1df72d 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -132,7 +132,7 @@ session.subscribe((event) => { case "tool_execution_end": console.log(`Result: ${event.result}`); break; - case "agent_end": + case "agent_settled": console.log("Done"); break; } diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 92575e18546..44ab528ce36 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.0", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.0", + "version": "0.84.3", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.0" + "@earendil-works/pi-coding-agent": "0.84.3" }, "engines": { "node": ">=22.19.0" @@ -450,12 +450,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -466,20 +466,18 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", - "@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", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -491,31 +489,30 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.3" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -532,15 +529,15 @@ "@mariozechner/clipboard": "0.3.9" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.3.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -550,16 +547,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.3.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -772,26 +769,6 @@ ], "optional": true }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -804,24 +781,6 @@ } ] }, - "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==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "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==", - "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", @@ -1273,23 +1232,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -1487,15 +1429,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1541,9 +1474,9 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -1556,9 +1489,6 @@ "zod": { "optional": true } - }, - "bin": { - "openai": "bin/cli" } }, "node_modules/p-retry": { @@ -1610,22 +1540,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1856,24 +1770,6 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index dda433d5f5e..e7cf5a2af37 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.0", + "version": "0.84.3", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.0" + "@earendil-works/pi-coding-agent": "0.84.3" }, "overrides": { "protobufjs": "7.6.5", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 825f2e79315..f65c23df9a5 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,24 +1,23 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -35,7 +34,7 @@ "@mariozechner/clipboard": "0.3.9" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "engines": { "node": ">=22.19.0" @@ -477,12 +476,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -493,20 +492,18 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", - "@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", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -518,19 +515,19 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.3" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.3.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -540,16 +537,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.3.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.0.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -762,26 +759,6 @@ ], "optional": true }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -794,24 +771,6 @@ } ] }, - "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==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "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==", - "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", @@ -1263,23 +1222,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -1477,15 +1419,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1531,9 +1464,9 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -1546,9 +1479,6 @@ "zod": { "optional": true } - }, - "bin": { - "openai": "bin/cli" } }, "node_modules/p-retry": { @@ -1600,22 +1530,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1846,24 +1760,6 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 7dd347d0c94..02f45a37b3a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,13 +1,13 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.3", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { "configDir": ".pi" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -17,7 +17,7 @@ "import": "./dist/index.js" }, "./rpc-entry": { - "import": "./dist/rpc-entry.js" + "import": "./dist/bundle/rpc-entry.js" }, "./client": { "types": "./dist/client/index.d.ts", @@ -34,8 +34,9 @@ ], "scripts": { "clean": "shx rm -rf dist", - "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", + "build": "npm run build:unbundled && node ../../scripts/build-coding-agent-bundle.mjs", + "build:unbundled": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./src/bun/cli.ts ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", @@ -43,16 +44,15 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -77,10 +77,8 @@ }, "devDependencies": { "@types/cross-spawn": "6.0.6", - "@types/diff": "7.0.2", "@types/hosted-git-info": "3.0.5", - "@types/ms": "2.1.0", - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@types/proper-lockfile": "4.1.4", "@types/semver": "7.7.1", "shx": "0.4.0", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b6adf435f8d..8ad5da63e5c 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -42,6 +42,7 @@ export interface Args { promptTemplates?: string[]; noPromptTemplates?: boolean; themes?: string[]; + useTheme?: string; noThemes?: boolean; noContextFiles?: boolean; listModels?: string | true; @@ -62,6 +63,11 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel { return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); } +export function normalizeSessionName(value: string): string | undefined { + const name = value.trim(); + return name.length > 0 ? name : undefined; +} + export function parseArgs(args: string[]): Args { const result: Args = { messages: [], @@ -73,7 +79,16 @@ export function parseArgs(args: string[]): Args { for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === "--help" || arg === "-h") { + if (arg === "--") { + for (const positionalArg of args.slice(i + 1)) { + if (positionalArg.startsWith("@")) { + result.fileArgs.push(positionalArg.slice(1)); + } else { + result.messages.push(positionalArg); + } + } + break; + } else if (arg === "--help" || arg === "-h") { result.help = true; } else if (arg === "--version" || arg === "-v") { result.version = true; @@ -162,6 +177,14 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--theme" && i + 1 < args.length) { result.themes = result.themes ?? []; result.themes.push(args[++i]); + } else if (arg === "--use-theme") { + const themeName = args[i + 1]; + if (themeName === undefined || themeName.startsWith("-")) { + result.diagnostics.push({ type: "error", message: "--use-theme requires a theme name" }); + } else { + result.useTheme = themeName; + i++; + } } else if (arg === "--no-skills" || arg === "-ns") { result.noSkills = true; } else if (arg === "--no-prompt-templates" || arg === "-np") { @@ -239,7 +262,7 @@ export function printHelp(extensionFlags?: ExtensionFlag[]): void { console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools ${chalk.bold("Usage:")} - ${APP_NAME} [options] [@files...] [messages...] + ${APP_NAME} [options] [--] [@files...] [messages...] ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings @@ -248,7 +271,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) - ${APP_NAME} auth Print credentials for external clients + ${APP_NAME} auth Print credentials or check provider readiness ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth ${chalk.bold("Options:")} @@ -283,6 +306,7 @@ ${chalk.bold("Options:")} --prompt-template Load a prompt template file or directory (can be used multiple times) --no-prompt-templates, -np Disable prompt template discovery and loading --theme Load a theme file or directory (can be used multiple times) + --use-theme Set the initial interactive theme for this run --no-themes Disable theme discovery and loading --no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading --export Export session file to HTML and exit @@ -292,6 +316,7 @@ ${chalk.bold("Options:")} --approve, -a Trust project-local files for this run --no-approve, -na Ignore project-local files for this run --offline Disable startup network operations (same as PI_OFFLINE=1) + -- End option parsing; treat remaining arguments as messages/files --help, -h Show this help --version, -v Show version number @@ -299,10 +324,10 @@ Extensions can register additional flags (e.g., --plan from plan-mode extension) ${chalk.bold("Examples:")} # Print a provider API key for an external client - ${APP_NAME} auth print-api-key --provider openai --model gpt-5.5 + ${APP_NAME} auth print-api-key --provider openai # Print an OAuth bearer token for an external client (refreshes if expired) - ${APP_NAME} auth print-bearer-token --provider openai-codex --model gpt-5.5 + ${APP_NAME} auth print-bearer-token --provider openai-codex # Interactive mode ${APP_NAME} @@ -316,6 +341,9 @@ ${chalk.bold("Examples:")} # Non-interactive mode (process and exit) ${APP_NAME} -p "List all .ts files in src/" + # Prompt beginning with a dash + ${APP_NAME} -p -- "- Summarize these points" + # Multiple messages (interactive) ${APP_NAME} "Read package.json" "What dependencies do we have?" @@ -407,12 +435,13 @@ ${chalk.bold("Environment Variables:")} PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) ${chalk.bold("Built-in Tool Names:")} - read - Read file contents - bash - Execute bash commands - edit - Edit files with find/replace - write - Write files (creates/overwrites) - grep - Search file contents (read-only, off by default) - find - Find files by glob pattern (read-only, off by default) - ls - List directory contents (read-only, off by default) + read - Read file contents + bash - Execute bash commands + powershell - Execute PowerShell commands on Windows + edit - Edit files with find/replace + write - Write files (creates/overwrites) + grep - Search file contents (read-only, off by default) + find - Find files by glob pattern (read-only, off by default) + ls - List directory contents (read-only, off by default) `); } diff --git a/packages/coding-agent/src/cli/auth-check.ts b/packages/coding-agent/src/cli/auth-check.ts new file mode 100644 index 00000000000..9266ddb7f0a --- /dev/null +++ b/packages/coding-agent/src/cli/auth-check.ts @@ -0,0 +1,73 @@ +import type { CredentialStore } from "@earendil-works/pi-ai"; +import { resolveCliModel } from "../core/model-resolver.ts"; +import { ModelRuntime } from "../core/model-runtime.ts"; +import { InMemoryCodingAgentModelsStore } from "../core/models-store.ts"; +import type { Args } from "./args.ts"; +import { AuthCommandError, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts"; + +export type AuthCheckStatus = "ready" | "not_ready" | "invalid"; +export type AuthCheckReason = + | "provider_not_found" + | "credentials_not_configured" + | "credential_not_available" + | "invalid_state"; + +export interface AuthCheckResult { + status: AuthCheckStatus; + provider: string; + reason?: AuthCheckReason; + authType?: "api_key" | "oauth"; +} + +export async function checkProviderAuth( + args: Args, + modelRuntime: ModelRuntime, + options: { refresh: boolean } = { refresh: false }, +): Promise { + const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, "check"); + let provider = cliProvider; + if (cliModel) { + const resolved = resolveCliModel({ cliProvider, cliModel, modelRuntime }); + if (resolved.error || !resolved.model) { + throw new AuthCommandError(resolved.error ?? `Unable to resolve model "${cliModel}"`); + } + provider = resolved.model.provider; + } + if (!provider) throw new AuthCommandError("Unable to resolve an auth provider"); + if (modelRuntime.getError()) { + return { status: "invalid", provider, reason: "invalid_state" }; + } + if (!modelRuntime.getProvider(provider)) { + return { status: "not_ready", provider, reason: "provider_not_found" }; + } + try { + const auth = await modelRuntime.checkAuth(provider); + if (!auth) return { status: "not_ready", provider, reason: "credentials_not_configured" }; + if (options.refresh && !(await modelRuntime.getAuth(provider))) { + return { status: "not_ready", provider, reason: "credentials_not_configured" }; + } + return { status: "ready", provider, authType: auth.type }; + } catch { + return { status: "invalid", provider, reason: "invalid_state" }; + } +} + +export async function getProviderCredential( + providerId: string, + modelRuntime: ModelRuntime, + credentials: CredentialStore, + options: { refresh: boolean }, +): Promise { + const credential = await credentials.read(providerId); + if (!options.refresh && credential?.type === "oauth") return credential.access; + return getAuthCredential(await modelRuntime.getAuth(providerId)); +} + +export async function createAuthCheckModelRuntime(credentials: CredentialStore): Promise { + return ModelRuntime.create({ + credentials, + modelsStore: new InMemoryCodingAgentModelsStore(), + allowModelNetwork: false, + refreshOnCreate: false, + }); +} diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts new file mode 100644 index 00000000000..9ee80460279 --- /dev/null +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -0,0 +1,126 @@ +import type { AuthResult } from "@earendil-works/pi-ai"; +import { APP_NAME } from "../config.ts"; +import type { Args } from "./args.ts"; + +export type AuthCommandKind = "check" | "api_key" | "bearer_token"; + +export interface AuthCommand { + kind: AuthCommandKind; + args: string[]; + json: boolean; + credentials: boolean; + noRefresh: boolean; + minExpiryMs?: number; +} + +export class AuthCommandError extends Error {} + +const AUTH_COMMAND_USAGE: Record = { + check: `${APP_NAME} auth check --provider [--json] [--credentials] [--no-refresh]`, + api_key: `${APP_NAME} auth print-api-key --provider [--model ]`, + bearer_token: `${APP_NAME} auth print-bearer-token --provider [--model ] [--min-expiry ]`, +}; + +export function getAuthCommandName(kind: AuthCommandKind): string { + return kind === "check" ? "auth check" : kind === "api_key" ? "auth print-api-key" : "auth print-bearer-token"; +} + +export function getAuthCommandUsage(kind: AuthCommandKind): string { + return AUTH_COMMAND_USAGE[kind]; +} + +export function isAuthCommandHelp(args: string[]): boolean { + return ( + args[0] === "auth" && + (args[1] === undefined || args[1] === "help" || args.includes("--help") || args.includes("-h")) + ); +} + +export function printAuthCommandHelp(): void { + console.log(`Usage: + pi auth print-api-key [--provider ] [--model ] + pi auth print-bearer-token [--provider ] [--model ] [--min-expiry ] + pi auth check [--provider ] [--model ] [--json] [--credentials] [--no-refresh] + +Auth commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the credential, or includes it in JSON output.`); +} + +export function parseAuthCommand(args: string[]): AuthCommand | undefined { + if (args[0] !== "auth") return undefined; + + const kind = + args[1] === "check" + ? "check" + : args[1] === "print-api-key" + ? "api_key" + : args[1] === "print-bearer-token" + ? "bearer_token" + : undefined; + if (!kind) { + throw new AuthCommandError( + `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, + ); + } + + const commandArgs: string[] = []; + let json = false; + let credentials = false; + let noRefresh = false; + let minExpiryMs: number | undefined; + for (let index = 2; index < args.length; index++) { + const arg = args[index]; + if (arg === "--min-expiry") { + if (kind !== "bearer_token") + throw new AuthCommandError("--min-expiry is only supported by print-bearer-token"); + const value = args[++index]; + const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined; + if (!match) throw new AuthCommandError("--min-expiry must use a duration such as 30m or 1h"); + const amount = Number(match[1]); + const unit = match[2]; + minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000); + continue; + } + if (arg === "--json" || arg === "--credentials" || arg === "--no-refresh") { + if (kind !== "check") throw new AuthCommandError(`${arg} is only supported by auth check`); + if (arg === "--json") json = true; + else if (arg === "--credentials") credentials = true; + else noRefresh = true; + continue; + } + commandArgs.push(arg); + } + + return minExpiryMs === undefined + ? { kind, args: commandArgs, json, credentials, noRefresh } + : { kind, args: commandArgs, json, credentials, noRefresh, minExpiryMs }; +} + +export function validateAuthCommandArgs(args: Args, kind: AuthCommandKind): { provider?: string; model?: string } { + const provider = args.provider?.trim() || undefined; + const model = args.model?.trim() || undefined; + if (args.unknownFlags.size > 0) { + const option = args.unknownFlags.keys().next().value; + throw new AuthCommandError(`Unknown option --${option} for "${getAuthCommandName(kind)}".`); + } + if (args.apiKey !== undefined || args.messages.length > 0 || args.fileArgs.length > 0) { + throw new AuthCommandError("Auth commands only accept --provider and --model"); + } + if (kind === "check") { + if (!provider && !model) { + throw new AuthCommandError("Auth checks require --provider or --model "); + } + return { provider, model }; + } + if (!provider && !model) { + throw new AuthCommandError("Credential printing requires --provider or --model "); + } + return { provider, model }; +} + +export function getAuthCredential(auth: AuthResult | undefined): string | undefined { + if (auth?.auth.apiKey) return auth.auth.apiKey; + const authorization = Object.entries(auth?.auth.headers ?? {}).find( + ([name]) => name.toLowerCase() === "authorization", + )?.[1]; + return typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined; +} diff --git a/packages/coding-agent/src/cli/credential-print.ts b/packages/coding-agent/src/cli/credential-print.ts index 8f069781d54..304876659cd 100644 --- a/packages/coding-agent/src/cli/credential-print.ts +++ b/packages/coding-agent/src/cli/credential-print.ts @@ -2,81 +2,14 @@ import type { Api, CredentialInfo, Model } from "@earendil-works/pi-ai"; import { resolveCliModel } from "../core/model-resolver.ts"; import type { ModelRuntime } from "../core/model-runtime.ts"; import type { Args } from "./args.ts"; - -export type CredentialPrintKind = "api_key" | "bearer_token"; +import { AuthCommandError, type AuthCommandKind, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts"; const DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000; -export interface CredentialPrintCommand { - kind: CredentialPrintKind; - args: string[]; - minExpiryMs?: number; -} - -export class CredentialPrintError extends Error {} - -export function isCredentialPrintHelp(args: string[]): boolean { - return ( - args[0] === "auth" && (args[1] === undefined || args[1] === "help" || args[1] === "--help" || args[1] === "-h") - ); -} - -export function printCredentialPrintHelp(): void { - console.log(`Usage: - pi auth print-api-key --model [--provider ] - pi auth print-bearer-token --model [--provider ] [--min-expiry ] - -Prints the configured credential alone on stdout. Provider inference uses configured credentials; specify --provider to select explicitly. Bearer tokens have a 30-minute minimum expiry by default. --min-expiry accepts ms, s, m, or h (for example, 30m).`); -} - -/** Parse the small, extensible `pi auth` command surface before normal startup. */ -export function parseCredentialPrintCommand(args: string[]): CredentialPrintCommand | undefined { - if (args[0] !== "auth") return undefined; - - const kind = args[1] === "print-api-key" ? "api_key" : args[1] === "print-bearer-token" ? "bearer_token" : undefined; - if (!kind) { - throw new CredentialPrintError( - `Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key" or "pi auth print-bearer-token".`, - ); - } - - const commandArgs: string[] = []; - let minExpiryMs: number | undefined; - for (let index = 2; index < args.length; index++) { - if (args[index] !== "--min-expiry") { - commandArgs.push(args[index]); - continue; - } - if (kind !== "bearer_token") { - throw new CredentialPrintError("--min-expiry is only supported by print-bearer-token"); - } - const value = args[++index]; - const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined; - if (!match) { - throw new CredentialPrintError("--min-expiry must use a duration such as 30m or 1h"); - } - const amount = Number(match[1]); - const unit = match[2]; - minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000); - } - - return minExpiryMs === undefined ? { kind, args: commandArgs } : { kind, args: commandArgs, minExpiryMs }; -} - -export function validateCredentialPrintArgs(args: Args): void { - if (!args.model?.trim()) { - throw new CredentialPrintError("Credential printing requires --model "); - } - if (args.apiKey !== undefined) { - throw new CredentialPrintError("Credential printing reads configured credentials; --api-key is not supported"); - } - if (args.messages.length > 0 || args.fileArgs.length > 0 || args.unknownFlags.size > 0) { - throw new CredentialPrintError("Credential printing only accepts --provider and --model"); - } -} +type CredentialPrintKind = Exclude; /** - * Resolve one request credential for a specific provider/model pair. + * Resolve one configured provider credential. * * This intentionally calls ModelRuntime.getAuth(), which refreshes and persists * OAuth credentials with less than five minutes remaining through the normal request-auth path. @@ -88,64 +21,67 @@ export async function resolveCredentialForPrint( minExpiryMs?: number, signal?: AbortSignal, ): Promise { - validateCredentialPrintArgs(args); - + const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, kind); const credentialTypes = new Map( (await modelRuntime.listCredentials({ signal })).map((credential) => [credential.providerId, credential.type]), ); - const models: Model[] = []; - if (args.provider) { - const resolved = resolveCliModel({ cliProvider: args.provider, cliModel: args.model, modelRuntime }); - if (resolved.error || !resolved.model) { - throw new CredentialPrintError(resolved.error ?? "Unable to resolve the requested provider/model"); + const providers: Array<{ id: string; model?: Model }> = []; + if (cliProvider) { + const provider = modelRuntime.getProvider(cliProvider); + if (!provider) { + throw new AuthCommandError(`Unknown provider "${cliProvider}". Use --list-models to see available providers.`); + } + if (cliModel) { + const resolved = resolveCliModel({ cliProvider: provider.id, cliModel, modelRuntime }); + if (resolved.error || !resolved.model) { + throw new AuthCommandError(resolved.error ?? "Unable to resolve the requested provider/model"); + } + providers.push({ id: provider.id, model: resolved.model }); + } else { + providers.push({ id: provider.id }); } - models.push(resolved.model); } else { for (const provider of modelRuntime.getProviders()) { if (!credentialTypes.has(provider.id)) continue; - const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: args.model, modelRuntime }); + const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: cliModel!, modelRuntime }); if (resolved.model && !resolved.error && !resolved.warning?.includes("Using custom model id")) { - models.push(resolved.model); + providers.push({ id: provider.id, model: resolved.model }); } } - if (models.length === 0) { - throw new CredentialPrintError(`Model "${args.model}" not found. Use --list-models to see available models.`); + if (providers.length === 0) { + throw new AuthCommandError(`Model "${cliModel}" not found. Use --list-models to see available models.`); } } const credentials: Array<{ providerId: string; value: string }> = []; - for (const model of models) { - const type = credentialTypes.get(model.provider); + for (const provider of providers) { + const type = credentialTypes.get(provider.id); if (kind === "api_key" && type === "oauth") continue; if (kind === "bearer_token" && type !== "oauth") continue; - - const auth = await modelRuntime.getAuth(model, { + const authOptions = { ...(kind === "bearer_token" ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } : {}), signal, - }); - const authorization = Object.entries(auth?.auth.headers ?? {}).find( - ([name]) => name.toLowerCase() === "authorization", - )?.[1]; - const bearerToken = typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined; - const value = kind === "bearer_token" ? (auth?.auth.apiKey ?? bearerToken) : auth?.auth.apiKey; - if (value) credentials.push({ providerId: model.provider, value }); + }; + const auth = provider.model + ? await modelRuntime.getAuth(provider.model, authOptions) + : await modelRuntime.getAuth(provider.id, authOptions); + const value = getAuthCredential(auth); + if (value) credentials.push({ providerId: provider.id, value }); } if (credentials.length === 1) return credentials[0].value; if (credentials.length === 0) { - const providerId = models[0]?.provider; + const providerId = providers[0]?.id; const type = providerId ? credentialTypes.get(providerId) : undefined; - if (args.provider && kind === "api_key" && type === "oauth") { - throw new CredentialPrintError(`Provider "${providerId}" is configured with OAuth, not an API key`); + if (cliProvider && kind === "api_key" && type === "oauth") { + throw new AuthCommandError(`Provider "${providerId}" is configured with OAuth, not an API key`); } - if (args.provider && kind === "bearer_token" && type !== "oauth") { - throw new CredentialPrintError(`Provider "${providerId}" is not configured with an OAuth bearer token`); + if (cliProvider && kind === "bearer_token" && type !== "oauth") { + throw new AuthCommandError(`Provider "${providerId}" is not configured with an OAuth bearer token`); } - throw new CredentialPrintError( - `No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`, - ); + throw new AuthCommandError(`No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`); } - throw new CredentialPrintError( - `Model "${args.model}" has multiple configured providers (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`, + throw new AuthCommandError( + `Multiple configured providers matched (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`, ); } diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 4b3bf0e20fe..af3c1c10096 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -9,6 +9,7 @@ import { resolve } from "path"; import { resolveReadPath } from "../core/tools/path-utils.ts"; import { processImage } from "../utils/image-process.ts"; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts"; +import { stripBom } from "../utils/text.ts"; export interface ProcessedFiles { text: string; @@ -73,7 +74,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process } else { // Handle text file try { - const content = await readFile(absolutePath, "utf-8"); + const content = stripBom(await readFile(absolutePath, "utf-8")); text += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 38049f05c9a..21e7cd2a3ed 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; import { spawnProcessSync } from "./utils/child-process.ts"; import { normalizePath } from "./utils/paths.ts"; +import { stripBom } from "./utils/text.ts"; // ============================================================================= // Package Detection @@ -361,9 +362,26 @@ export function getUpdateInstruction(packageName: string): string { /** * Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md). * - For Bun binary: returns the directory containing the executable - * - For Node.js (dist/): returns __dirname (the dist/ directory) - * - For tsx (src/): returns parent directory (the package root) + * - For Node.js and tsx: returns the package root containing package.json + * - Ignores Bun binary metadata copied into dist/ when the package root is available */ +export function findNodePackageDir(startDir: string): string { + let dir = startDir; + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + const parent = dirname(dir); + // build:binary places Bun's metadata inside dist/. Node still needs the + // package root so its dist-relative asset paths do not become dist/dist/. + if (basename(dir) === "dist" && existsSync(join(parent, "package.json"))) { + return parent; + } + return dir; + } + dir = dirname(dir); + } + return startDir; +} + export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; @@ -375,16 +393,7 @@ export function getPackageDir(): string { // Bun binary: process.execPath points to the compiled executable return dirname(process.execPath); } - // Node.js: walk up from __dirname until we find package.json - let dir = __dirname; - while (dir !== dirname(dir)) { - if (existsSync(join(dir, "package.json"))) { - return dir; - } - dir = dirname(dir); - } - // Fallback (shouldn't happen) - return __dirname; + return findNodePackageDir(__dirname); } /** @@ -478,7 +487,7 @@ interface PackageJson { let pkg: PackageJson = {}; try { - pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; + pkg = JSON.parse(stripBom(readFileSync(getPackageJsonPath(), "utf-8"))) as PackageJson; } catch (e: unknown) { const err = e as NodeJS.ErrnoException; if (err.code !== "ENOENT") throw e; @@ -501,7 +510,7 @@ export function expandTildePath(path: string): string { const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; -/** Get the share viewer URL for a gist ID */ +/** Get the share viewer URL for a gist ID. */ export function getShareViewerUrl(gistId: string): string { const baseUrl = process.env.PI_SHARE_VIEWER_URL || DEFAULT_SHARE_VIEWER_URL; return `${baseUrl}#${gistId}`; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b210c5ace4f..488f5e0aecf 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,7 +13,7 @@ * Modes use this class and add their own I/O layer on top. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import type { Agent, @@ -48,12 +48,12 @@ import { } from "@earendil-works/pi-ai/compat"; import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; -import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { + type CompactionPreparation, type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, @@ -64,7 +64,7 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.ts"; -import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; import { @@ -81,6 +81,7 @@ import { type ReplacedSessionContext, type SessionBeforeCompactResult, type SessionBeforeTreeResult, + type SessionCompactFailedEvent, type SessionStartEvent, type ShutdownHandler, type ToolDefinition, @@ -99,8 +100,9 @@ import { ModelRegistry } from "./model-registry.ts"; import type { ModelRuntime } from "./model-runtime.ts"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; +import { exportSessionToJsonl } from "./session-export.ts"; import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts"; -import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts"; +import { getLatestCompactionEntry } from "./session-manager.ts"; import type { SettingsManager } from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; @@ -238,7 +240,7 @@ export interface ExtensionBindings { /** Options for AgentSession.prompt() */ export interface PromptOptions { - /** Whether to expand file-based prompt templates (default: true) */ + /** Whether to dispatch extension commands and expand skill commands and prompt templates (default: true) */ expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; @@ -250,6 +252,12 @@ export interface PromptOptions { preflightResult?: (success: boolean) => void; } +/** Options for model/thinking mutations. */ +export interface ModelMutationOptions { + /** Persist the new value to global defaults. Defaults to session-only. */ + persist?: boolean; +} + /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; @@ -295,9 +303,6 @@ function estimateMessagesTokens(messages: AgentMessage[]): number { // Constants // ============================================================================ -/** Standard thinking levels */ -const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; - // ============================================================================ // AgentSession Class // ============================================================================ @@ -574,6 +579,12 @@ export class AgentSession { }); } + private async _emitSessionCompactFailed(event: Omit): Promise { + if (this._extensionRunner.hasHandlers("session_compact_failed")) { + await this._extensionRunner.emit({ type: "session_compact_failed", ...event }); + } + } + private _getIdleWaitPromise(): Promise { if (!this._idleWaitPromise) { this._idleWaitPromise = new Promise((resolve) => { @@ -1449,7 +1460,7 @@ export class AgentSession { } satisfies CustomMessage; if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); - } else if (this.isStreaming) { + } else if (this.isStreaming && options?.triggerTurn !== false) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { @@ -1476,10 +1487,11 @@ export class AgentSession { * * @param content User message content (string or content array) * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" + * @param options.expandPromptTemplates Whether to dispatch extension commands and expand skill commands and prompt templates. Default: false. */ async sendUserMessage( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): Promise { // Normalize content to text string + optional images let text: string; @@ -1501,9 +1513,8 @@ export class AgentSession { if (images.length === 0) images = undefined; } - // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion await this.prompt(text, { - expandPromptTemplates: false, + expandPromptTemplates: options?.expandPromptTemplates ?? false, streamingBehavior: options?.deliverAs, images, source: "extension", @@ -1580,21 +1591,26 @@ export class AgentSession { /** * Set model directly. - * Validates that auth is configured, saves to session and settings. + * Validates that auth is configured and saves to the session transcript. + * Persists to global defaults only when options.persist is true. * @throws Error if no auth is configured for the model */ - async setModel(model: Model): Promise { + async setModel(model: Model, options: ModelMutationOptions = {}): Promise { if (!(await this._modelRuntime.checkAuth(model.provider))) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const previousModel = this.model; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(model); this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + } - // Re-clamp thinking level for new model's capabilities + // Apply thinking level for the new model. + // Per-model thinking level overrides take priority over the global default. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(model, previousModel, "set"); @@ -1606,14 +1622,20 @@ export class AgentSession { * @param direction - "forward" (default) or "backward" * @returns The new model info, or undefined if only one model available */ - async cycleModel(direction: "forward" | "backward" = "forward"): Promise { + async cycleModel( + direction: "forward" | "backward" = "forward", + options: ModelMutationOptions = {}, + ): Promise { if (this._scopedModels.length > 0) { - return this._cycleScopedModel(direction); + return this._cycleScopedModel(direction, options); } - return this._cycleAvailableModel(direction); + return this._cycleAvailableModel(direction, options); } - private async _cycleScopedModel(direction: "forward" | "backward"): Promise { + private async _cycleScopedModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableIds = new Set( this._modelRuntime.getAvailableSnapshot().map((model) => `${model.provider}\0${model.id}`), ); @@ -1629,17 +1651,20 @@ export class AgentSession { const len = scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = scopedModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); + const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel); // Apply model this.agent.state.model = next.model; this.sessionManager.appendModelChange(next.model.provider, next.model.id); - this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + } - // Apply thinking level. - // - Explicit scoped model thinking level overrides current session level - // - Undefined scoped model thinking level inherits the current session preference + // Apply thinking level for the new model. + // - Explicit scoped model thinking level overrides defaults + // - Per-model thinking level overrides take priority over the global default // setThinkingLevel clamps to model capabilities. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(next.model, currentModel, "cycle"); @@ -1647,7 +1672,10 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } - private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { + private async _cycleAvailableModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableModels = this._modelRuntime.getAvailableSnapshot(); if (availableModels.length <= 1) return undefined; @@ -1659,12 +1687,15 @@ export class AgentSession { const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel); this.agent.state.model = nextModel; this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); - this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + } - // Re-clamp thinking level for new model's capabilities + // Apply thinking level for the new model. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(nextModel, currentModel, "cycle"); @@ -1679,9 +1710,10 @@ export class AgentSession { /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. - * Saves to session and settings only if the level actually changes. + * Saves the clamped level to the session transcript only if the level actually changes. + * Persists the requested level to global defaults only when options.persist is true. */ - setThinkingLevel(level: ThinkingLevel): void { + setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); @@ -1691,11 +1723,12 @@ export class AgentSession { this.agent.state.thinkingLevel = effectiveLevel; + if (options.persist) { + this.settingsManager.setDefaultThinkingLevel(level); + } + if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); - if (this.supportsThinking() || effectiveLevel !== "off") { - this.settingsManager.setDefaultThinkingLevel(effectiveLevel); - } this._emit({ type: "thinking_level_changed", level: effectiveLevel }); void this._extensionRunner.emit({ type: "thinking_level_select", @@ -1709,7 +1742,7 @@ export class AgentSession { * Cycle to next thinking level. * @returns New level, or undefined if model doesn't support thinking */ - cycleThinkingLevel(): ThinkingLevel | undefined { + cycleThinkingLevel(options: ModelMutationOptions = {}): ThinkingLevel | undefined { if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); @@ -1717,7 +1750,7 @@ export class AgentSession { const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; - this.setThinkingLevel(nextLevel); + this.setThinkingLevel(nextLevel, options); return nextLevel; } @@ -1726,7 +1759,7 @@ export class AgentSession { * The provider will clamp to what the specific model supports internally. */ getAvailableThinkingLevels(): ThinkingLevel[] { - if (!this.model) return THINKING_LEVELS; + if (!this.model) return [...THINKING_LEVEL_OPTIONS]; return getSupportedThinkingLevels(this.model) as ThinkingLevel[]; } @@ -1737,14 +1770,18 @@ export class AgentSession { return !!this.model?.reasoning; } - private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel { + private _getThinkingLevelForModelSwitch(targetModel?: Model, explicitLevel?: ThinkingLevel): ThinkingLevel { if (explicitLevel !== undefined) { return explicitLevel; } - if (!this.supportsThinking()) { - return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + // Per-model default takes priority when switching to a model that has one + if (targetModel) { + const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id); + if (perModel !== undefined) { + return perModel; + } } - return this.thinkingLevel; + return this.settingsManager.getDefaultThinkingLevel() ?? this.thinkingLevel ?? DEFAULT_THINKING_LEVEL; } private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel { @@ -1782,15 +1819,53 @@ export class AgentSession { // Compaction // ========================================================================= + /** Generate Pi's built-in compaction summary for manual and automatic compaction. */ + private async _runDefaultCompaction( + preparation: CompactionPreparation, + requestModel: Model, + apiKey: string | undefined, + headers: Record | undefined, + customInstructions: string | undefined, + signal: AbortSignal, + env: Record | undefined, + reason: "manual" | "threshold" | "overflow", + ): Promise { + return compact( + preparation, + requestModel, + apiKey, + headers, + customInstructions, + signal, + this.thinkingLevel, + this.agent.streamFunction, + env, + this.settingsManager.getRetrySettings(), + this._summarizationRetryCallbacks({ source: "compaction", reason }), + undefined, // sessionId + ); + } + /** * Manually compact the session context. - * Aborts current agent operation first. + * + * This is the manual entry point used by `/compact`, RPC, and extensions. It is + * separate from automatic threshold/overflow compaction, which enters through + * `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the + * `session_before_compact` hook, both paths call the lower-level `compact()` + * function imported from `./compaction/index.ts`, unless the hook cancels or + * supplies a custom result. + * + * Aborts the current agent operation first. Manual compaction never retries or + * continues the interrupted agent turn. + * * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { await this.abort(); this._compactionAbortController = new AbortController(); this._emit({ type: "compaction_start", reason: "manual" }); + let fromExtension = false; try { if (!this.model) { @@ -1813,7 +1888,6 @@ export class AgentSession { } let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const result = (await this._extensionRunner.emit({ @@ -1850,19 +1924,16 @@ export class AgentSession { usage = extensionCompaction.usage; details = extensionCompaction.details; } else { - // Generate compaction result - const result = await compact( + // Shared default summary generator, also used by automatic compaction. + const result = await this._runDefaultCompaction( preparation, requestModel, apiKey, headers, customInstructions, this._compactionAbortController.signal, - this.thinkingLevel, - this.agent.streamFunction, env, - this.settingsManager.getRetrySettings(), - this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }), + "manual", ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -1917,6 +1988,7 @@ export class AgentSession { } catch (error) { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); + const errorMessage = aborted ? undefined : `Compaction failed: ${message}`; this._compactionAbortController = undefined; this._emit({ type: "compaction_end", @@ -1924,7 +1996,14 @@ export class AgentSession { result: undefined, aborted, willRetry: false, - errorMessage: aborted ? undefined : `Compaction failed: ${message}`, + errorMessage, + }); + await this._emitSessionCompactFailed({ + reason: "manual", + errorMessage, + aborted, + willRetry: false, + fromExtension, }); throw error; } finally { @@ -1948,16 +2027,25 @@ export class AgentSession { } /** - * Check if compaction is needed and run it. - * Called after agent_end and before prompt submission. + * Dispatch automatic compaction after `agent_end` or before prompt submission. + * Manual compaction does not call this method; it enters through `compact()`. + * + * Automatic cases: + * 1. Overflow with retry: a context-overflow error or recoverable length stop; + * remove the failed assistant message, compact, and retry the turn once. + * 2. Overflow without retry: a successful response exceeded the configured + * context window; compact but preserve the completed response. + * 3. Threshold without retry: valid or estimated context usage crossed the + * configured threshold; compact without retrying the completed response. * - * Two cases: - * 1. Recoverable failure: LLM returned context overflow or stopped below its desired output limit; - * remove the assistant message, compact, and auto-retry once - * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually) + * Each case calls `_runAutoCompaction()`. After preparation and the + * `session_before_compact` hook, that method calls the lower-level `compact()` + * function imported from `./compaction/index.ts`, unless the hook cancels or + * supplies a custom result. * * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true + * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages */ private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const settings = this.settingsManager.getCompactionSettings(); @@ -1985,35 +2073,45 @@ export class AgentSession { return false; } - // Case 1: Recoverable failure. Explicit/silent context overflow still uses context metadata. + // Automatic cases 1 and 2: context overflow. // A length stop is recoverable when output ended below the model's original desired limit, // independent of the configured context size or any context-clamped provider request limit. - // A successful response over the configured window should compact but must not retry: the - // assistant answer already completed and agent.continue() cannot continue from an assistant. + const contextOverflow = sameModel && isContextOverflow(assistantMessage, contextWindow); const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0); - if (sameModel && (isContextOverflow(assistantMessage, contextWindow) || recoverableLength)) { + if (contextOverflow || recoverableLength) { const willRetry = assistantMessage.stopReason !== "stop"; + // Case 2: the response completed successfully. Compact, but do not retry because + // agent.continue() cannot continue from a completed assistant response. if (!willRetry) { return await this._runAutoCompaction("overflow", false); } if (this._overflowRecoveryAttempted) { + const errorMessage = contextOverflow + ? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model." + : "Truncated response recovery failed after one compact-and-retry attempt."; this._emit({ type: "compaction_end", reason: "overflow", result: undefined, aborted: false, willRetry: false, - errorMessage: - "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", + errorMessage, + }); + await this._emitSessionCompactFailed({ + reason: "overflow", + errorMessage, + aborted: false, + willRetry: false, + fromExtension: false, }); return false; } + // Case 1: remove the failed or truncated message from agent state, compact, and + // retry once. The message remains in session history but is excluded from retry context. this._overflowRecoveryAttempted = true; - // Remove the failed or truncated message from agent state. It remains in session history, - // but must not be included in the compact-and-retry context. const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.state.messages = messages.slice(0, -1); @@ -2021,7 +2119,7 @@ export class AgentSession { return await this._runAutoCompaction("overflow", willRetry); } - // Case 2: Threshold - context is getting large + // Case 3: threshold compaction without retry. // For error messages or all-zero usage messages, estimate from the last valid response. // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage // responses can still compact and do not reset context accounting. @@ -2030,17 +2128,20 @@ export class AgentSession { if (assistantMessage.stopReason === "error" || directContextTokens === 0) { const messages = this.agent.state.messages; const estimate = estimateContextTokens(messages); - if (estimate.lastUsageIndex === null) return false; // No usage data at all - // Verify the usage source is post-compaction. Kept pre-compaction messages - // have stale usage reflecting the old (larger) context and would falsely - // trigger compaction right after one just finished. - const usageMsg = messages[estimate.lastUsageIndex]; - if ( - compactionEntry && - usageMsg.role === "assistant" && - (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() - ) { - return false; + // Without provider usage, estimate.tokens is the pure message-size estimate. + // Only usage-backed estimates need the stale pre-compaction check. + if (estimate.lastUsageIndex !== null) { + // Verify the usage source is post-compaction. Kept pre-compaction messages + // have stale usage reflecting the old (larger) context and would falsely + // trigger compaction right after one just finished. + const usageMsg = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMsg.role === "assistant" && + (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() + ) { + return false; + } } contextTokens = estimate.tokens; } else { @@ -2053,11 +2154,19 @@ export class AgentSession { } /** - * Internal: Run auto-compaction with events. + * Execute threshold or overflow compaction. Manual compaction uses + * `AgentSession.compact()` instead. Both paths call the lower-level `compact()` + * function imported from `./compaction/index.ts` after preparation and extension + * interception. + * + * @param reason Automatic trigger selected by `_checkCompaction()` + * @param willRetry Whether to continue the interrupted turn after overflow compaction + * @returns Whether the post-run loop should call `agent.continue()` */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); let started = false; + let fromExtension = false; try { if (!this.model) { @@ -2078,7 +2187,6 @@ export class AgentSession { started = true; let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const extensionResult = (await this._extensionRunner.emit({ @@ -2099,6 +2207,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension: false, + }); return false; } @@ -2122,19 +2236,16 @@ export class AgentSession { usage = extensionCompaction.usage; details = extensionCompaction.details; } else { - // Generate compaction result - const compactResult = await compact( + // Shared default summary generator, also used by manual compaction. + const compactResult = await this._runDefaultCompaction( preparation, requestModel, apiKey, headers, undefined, this._autoCompactionAbortController.signal, - this.thinkingLevel, - this.agent.streamFunction, env, - this.settingsManager.getRetrySettings(), - this._summarizationRetryCallbacks({ source: "compaction", reason }), + reason, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; @@ -2151,6 +2262,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension, + }); return false; } @@ -2204,16 +2321,24 @@ export class AgentSession { } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; if (started) { + const formattedErrorMessage = + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`; this._emit({ type: "compaction_end", reason, result: undefined, aborted: false, willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, + errorMessage: formattedErrorMessage, + }); + await this._emitSessionCompactFailed({ + reason, + errorMessage: formattedErrorMessage, + aborted: false, + willRetry: false, + fromExtension, }); } return false; @@ -3220,11 +3345,13 @@ export class AgentSession { /** * Export session to HTML. * @param outputPath Optional output path (defaults to session directory) + * @param options Optional export presentation settings * @returns Path to exported file */ - async exportToHtml(outputPath?: string): Promise { - const configuredThemeName = this.settingsManager.getTheme(); - const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined; + async exportToHtml(outputPath?: string, options: { themeName?: string } = {}): Promise { + const themeName = [options.themeName, this.settingsManager.getTheme()].find( + (candidate) => candidate !== undefined && getThemeByName(candidate) !== undefined, + ); // Create tool renderer if we have an extension runner (for custom tool HTML rendering) const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ @@ -3247,36 +3374,7 @@ export class AgentSession { * @returns The resolved output file path. */ exportToJsonl(outputPath?: string): string { - const filePath = resolvePath( - outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, - process.cwd(), - ); - const dir = dirname(filePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const header: SessionHeader = { - type: "session", - version: CURRENT_SESSION_VERSION, - id: this.sessionManager.getSessionId(), - timestamp: new Date().toISOString(), - cwd: this.sessionManager.getCwd(), - }; - - const branchEntries = this.sessionManager.getBranch(); - const lines = [JSON.stringify(header)]; - - // Re-chain parentIds to form a linear sequence - let prevId: string | null = null; - for (const entry of branchEntries) { - const linear = { ...entry, parentId: prevId }; - lines.push(JSON.stringify(linear)); - prevId = entry.id; - } - - writeFileSync(filePath, `${lines.join("\n")}\n`); - return filePath; + return exportSessionToJsonl(this.sessionManager, outputPath); } // ========================================================================= diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 2f792f0dfb4..9d64959194d 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -4,14 +4,15 @@ */ import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { setTimeout as sleep } from "timers/promises"; import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; -import { resolveConfigValue } from "./resolve-config-value.ts"; +import { stripBom } from "../utils/text.ts"; +import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts"; type AuthStorageData = Record; @@ -20,6 +21,7 @@ type LockResult = { next?: string; }; +// The mode applies only on creation so administrator-managed modes and ACLs remain intact. const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const; type AuthFileReload = { @@ -61,7 +63,6 @@ export class FileAuthStorageBackend implements AuthStorageBackend { private ensureFileExists(): void { if (!existsSync(this.authPath)) { writeFileSync(this.authPath, "{}", AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); } } @@ -103,7 +104,6 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const { result, next } = fn(current); if (next !== undefined) { writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); } return result; } finally { @@ -185,7 +185,6 @@ export class FileAuthStorageBackend implements AuthStorageBackend { options?.signal?.throwIfAborted(); if (next !== undefined) { writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); } throwIfCompromised(); return result; @@ -201,6 +200,95 @@ export class FileAuthStorageBackend implements AuthStorageBackend { } } +export class ReadOnlyAuthStorage implements CredentialStore { + private readonly authPath: string; + private data: AuthStorageData | undefined; + + constructor(authPath: string = join(getAgentDir(), "auth.json")) { + this.authPath = normalizePath(authPath); + } + + private load(): AuthStorageData { + if (this.data) return this.data; + + let parsed: unknown; + try { + parsed = JSON.parse(stripBom(readFileSync(this.authPath, "utf-8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.data = {}; + return this.data; + } + throw new Error(`Failed to read auth.json: ${error instanceof Error ? error.message : String(error)}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Invalid auth.json: expected an object"); + } + for (const [providerId, credential] of Object.entries(parsed)) { + if (typeof credential !== "object" || credential === null || Array.isArray(credential)) { + throw new Error(`Invalid auth.json credential for provider "${providerId}"`); + } + const value = credential as Record; + if (value.type === "api_key") { + const validKey = value.key === undefined || typeof value.key === "string"; + const validEnv = + value.env === undefined || + (typeof value.env === "object" && + value.env !== null && + !Array.isArray(value.env) && + Object.values(value.env).every((entry) => typeof entry === "string")); + if (validKey && validEnv) continue; + } else if ( + value.type === "oauth" && + typeof value.access === "string" && + typeof value.refresh === "string" && + typeof value.expires === "number" && + Number.isFinite(value.expires) + ) { + continue; + } + throw new Error(`Invalid auth.json credential for provider "${providerId}"`); + } + + this.data = parsed as AuthStorageData; + return this.data; + } + + async read(providerId: string, options?: AuthOperationOptions): Promise { + options?.signal?.throwIfAborted(); + const credential = this.load()[providerId]; + options?.signal?.throwIfAborted(); + if (!credential) return undefined; + if (credential.type !== "api_key" || !credential.key || isCommandConfigValue(credential.key)) { + return structuredClone(credential); + } + return { ...credential, key: resolveConfigValue(credential.key, credential.env) }; + } + + async list(options?: AuthOperationOptions): Promise { + options?.signal?.throwIfAborted(); + const credentials = Object.entries(this.load()).map(([providerId, credential]) => ({ + providerId, + type: credential.type, + })); + options?.signal?.throwIfAborted(); + return credentials; + } + + async modify( + _providerId: string, + _fn: (current: Credential | undefined) => Promise, + _options?: AuthOperationOptions, + ): Promise { + throw new Error("Read-only credential storage cannot modify auth.json"); + } + + async delete(_providerId: string, _options?: AuthOperationOptions): Promise { + throw new Error("Read-only credential storage cannot modify auth.json"); + } +} + export class InMemoryAuthStorageBackend implements AuthStorageBackend { private value: string | undefined; private asyncChain: Promise = Promise.resolve(); @@ -275,7 +363,7 @@ export class AuthStorage implements CredentialStore { if (!content) { return {}; } - return JSON.parse(content) as AuthStorageData; + return JSON.parse(stripBom(content)) as AuthStorageData; } private updateReadState(data: AuthStorageData, revision?: string): void { @@ -410,7 +498,7 @@ export function readStoredCredential( authPath: string = join(getAgentDir(), "auth.json"), ): Credential | undefined { try { - const data = JSON.parse(readFileSync(normalizePath(authPath), "utf-8")) as AuthStorageData; + const data = JSON.parse(stripBom(readFileSync(normalizePath(authPath), "utf-8"))) as AuthStorageData; return data[providerId]; } catch { return undefined; diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index dbb1217e575..d0abd545098 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -16,7 +16,7 @@ import { createCustomMessage, } from "../messages.ts"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; -import { completeSummarization, estimateTokens } from "./compaction.ts"; +import { completeSummarization, estimateTokens, getSummarizationFailure } from "./compaction.ts"; import { computeFileLists, createFileOps, @@ -354,8 +354,12 @@ export async function generateBranchSummary( if (response.stopReason === "aborted") { return { aborted: true }; } - if (response.stopReason === "error") { - return { error: response.errorMessage || "Summarization failed" }; + const failure = getSummarizationFailure(response, "Branch summarization"); + if (failure) { + return { error: failure }; + } + if (response.content.some((block) => block.type === "toolCall")) { + return { error: "Branch summarization attempted to call a tool" }; } let summary = contentText(response.content); diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 3f9b90ca424..cd442d4aa15 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -497,9 +497,7 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. - -Update the existing structured summary with new information. RULES: +const UPDATE_SUMMARIZATION_INSTRUCTIONS = `Update the existing structured summary with new information. RULES: - PRESERVE all existing information from the previous summary - ADD new progress, decisions, and context from the new messages - UPDATE the Progress section: move items from "In Progress" to "Done" when completed @@ -536,6 +534,24 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; + +/** + * Returns an error message when a summarization response cannot safely be persisted. + * A length stop contains partial text and must not become a session checkpoint. + */ +export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined { + if (response.stopReason === "error") { + return `${label} failed: ${response.errorMessage || "Unknown error"}`; + } + if (response.stopReason === "length") { + return `${label} failed: generation hit the token cap and the summary is incomplete`; + } + return undefined; +} + function createSummarizationOptions( model: Model, maxTokens: number, @@ -544,8 +560,9 @@ function createSummarizationOptions( env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, + sessionId: string | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env }; + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } @@ -567,11 +584,13 @@ export async function completeSummarization( retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { - // Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused. + // Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available; + // callers without a session ID, including branch summaries, receive a fresh routing ID. const requestOptions: SimpleStreamOptions = { ...options, cacheRetention: "none", - sessionId: uuidv7(), + sessionId: options.sessionId ?? uuidv7(), + toolChoice: "none", }; const produce = async (): Promise => streamFn @@ -598,6 +617,7 @@ export async function generateSummary( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise { return ( await generateSummaryWithUsage( @@ -614,10 +634,25 @@ export async function generateSummary( env, retry, callbacks, + sessionId, ) ).text; } +/** Build the provider context for a standalone summary request. */ +function buildSummarizationContext(promptText: string): Context { + return { + systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, + messages: [ + { + role: "user", + content: [{ type: "text", text: promptText }], + timestamp: Date.now(), + }, + ], + }; +} + /** Generate or update a conversation summary and return its provider usage. */ export async function generateSummaryWithUsage( currentMessages: AgentMessage[], @@ -633,6 +668,7 @@ export async function generateSummaryWithUsage( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), @@ -657,27 +693,32 @@ export async function generateSummaryWithUsage( } promptText += basePrompt; - const summarizationMessages = [ - { - role: "user" as const, - content: [{ type: "text" as const, text: promptText }], - timestamp: Date.now(), - }, - ]; - - const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel); + const completionOptions = createSummarizationOptions( + model, + maxTokens, + apiKey, + headers, + env, + signal, + thinkingLevel, + sessionId, + ); const response = await completeSummarization( model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + buildSummarizationContext(promptText), completionOptions, streamFn, retry, callbacks, ); - if (response.stopReason === "error") { - throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); + const failure = getSummarizationFailure(response, "Summarization"); + if (failure) { + throw new Error(failure); + } + if (response.content.some((block) => block.type === "toolCall")) { + throw new Error("Summarization attempted to call a tool"); } const textContent = contentText(response.content); @@ -813,6 +854,7 @@ Be concise. Focus on what's needed to understand the kept suffix.`; * * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary + * @param sessionId - Optional routing session ID forwarded without enabling prompt caching */ export async function compact( preparation: CompactionPreparation, @@ -826,6 +868,7 @@ export async function compact( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise { const { firstKeptEntryId, @@ -860,6 +903,7 @@ export async function compact( env, retry, callbacks, + sessionId, ); historyText = historyResult.text; historyUsage = historyResult.usage; @@ -876,6 +920,7 @@ export async function compact( streamFn, retry, callbacks, + sessionId, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; @@ -896,6 +941,7 @@ export async function compact( env, retry, callbacks, + sessionId, ); summary = result.text; summaryUsage = result.usage; @@ -933,6 +979,7 @@ async function generateTurnPrefixSummary( streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), @@ -941,25 +988,22 @@ async function generateTurnPrefixSummary( const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; - const summarizationMessages = [ - { - role: "user" as const, - content: [{ type: "text" as const, text: promptText }], - timestamp: Date.now(), - }, - ]; const response = await completeSummarization( model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), + buildSummarizationContext(promptText), + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), streamFn, retry, callbacks, ); - if (response.stopReason === "error") { - throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); + const failure = getSummarizationFailure(response, "Turn prefix summarization"); + if (failure) { + throw new Error(failure); + } + if (response.content.some((block) => block.type === "toolCall")) { + throw new Error("Turn prefix summarization attempted to call a tool"); } return { diff --git a/packages/coding-agent/src/core/defaults.ts b/packages/coding-agent/src/core/defaults.ts index fddc7d14f9a..1e15c40e0f0 100644 --- a/packages/coding-agent/src/core/defaults.ts +++ b/packages/coding-agent/src/core/defaults.ts @@ -1,3 +1,12 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; +export const THINKING_LEVEL_OPTIONS: readonly ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; diff --git a/packages/coding-agent/src/core/experimental.ts b/packages/coding-agent/src/core/experimental.ts index 12d33c74c88..77a7b5d2a7c 100644 --- a/packages/coding-agent/src/core/experimental.ts +++ b/packages/coding-agent/src/core/experimental.ts @@ -1,3 +1,9 @@ +const PREFER_STRICT_TOOL_SAMPLING = { type: "json_schema", strict: "prefer" } as const; + export function areExperimentalFeaturesEnabled(): boolean { return process.env.PI_EXPERIMENTAL === "1"; } + +export function getExperimentalToolSampling() { + return areExperimentalFeaturesEnabled() ? PREFER_STRICT_TOOL_SAMPLING : undefined; +} diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index f8841aa8eb0..90c7860412f 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -105,6 +105,8 @@ export type { MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, + PowerShellToolCallEvent, + PowerShellToolResultEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -134,6 +136,7 @@ export type { SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, + SessionCompactFailedEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, @@ -179,6 +182,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index a16c78530ec..36df2da2161 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -46,7 +46,7 @@ import type { ToolDefinition, } from "./types.ts"; -/** Modules available to extensions via virtualModules (for compiled Bun binary) */ +/** Modules available to extensions via virtualModules (for compiled binaries) */ const VIRTUAL_MODULES: Record = { typebox: _bundledTypebox, "typebox/compile": _bundledTypeboxCompile, @@ -75,11 +75,16 @@ const VIRTUAL_MODULES: Record = { const require = createRequire(import.meta.url); +const isNodeSeaBinary = + ("sea" in process.features && process.features.sea === true) || + process.getBuiltinModule("node:sea")?.isSea() === true; +declare const PI_BUNDLED_NODE: boolean; +const isBundledNode = typeof PI_BUNDLED_NODE !== "undefined" && PI_BUNDLED_NODE; const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts"; /** * Get aliases for jiti (used in built Node.js mode). - * In Bun binary mode, virtualModules is used instead. + * In compiled binary mode, virtualModules is used instead. */ let _aliases: Record | null = null; @@ -251,18 +256,38 @@ function createExtensionAPI( runtime: ExtensionRuntime, cwd: string, eventBus: EventBus, -): ExtensionAPI { +): { api: ExtensionAPI; commit: () => void; discard: () => void } { + const pendingFlagValues = new Map(); + const pendingRuntimeChanges: Array<() => void> = []; + const loadingUnsubscribers: Array<() => void> = []; + let state: "loading" | "active" | "failed" = "loading"; + const assertActive = () => { + if (state === "failed") { + throw new Error(`Extension "${extension.path}" failed to load and its API is no longer active.`); + } + runtime.assertActive(); + }; + const applyRuntimeChange = (change: () => void) => { + if (state === "loading") pendingRuntimeChanges.push(change); + else change(); + }; + const clearPending = () => { + pendingFlagValues.clear(); + pendingRuntimeChanges.length = 0; + loadingUnsubscribers.length = 0; + }; + const api = { // Registration methods - write to extension on(event: string, handler: HandlerFn): void { - runtime.assertActive(); + assertActive(); const list = extension.handlers.get(event) ?? []; list.push(handler); extension.handlers.set(event, list); }, registerTool(tool: ToolDefinition): void { - runtime.assertActive(); + assertActive(); extension.tools.set(tool.name, { definition: tool, sourceInfo: extension.sourceInfo, @@ -271,7 +296,7 @@ function createExtensionAPI( }, registerCommand(name: string, options: Omit): void { - runtime.assertActive(); + assertActive(); extension.commands.set(name, { name, sourceInfo: extension.sourceInfo, @@ -286,7 +311,7 @@ function createExtensionAPI( handler: (ctx: import("./types.ts").ExtensionContext) => Promise | void; }, ): void { - runtime.assertActive(); + assertActive(); extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options }); }, @@ -294,135 +319,164 @@ function createExtensionAPI( name: string, options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { - runtime.assertActive(); + assertActive(); + if (options.default !== undefined && typeof options.default !== options.type) { + throw new Error( + `Invalid default for flag "${name}": expected ${options.type}, got ${typeof options.default}`, + ); + } extension.flags.set(name, { name, extensionPath: extension.path, ...options }); if (options.default !== undefined && !runtime.flagValues.has(name)) { - runtime.flagValues.set(name, options.default); + if (state === "loading") { + if (!pendingFlagValues.has(name)) pendingFlagValues.set(name, options.default); + } else { + runtime.flagValues.set(name, options.default); + } } }, registerMessageRenderer(customType: string, renderer: MessageRenderer): void { - runtime.assertActive(); + assertActive(); extension.messageRenderers.set(customType, renderer as MessageRenderer); }, registerMarkdownTransformer(transformer: MarkdownTransformer): void { - runtime.assertActive(); + assertActive(); extension.markdownTransformer = transformer; }, registerEntryRenderer(customType: string, renderer: EntryRenderer): void { - runtime.assertActive(); + assertActive(); extension.entryRenderers ??= new Map(); extension.entryRenderers.set(customType, renderer as EntryRenderer); }, // Flag access - checks extension registered it, reads from runtime getFlag(name: string): boolean | string | undefined { - runtime.assertActive(); + assertActive(); if (!extension.flags.has(name)) return undefined; - return runtime.flagValues.get(name); + return runtime.flagValues.has(name) ? runtime.flagValues.get(name) : pendingFlagValues.get(name); }, // Action methods - delegate to shared runtime sendMessage(message, options): void { - runtime.assertActive(); + assertActive(); runtime.sendMessage(message, options); }, sendUserMessage(content, options): void { - runtime.assertActive(); + assertActive(); runtime.sendUserMessage(content, options); }, appendEntry(customType: string, data?: unknown): void { - runtime.assertActive(); + assertActive(); runtime.appendEntry(customType, data); }, setSessionName(name: string): void { - runtime.assertActive(); + assertActive(); runtime.setSessionName(name); }, getSessionName(): string | undefined { - runtime.assertActive(); + assertActive(); return runtime.getSessionName(); }, setLabel(entryId: string, label: string | undefined): void { - runtime.assertActive(); + assertActive(); runtime.setLabel(entryId, label); }, exec(command: string, args: string[], options?: ExecOptions) { - runtime.assertActive(); + assertActive(); return execCommand(command, args, options?.cwd ?? cwd, options); }, getActiveTools(): string[] { - runtime.assertActive(); + assertActive(); return runtime.getActiveTools(); }, getAllTools() { - runtime.assertActive(); + assertActive(); return runtime.getAllTools(); }, setActiveTools(toolNames: string[]): void { - runtime.assertActive(); + assertActive(); runtime.setActiveTools(toolNames); }, getCommands() { - runtime.assertActive(); + assertActive(); return runtime.getCommands(); }, setModel(model) { - runtime.assertActive(); + assertActive(); return runtime.setModel(model); }, getThinkingLevel() { - runtime.assertActive(); + assertActive(); return runtime.getThinkingLevel(); }, setThinkingLevel(level) { - runtime.assertActive(); + assertActive(); runtime.setThinkingLevel(level); }, registerProvider(providerOrName: Provider | string, config?: ProviderConfig) { - runtime.assertActive(); + assertActive(); if (typeof providerOrName === "string") { if (!config) throw new Error("Provider config is required when registering by name"); - runtime.registerProvider(providerOrName, config, extension.path); + applyRuntimeChange(() => runtime.registerProvider(providerOrName, config, extension.path)); return; } - runtime.registerNativeProvider(providerOrName, extension.path); + applyRuntimeChange(() => runtime.registerNativeProvider(providerOrName, extension.path)); }, unregisterProvider(name: string) { - runtime.assertActive(); - runtime.unregisterProvider(name, extension.path); + assertActive(); + applyRuntimeChange(() => runtime.unregisterProvider(name, extension.path)); }, events: { emit(channel, data) { - runtime.assertActive(); + assertActive(); eventBus.emit(channel, data); }, on(channel, handler) { - runtime.assertActive(); - return runtime.trackEventBusSubscription(eventBus.on(channel, handler)); + assertActive(); + const unsubscribe = runtime.trackEventBusSubscription(eventBus.on(channel, handler)); + if (state === "loading") loadingUnsubscribers.push(unsubscribe); + return unsubscribe; }, }, } as ExtensionAPI; - return api; + return { + api, + commit: () => { + if (state !== "loading") return; + runtime.assertActive(); + for (const [name, value] of pendingFlagValues) { + if (!runtime.flagValues.has(name)) runtime.flagValues.set(name, value); + } + for (const apply of pendingRuntimeChanges) apply(); + state = "active"; + clearPending(); + }, + discard: () => { + if (state !== "loading") return; + state = "failed"; + for (const unsubscribe of loadingUnsubscribers) unsubscribe(); + clearPending(); + }, + }; } function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken { @@ -443,9 +497,10 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension const jiti = createJiti(import.meta.url, { moduleCache: false, - // Bun uses modules embedded in the executable. Source TypeScript reuses the - // host-resolved modules and root tsconfig paths. Built Node uses dist aliases. - ...(isBunBinary + // Compiled binaries and the bundled Node distribution use embedded modules. + // Source TypeScript reuses host modules and root tsconfig paths. Unbundled + // Node builds use dist aliases. + ...(isBunBinary || isNodeSeaBinary || isBundledNode ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true } @@ -487,6 +542,27 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension }; } +async function initializeExtension( + factory: ExtensionFactory, + extensionPath: string, + resolvedPath: string, + cwd: string, + eventBus: EventBus, + runtime: ExtensionRuntime, +): Promise { + const extension = createExtension(extensionPath, resolvedPath); + const load = createExtensionAPI(extension, runtime, cwd, eventBus); + try { + await factory(load.api); + load.commit(); + } catch (error) { + load.discard(); + throw error; + } + time(`${extensionPath} factory`, "extensions"); + return extension; +} + async function loadExtension( extensionPath: string, cwd: string, @@ -503,10 +579,7 @@ async function loadExtension( return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } - const extension = createExtension(extensionPath, resolvedPath); - const api = createExtensionAPI(extension, runtime, cwd, eventBus); - await factory(api); - time(`${extensionPath} factory`, "extensions"); + const extension = await initializeExtension(factory, extensionPath, resolvedPath, cwd, eventBus, runtime); return { extension, error: null }; } catch (err) { @@ -525,12 +598,8 @@ export async function loadExtensionFromFactory( runtime: ExtensionRuntime, extensionPath = "", ): Promise { - const extension = createExtension(extensionPath, extensionPath); const resolvedCwd = resolvePath(cwd); - const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus); - await factory(api); - time(`${extensionPath} factory`, "extensions"); - return extension; + return initializeExtension(factory, extensionPath, extensionPath, resolvedCwd, eventBus, runtime); } /** diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index e62dfa10f67..bade4641951 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -78,6 +78,8 @@ import type { GrepToolInput, LsToolDetails, LsToolInput, + PowerShellToolDetails, + PowerShellToolInput, ReadToolDetails, ReadToolInput, WriteToolInput, @@ -177,8 +179,8 @@ export interface ExtensionUIContext { /** Set a custom footer component, or undefined to restore the built-in footer. * * The factory receives a FooterDataProvider for data not otherwise accessible: - * git branch and extension statuses from setStatus(). Token stats, model info, - * etc. are available via ctx.sessionManager and ctx.model. + * git branch and extension statuses from setStatus(). Context usage is on + * ctx.getContextUsage(), token stats on ctx.sessionManager.getEntries(), model info on ctx.model. */ setFooter( factory: @@ -399,7 +401,7 @@ export interface ReplacedSessionContext extends ExtensionCommandContext { sendUserMessage( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): Promise; } @@ -601,7 +603,7 @@ export interface SessionBeforeCompactEvent { signal: AbortSignal; } -/** Fired after context compaction */ +/** Fired after context compaction succeeds */ export interface SessionCompactEvent { type: "session_compact"; compactionEntry: CompactionEntry; @@ -612,6 +614,21 @@ export interface SessionCompactEvent { willRetry: boolean; } +/** Fired after context compaction fails or is aborted */ +export interface SessionCompactFailedEvent { + type: "session_compact_failed"; + /** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */ + reason: "manual" | "threshold" | "overflow"; + /** Error text when compaction failed for a non-abort reason. */ + errorMessage?: string; + /** True when compaction was cancelled or aborted. */ + aborted: boolean; + /** True when the aborted turn would have been retried after this compaction (overflow recovery) */ + willRetry: boolean; + /** True when the failing compaction content came from a session_before_compact handler. */ + fromExtension: boolean; +} + /** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */ export interface SessionShutdownEvent { type: "session_shutdown"; @@ -658,6 +675,7 @@ export type SessionEvent = | SessionBeforeForkEvent | SessionBeforeCompactEvent | SessionCompactEvent + | SessionCompactFailedEvent | SessionShutdownEvent | SessionBeforeTreeEvent | SessionTreeEvent; @@ -860,6 +878,11 @@ export interface BashToolCallEvent extends ToolCallEventBase { input: BashToolInput; } +export interface PowerShellToolCallEvent extends ToolCallEventBase { + toolName: "powershell"; + input: PowerShellToolInput; +} + export interface ReadToolCallEvent extends ToolCallEventBase { toolName: "read"; input: ReadToolInput; @@ -903,6 +926,7 @@ export interface CustomToolCallEvent extends ToolCallEventBase { */ export type ToolCallEvent = | BashToolCallEvent + | PowerShellToolCallEvent | ReadToolCallEvent | EditToolCallEvent | WriteToolCallEvent @@ -926,6 +950,11 @@ export interface BashToolResultEvent extends ToolResultEventBase { details: BashToolDetails | undefined; } +export interface PowerShellToolResultEvent extends ToolResultEventBase { + toolName: "powershell"; + details: PowerShellToolDetails | undefined; +} + export interface ReadToolResultEvent extends ToolResultEventBase { toolName: "read"; details: ReadToolDetails | undefined; @@ -964,6 +993,7 @@ export interface CustomToolResultEvent extends ToolResultEventBase { /** Fired after a tool executes. Can modify result. */ export type ToolResultEvent = | BashToolResultEvent + | PowerShellToolResultEvent | ReadToolResultEvent | EditToolResultEvent | WriteToolResultEvent @@ -976,6 +1006,9 @@ export type ToolResultEvent = export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { return e.toolName === "bash"; } +export function isPowerShellToolResult(e: ToolResultEvent): e is PowerShellToolResultEvent { + return e.toolName === "powershell"; +} export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { return e.toolName === "read"; } @@ -1016,6 +1049,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { * CustomToolCallEvent.toolName is `string` which overlaps with all literals. */ export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent; +export function isToolCallEventType(toolName: "powershell", event: ToolCallEvent): event is PowerShellToolCallEvent; export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent; export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent; export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent; @@ -1072,6 +1106,11 @@ export interface ToolCallEventResult { /** Block tool execution. To modify arguments, mutate `event.input` in place instead. */ block?: boolean; reason?: string; + /** + * Hint that the agent should stop after the current tool batch when this call is blocked. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; } /** Result from user_bash event handler */ @@ -1209,6 +1248,7 @@ export interface ExtensionAPI { handler: ExtensionHandler, ): void; on(event: "session_compact", handler: ExtensionHandler): void; + on(event: "session_compact_failed", handler: ExtensionHandler): void; on(event: "session_shutdown", handler: ExtensionHandler): void; on(event: "session_before_tree", handler: ExtensionHandler): void; on(event: "session_tree", handler: ExtensionHandler): void; @@ -1266,11 +1306,17 @@ export interface ExtensionAPI { /** Register a CLI flag. */ registerFlag( name: string, - options: { - description?: string; - type: "boolean" | "string"; - default?: boolean | string; - }, + options: + | { + description?: string; + type: "boolean"; + default?: boolean; + } + | { + description?: string; + type: "string"; + default?: string; + }, ): void; /** Get the value of a registered CLI flag. */ @@ -1302,10 +1348,11 @@ export interface ExtensionAPI { /** * Send a user message to the agent. Always triggers a turn. * When the agent is streaming, use deliverAs to specify how to queue the message. + * Set expandPromptTemplates to dispatch extension commands and expand skill commands and prompt templates. */ sendUserMessage( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): void; /** Append a custom entry to the session for state persistence (not sent to LLM). */ @@ -1555,7 +1602,7 @@ export type SendMessageHandler = ( export type SendUserMessageHandler = ( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ) => void; export type AppendEntryHandler = (customType: string, data?: T) => void; diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index edee25caa2a..3119d63d35a 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -94,7 +94,7 @@ function shouldPollGitHead(repoDir: string): boolean { /** * Provides git branch and extension statuses - data not otherwise accessible to extensions. - * Token stats, model info available via ctx.sessionManager and ctx.model. + * Context usage on ctx.getContextUsage(), token stats on ctx.sessionManager.getEntries(), model info on ctx.model. */ export class FooterDataProvider { private cwd: string; diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index ec5b135d27d..4022bdacef5 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -9,6 +9,7 @@ import { import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { getAgentDir } from "../config.ts"; +import { stripBom } from "../utils/text.ts"; export interface AppKeybindings { "app.interrupt": true; @@ -57,12 +58,37 @@ export interface AppKeybindings { export type AppKeybinding = keyof AppKeybindings; +export function useWindowsKeybindings( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)); +} + declare module "@earendil-works/pi-tui" { interface Keybindings extends AppKeybindings {} } +const windowsKeybindings = useWindowsKeybindings(); + export const KEYBINDINGS = { ...TUI_KEYBINDINGS, + "tui.editor.undo": { + ...TUI_KEYBINDINGS["tui.editor.undo"], + defaultKeys: process.platform === "win32" ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-", + }, + "tui.altScreen.previousPrompt": { + ...TUI_KEYBINDINGS["tui.altScreen.previousPrompt"], + defaultKeys: windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"], + }, + "tui.altScreen.nextPrompt": { + ...TUI_KEYBINDINGS["tui.altScreen.nextPrompt"], + defaultKeys: windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"], + }, + "tui.altScreen.search": { + ...TUI_KEYBINDINGS["tui.altScreen.search"], + defaultKeys: windowsKeybindings ? "ctrl+f" : "ctrl+shift+f", + }, "app.interrupt": { defaultKeys: "escape", description: "Cancel or abort" }, "app.clear": { defaultKeys: "ctrl+c", description: "Clear editor" }, "app.exit": { defaultKeys: "ctrl+d", description: "Exit when editor is empty" }, @@ -79,7 +105,7 @@ export const KEYBINDINGS = { description: "Cycle to next model", }, "app.model.cycleBackward": { - defaultKeys: "shift+ctrl+p", + defaultKeys: windowsKeybindings ? "alt+p" : "shift+ctrl+p", description: "Cycle to previous model", }, "app.model.select": { defaultKeys: "ctrl+l", description: "Open model selector" }, @@ -101,15 +127,15 @@ export const KEYBINDINGS = { description: "Copy message to clipboard", }, "app.message.followUp": { - defaultKeys: "alt+enter", + defaultKeys: windowsKeybindings ? "ctrl+q" : "alt+enter", description: "Queue follow-up message", }, "app.message.dequeue": { - defaultKeys: "alt+up", + defaultKeys: windowsKeybindings ? "alt+q" : "alt+up", description: "Restore queued messages", }, "app.clipboard.pasteImage": { - defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", + defaultKeys: windowsKeybindings ? "alt+v" : "ctrl+v", description: "Paste image from clipboard (text fallback)", }, "app.session.new": { defaultKeys: [], description: "Start a new session" }, @@ -329,7 +355,7 @@ function orderKeybindingsConfig(config: Record): Record | undefined { if (!existsSync(path)) return undefined; try { - const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + const parsed = JSON.parse(stripBom(readFileSync(path, "utf-8"))) as unknown; if (typeof parsed !== "object" || parsed === null) return undefined; return parsed as Record; } catch { diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 7f679a4947f..6036d5af939 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -6,6 +6,7 @@ import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; const PercentileCutoffsSchema = Type.Object({ p50: Type.Optional(Type.Number()), @@ -74,6 +75,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ supportsDeveloperRole: Type.Optional(Type.Boolean()), supportsReasoningEffort: Type.Optional(Type.Boolean()), supportsUsageInStreaming: Type.Optional(Type.Boolean()), + supportsFinishReason: Type.Optional(Type.Boolean()), maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), requiresToolResultName: Type.Optional(Type.Boolean()), requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), @@ -117,6 +119,7 @@ const OpenAIResponsesCompatSchema = Type.Object({ supportsLongCacheRetention: Type.Optional(Type.Boolean()), supportsStrictMode: Type.Optional(Type.Boolean()), supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()), + supportsAdditionalTools: Type.Optional(Type.Boolean()), supportsToolSearch: Type.Optional(Type.Boolean()), }); @@ -258,7 +261,7 @@ export class ModelConfig { let parsed: unknown; try { - parsed = JSON.parse(stripJsonComments(content)); + parsed = JSON.parse(stripJsonComments(stripBom(content))); } catch (error) { return new ModelConfig( new Map(), diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 7a35558e9be..441f47c16bd 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -18,6 +18,7 @@ import type { ModelRuntime } from "./model-runtime.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { + aimlapi: "openai/gpt-5.5-2026-04-23", "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", "ant-ling": "Ring-2.6-1T", anthropic: "claude-opus-4-8", @@ -32,11 +33,11 @@ export const defaultModelPerProvider: Record = { "github-copilot": "gpt-5.4", openrouter: "moonshotai/kimi-k2.6", "vercel-ai-gateway": "zai/glm-5.1", - xai: "grok-4.5", + xai: "grok-4.6", groq: "openai/gpt-oss-120b", - cerebras: "zai-glm-4.7", - zai: "glm-5.1", - "zai-coding-cn": "glm-5.1", + cerebras: "gpt-oss-120b", + zai: "glm-5.3", + "zai-coding-cn": "glm-5.3", mistral: "devstral-medium-latest", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", @@ -53,6 +54,7 @@ export const defaultModelPerProvider: Record = { "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", "qwen-token-plan": "qwen3.7-max", "qwen-token-plan-cn": "qwen3.7-max", + "qwen-token-plan-individual": "qwen3.8-max", xiaomi: "mimo-v2.5-pro", "xiaomi-token-plan-cn": "mimo-v2.5-pro", "xiaomi-token-plan-ams": "mimo-v2.5-pro", @@ -625,6 +627,7 @@ export async function findInitialModel(options: { defaultProvider?: string; defaultModelId?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; modelRuntime: ModelRuntime; }): Promise { const { @@ -635,6 +638,7 @@ export async function findInitialModel(options: { defaultProvider, defaultModelId, defaultThinkingLevel, + modelThinkingLevels, modelRuntime, } = options; @@ -659,9 +663,11 @@ export async function findInitialModel(options: { // 2. Use first model from scoped models (skip if continuing/resuming) if (scopedModels.length > 0 && !isContinuing) { + const scopedModel = scopedModels[0]; + const perModel = modelThinkingLevels?.[`${scopedModel.model.provider}/${scopedModel.model.id}`]; return { - model: scopedModels[0].model, - thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, + model: scopedModel.model, + thinkingLevel: scopedModel.thinkingLevel ?? perModel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, fallbackMessage: undefined, }; } @@ -671,7 +677,10 @@ export async function findInitialModel(options: { const found = modelRuntime.getModel(defaultProvider, defaultModelId); if (found && modelRuntime.hasConfiguredAuth(found.provider)) { model = found; - if (defaultThinkingLevel) { + const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`]; + if (perModel) { + thinkingLevel = perModel; + } else if (defaultThinkingLevel) { thinkingLevel = defaultThinkingLevel; } return { model, thinkingLevel, fallbackMessage: undefined }; diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 0ecd88de35d..6f4071b60b3 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -77,6 +77,8 @@ export interface CreateModelRuntimeOptions { catalogBaseUrl?: string; /** Optional caller cancellation for initial cache restoration and availability checks. */ signal?: AbortSignal; + /** Skip initial catalog and availability refresh. Static models remain available. */ + refreshOnCreate?: boolean; } export interface ModelRuntimeAuthOverrides extends AuthOperationOptions { @@ -205,7 +207,9 @@ export class ModelRuntime implements Models { : controller.signal : options.signal; try { - await runtime.refresh({ allowNetwork: refreshFromNetwork, signal }); + if (options.refreshOnCreate !== false) { + await runtime.refresh({ allowNetwork: refreshFromNetwork, signal }); + } } finally { if (timeout) clearTimeout(timeout); } diff --git a/packages/coding-agent/src/core/models-store.ts b/packages/coding-agent/src/core/models-store.ts index 8251b59b271..bfac3c5e76e 100644 --- a/packages/coding-agent/src/core/models-store.ts +++ b/packages/coding-agent/src/core/models-store.ts @@ -3,6 +3,7 @@ import type { ModelsStore, ModelsStoreEntry, ModelsStoreOperationOptions } from import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts"; type StoredModels = Record; @@ -59,7 +60,7 @@ export class FileModelsStore implements ModelsStore { } private parse(content: string | undefined): StoredModels { - return content ? (JSON.parse(content) as StoredModels) : {}; + return content ? (JSON.parse(stripBom(content)) as StoredModels) : {}; } private updateReadState(readState: ModelsFileReadState, data: StoredModels, revision?: string): void { diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index e221d2e7e3f..d65386c628a 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,6 +1,16 @@ import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + globSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; function getEnv(): NodeJS.ProcessEnv { @@ -24,14 +34,14 @@ function getEnv(): NodeJS.ProcessEnv { import { basename, dirname, join, relative, resolve, sep } from "node:path"; import type { Readable } from "node:stream"; -import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; -import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; +import { gt, maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { isStdoutTakenOver } from "./output-guard.ts"; import { type PiManifest, readPiManifest } from "./pi-manifest.ts"; import type { PackageSource, SettingsManager } from "./settings-manager.ts"; @@ -274,6 +284,18 @@ function hasGlobPattern(s: string): boolean { return s.includes("*") || s.includes("?"); } +/** Glob entries discover visible paths; exact entries can target dot paths or symlinked trees. */ +function expandPackageGlob(pattern: string, root: string): string[] { + return globSync(pattern, { cwd: root }) + .map((match) => resolve(root, match)) + .filter((path) => + relative(root, path) + .split(sep) + .every((segment) => segment === ".." || !segment.startsWith(".")), + ) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } { const plain: string[] = []; const patterns: string[] = []; @@ -397,7 +419,12 @@ function collectSkillEntries( } const relPath = toPosixPath(relative(root, fullPath)); - if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) { + const shouldIncludeMarkdownFile = + isFile && + entry.name.endsWith(".md") && + !ig.ignores(relPath) && + ((mode === "pi" && dir === root) || (mode === "agents" && dir !== root)); + if (shouldIncludeMarkdownFile) { entries.push(fullPath); continue; } @@ -1129,7 +1156,7 @@ export class DefaultPackageManager implements PackageManager { try { const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); - return targetVersion !== installedVersion; + return gt(targetVersion, installedVersion); } catch { // Preserve existing update behavior when version lookup fails. return true; @@ -1463,7 +1490,7 @@ export class DefaultPackageManager implements PackageManager { try { const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); - return targetVersion !== installedVersion; + return gt(targetVersion, installedVersion); } catch { return false; } @@ -1474,7 +1501,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(packageJsonPath)) return undefined; try { const content = readFileSync(packageJsonPath, "utf-8"); - const pkg = JSON.parse(content) as { version?: string }; + const pkg = JSON.parse(stripBom(content)) as { version?: string }; return pkg.version; } catch { return undefined; @@ -1856,7 +1883,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(packageJsonPath)) return false; try { - const manifest = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { dependencies?: unknown }; + const manifest = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as { dependencies?: unknown }; if ( !manifest.dependencies || typeof manifest.dependencies !== "object" || @@ -2294,12 +2321,7 @@ export class DefaultPackageManager implements PackageManager { return [resolve(root, entry)]; } - return globSync(entry, { - cwd: root, - absolute: true, - dot: false, - nodir: false, - }).map((match) => resolve(match)); + return expandPackageGlob(entry, root); }); return this.collectFilesFromPaths(resolved, resourceType); } diff --git a/packages/coding-agent/src/core/pi-manifest.ts b/packages/coding-agent/src/core/pi-manifest.ts index bff128cd5d6..fd7dd5edb6e 100644 --- a/packages/coding-agent/src/core/pi-manifest.ts +++ b/packages/coding-agent/src/core/pi-manifest.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { stripBom } from "../utils/text.ts"; export interface PiManifest { extensions?: string[]; @@ -15,7 +16,7 @@ function isObject(value: unknown): value is Record { export function readPiManifest(packageJsonPath: string): PiManifest | null { try { - const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + const pkg: unknown = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))); if (!isObject(pkg) || !isObject(pkg.pi)) { return null; } diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts index 2521ad5dbc5..b9ece17e49e 100644 --- a/packages/coding-agent/src/core/project-trust.ts +++ b/packages/coding-agent/src/core/project-trust.ts @@ -1,4 +1,4 @@ -import { CONFIG_DIR_NAME } from "../config.ts"; +import { APP_NAME, CONFIG_DIR_NAME } from "../config.ts"; import { emitProjectTrustEvent } from "./extensions/runner.ts"; import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; import type { DefaultProjectTrust } from "./settings-manager.ts"; @@ -22,7 +22,7 @@ export interface ResolveProjectTrustedOptions { } function formatProjectTrustPrompt(cwd: string): string { - return `Trust project folder?\n${cwd}\n\nThis allows pi to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`; + return `Trust project folder?\n${cwd}\n\nThis allows ${APP_NAME} to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`; } async function selectProjectTrustOption( diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts index 3a541f52767..602d3963092 100644 --- a/packages/coding-agent/src/core/provider-attribution.ts +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -3,6 +3,7 @@ import type { SettingsManager } from "./settings-manager.ts"; import { isInstallTelemetryEnabled } from "./telemetry.ts"; const OPENROUTER_HOST = "openrouter.ai"; +const AIMLAPI_HOST = "api.aimlapi.com"; const NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; const CLOUDFLARE_API_HOST = "api.cloudflare.com"; const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com"; @@ -20,6 +21,10 @@ function isOpenRouterModel(model: Model): boolean { return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST); } +function isAimlapiModel(model: Model): boolean { + return model.provider === "aimlapi" || matchesHost(model.baseUrl, AIMLAPI_HOST); +} + function isNvidiaNimModel(model: Model): boolean { return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST); } @@ -49,6 +54,18 @@ function getDefaultAttributionHeaders( }; } + if (isAimlapiModel(model)) { + return { + // Rebate attribution id (part_...) for the "pi" partner row in AI/ML + // API's rebate_partners table — do not repoint this to a different + // partner without also updating the backend record. + "X-AIMLAPI-Partner-ID": "part_0OJphKWIKTIItaGwnhjmaGJI", + "X-AIMLAPI-Source": "agent/pi", + "HTTP-Referer": "https://pi.dev", + "X-Title": "pi", + }; + } + if (isNvidiaNimModel(model)) { return { "X-BILLING-INVOKE-ORIGIN": "Pi", diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index c79b7e3bbd6..a12c80ba4a1 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -4,6 +4,7 @@ import { fetchWithRetry } from "../utils/management-http.ts"; import { getPiUserAgent } from "../utils/pi-user-agent.ts"; const DEFAULT_CATALOG_BASE_URL = "https://pi.dev"; +const REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000; export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000; function mergeModels(baseline: readonly Model[], dynamic: readonly Model[]): Model[] { @@ -78,14 +79,18 @@ export function withRemoteCatalog( // leave the overlay empty. const validator = stored?.models.length ? stored.etag : undefined; const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl); - const response = await fetchWithRetry(url, { - headers: { - accept: "application/json", - "User-Agent": getPiUserAgent(VERSION), - ...(validator ? { "if-none-match": validator } : {}), + const response = await fetchWithRetry( + url, + { + headers: { + accept: "application/json", + "User-Agent": getPiUserAgent(VERSION), + ...(validator ? { "if-none-match": validator } : {}), + }, + signal: context.signal, }, - signal: context.signal, - }); + { attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS }, + ); if (context.signal.aborted) return; const checkedAt = Date.now(); // Unchanged: dynamicModels already holds the stored overlay, so only the diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index c24d0a21047..04102425b82 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -8,6 +8,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts"; export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { clearExtensionCache, @@ -57,7 +58,7 @@ function resolvePromptInput(input: string | undefined, description: string): str if (existsSync(input)) { try { - return readFileSync(input, "utf-8"); + return stripBom(readFileSync(input, "utf-8")); } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); return input; @@ -78,7 +79,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } } return { path: filePath, - content: readFileSync(filePath, "utf-8"), + content: stripBom(readFileSync(filePath, "utf-8")), }; } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7f662d330c7..adbf102a398 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -23,6 +23,7 @@ import { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -62,9 +63,11 @@ export interface CreateAgentSessionOptions { /** * Optional allowlist of tool names. * - * When omitted, pi enables the default built-in tools (read, bash, edit, write) - * and leaves extension/custom tools enabled unless `noTools` changes that default. - * When provided, only the listed tool names are enabled. + * When omitted, pi uses the `defaultTools` setting for the initial built-in + * selection when configured. Otherwise it enables the default built-in tools + * (read, bash, edit, write). Extension/custom tools remain enabled unless + * `noTools` changes that default. When provided, only the listed tool names are + * enabled. */ tools?: string[]; /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ @@ -123,6 +126,7 @@ export { createGrepTool, createFindTool, createLsTool, + createPowerShellTool, }; // Helper Functions @@ -211,6 +215,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} defaultProvider: settingsManager.getDefaultProvider(), defaultModelId: settingsManager.getDefaultModel(), defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), + modelThinkingLevels: settingsManager.getAllModelThinkingLevels(), modelRuntime, }); model = result.model; @@ -230,7 +235,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); } - // Fall back to settings default + // Fall back to per-model override, then global default + if (thinkingLevel === undefined && model) { + const perModel = settingsManager.getModelThinkingLevel(model.provider, model.id); + if (perModel) { + thinkingLevel = perModel; + } + } if (thinkingLevel === undefined) { thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; } @@ -243,11 +254,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; + const configuredDefaultToolNames = settingsManager.getDefaultTools(); const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); const excludedToolNames = options.excludeTools; const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; - const initialActiveToolNames: string[] = ( - options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames + const initialActiveToolNames = ( + options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? defaultActiveToolNames)) ).filter((name) => !excludedToolNameSet?.has(name)); let agent: Agent; diff --git a/packages/coding-agent/src/core/session-export.ts b/packages/coding-agent/src/core/session-export.ts new file mode 100644 index 00000000000..3607242c373 --- /dev/null +++ b/packages/coding-agent/src/core/session-export.ts @@ -0,0 +1,42 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { resolvePath } from "../utils/paths.ts"; +import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionManager } from "./session-manager.ts"; + +/** Write the current session branch and optional trailing export-only entries as JSONL. */ +export function exportSessionToJsonl( + sessionManager: SessionManager, + outputPath?: string, + createTrailingEntries?: (parentId: string | null, timestamp: string) => readonly object[], +): string { + const filePath = resolvePath( + outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, + process.cwd(), + ); + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const timestamp = new Date().toISOString(); + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: sessionManager.getSessionId(), + timestamp, + cwd: sessionManager.getCwd(), + }; + const lines = [JSON.stringify(header)]; + + let parentId: string | null = null; + for (const entry of sessionManager.getBranch()) { + lines.push(JSON.stringify({ ...entry, parentId })); + parentId = entry.id; + } + for (const entry of createTrailingEntries?.(parentId, timestamp) ?? []) { + lines.push(JSON.stringify(entry)); + } + + writeFileSync(filePath, `${lines.join("\n")}\n`); + return filePath; +} diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 70d39216465..f8db9b0e5b5 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -17,7 +17,7 @@ import { readdir, stat } from "fs/promises"; import { join, resolve } from "path"; import { createInterface } from "readline"; import { StringDecoder } from "string_decoder"; -import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; +import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; import { type BashExecutionMessage, @@ -902,7 +902,7 @@ export class SessionManager { if (this.fileEntries.length === 0) { const explicitPath = this.sessionFile; if (statSync(explicitPath).size > 0) { - throw new Error(`Session file is not a valid pi session: ${explicitPath}`); + throw new Error(`Session file is not a valid ${APP_NAME} session: ${explicitPath}`); } this.newSession(); this.sessionFile = explicitPath; @@ -1388,13 +1388,14 @@ export class SessionManager { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } + const fromId = this.leafId ?? "root"; this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), - fromId: branchFromId ?? "root", + fromId, summary, details, usage, diff --git a/packages/coding-agent/src/core/settings-diagnostics.ts b/packages/coding-agent/src/core/settings-diagnostics.ts new file mode 100644 index 00000000000..8dfec3899af --- /dev/null +++ b/packages/coding-agent/src/core/settings-diagnostics.ts @@ -0,0 +1,25 @@ +import type { AgentSessionRuntimeDiagnostic } from "./agent-session-services.ts"; +import type { SettingsManager } from "./settings-manager.ts"; + +export function collectSettingsDiagnostics(settingsManager: SettingsManager): AgentSessionRuntimeDiagnostic[] { + return settingsManager.drainErrors().map(({ scope, path, error }) => ({ + type: "warning", + message: path ? `Invalid settings file ${path}: ${error.message}` : `Invalid ${scope} settings: ${error.message}`, + })); +} + +/** + * Remove duplicate type/message diagnostics while preserving their first occurrence. + * Startup and runtime settings managers can report the same file error. + */ +export function deduplicateDiagnostics( + diagnostics: readonly AgentSessionRuntimeDiagnostic[], +): AgentSessionRuntimeDiagnostic[] { + const seen = new Set(); + return diagnostics.filter((diagnostic) => { + const key = `${diagnostic.type}\0${diagnostic.message}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4a8c8bc4782..19d59483eaf 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -7,6 +7,7 @@ import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; export interface CompactionSettings { @@ -34,6 +35,7 @@ export interface RetrySettings { } export type TuiMode = RendererTuiMode; +export type FullscreenExitOutput = "transcript" | "resume-hint"; export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) @@ -91,6 +93,7 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; // per-model default thinking level overrides keyed by "provider/modelId" transport?: TransportSetting; // default: "auto" steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time"; @@ -99,7 +102,7 @@ export interface Settings { branchSummary?: BranchSummarySettings; retry?: RetrySettings; hideThinkingBlock?: boolean; - showCacheMissNotices?: boolean; // default: false - show transcript notices for significant prompt-cache misses + showCacheMissNotices?: boolean; // default: false - show prompt-cache miss and compaction cost notices externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion quietStartup?: boolean; @@ -119,6 +122,7 @@ export interface Settings { terminal?: TerminalSettings; images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) + defaultTools?: string[]; // Initial built-in tool selection doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels @@ -133,6 +137,7 @@ export interface Settings { httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it tuiMode?: TuiMode; // default: "regular" + fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode } @@ -187,9 +192,20 @@ export interface SettingsStorage { export interface SettingsError { scope: SettingsScope; + path?: string; error: Error; } +type SettingsPaths = Partial>; + +function toSettingsError(scope: SettingsScope, error: unknown, path?: string): SettingsError { + return { + scope, + ...(path ? { path } : {}), + error: error instanceof Error ? error : new Error(String(error)), + }; +} + export class FileSettingsStorage implements SettingsStorage { private globalSettingsPath: string; private projectSettingsPath: string; @@ -290,6 +306,7 @@ export class SettingsManager { private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors private writeQueue: Promise = Promise.resolve(); private errors: SettingsError[]; + private settingsPaths: SettingsPaths; private constructor( storage: SettingsStorage, @@ -299,6 +316,7 @@ export class SettingsManager { projectLoadError: Error | null = null, initialErrors: SettingsError[] = [], projectTrusted = true, + settingsPaths: SettingsPaths = {}, ) { this.storage = storage; this.globalSettings = initialGlobal; @@ -307,6 +325,7 @@ export class SettingsManager { this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; + this.settingsPaths = settingsPaths; this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } @@ -316,21 +335,35 @@ export class SettingsManager { agentDir: string = getAgentDir(), options: SettingsManagerCreateOptions = {}, ): SettingsManager { - const storage = new FileSettingsStorage(cwd, agentDir); - return SettingsManager.fromStorage(storage, options); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const storage = new FileSettingsStorage(resolvedCwd, resolvedAgentDir); + return SettingsManager.fromStorageWithPaths(storage, options, { + global: join(resolvedAgentDir, "settings.json"), + project: join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"), + }); } /** Create a SettingsManager from an arbitrary storage backend */ static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { + return SettingsManager.fromStorageWithPaths(storage, options); + } + + /** Create a manager while retaining optional file paths for reported storage errors. */ + private static fromStorageWithPaths( + storage: SettingsStorage, + options: SettingsManagerCreateOptions, + settingsPaths: SettingsPaths = {}, + ): SettingsManager { const projectTrusted = options.projectTrusted ?? true; const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); const initialErrors: SettingsError[] = []; if (globalLoad.error) { - initialErrors.push({ scope: "global", error: globalLoad.error }); + initialErrors.push(toSettingsError("global", globalLoad.error, settingsPaths.global)); } if (projectLoad.error) { - initialErrors.push({ scope: "project", error: projectLoad.error }); + initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project)); } return new SettingsManager( @@ -341,6 +374,7 @@ export class SettingsManager { projectLoad.error, initialErrors, projectTrusted, + settingsPaths, ); } @@ -366,7 +400,7 @@ export class SettingsManager { if (!content) { return {}; } - const settings = JSON.parse(content); + const settings = JSON.parse(stripBom(content)); return SettingsManager.migrateSettings(settings); } @@ -543,8 +577,7 @@ export class SettingsManager { } private recordError(scope: SettingsScope, error: unknown): void { - const normalizedError = error instanceof Error ? error : new Error(String(error)); - this.errors.push({ scope, error: normalizedError }); + this.errors.push(toSettingsError(scope, error, this.settingsPaths[scope])); } private clearModifiedScope(scope: SettingsScope): void { @@ -588,7 +621,7 @@ export class SettingsManager { ): void { this.storage.withLock(scope, (current) => { const currentFileSettings = current - ? SettingsManager.migrateSettings(JSON.parse(current) as Record) + ? SettingsManager.migrateSettings(JSON.parse(stripBom(current)) as Record) : {}; const mergedSettings: Settings = { ...currentFileSettings }; for (const field of modifiedFields) { @@ -752,6 +785,33 @@ export class SettingsManager { this.save(); } + getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined { + return this.settings.modelThinkingLevels?.[`${provider}/${modelId}`]; + } + + getAllModelThinkingLevels(): Record { + return { ...(this.settings.modelThinkingLevels ?? {}) }; + } + + setModelThinkingLevel(provider: string, modelId: string, level: ThinkingLevel): void { + if (!this.globalSettings.modelThinkingLevels) { + this.globalSettings.modelThinkingLevels = {}; + } + this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`] = level; + this.markModified("modelThinkingLevels"); + this.save(); + } + + removeModelThinkingLevel(provider: string, modelId: string): void { + if (!this.globalSettings.modelThinkingLevels) return; + delete this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`]; + if (Object.keys(this.globalSettings.modelThinkingLevels).length === 0) { + delete this.globalSettings.modelThinkingLevels; + } + this.markModified("modelThinkingLevels"); + this.save(); + } + getTransport(): TransportSetting { return this.settings.transport ?? "auto"; } @@ -1135,6 +1195,16 @@ export class SettingsManager { this.save(); } + getFullscreenExitOutput(): FullscreenExitOutput { + return this.settings.fullscreenExitOutput === "resume-hint" ? "resume-hint" : "transcript"; + } + + setFullscreenExitOutput(output: FullscreenExitOutput): void { + this.globalSettings.fullscreenExitOutput = output; + this.markModified("fullscreenExitOutput"); + this.save(); + } + getFullscreenScrollbar(): ScrollViewScrollbar { const mode = this.settings.fullscreenScrollbar; return mode === "always" || mode === "hidden" ? mode : "auto"; @@ -1176,6 +1246,11 @@ export class SettingsManager { return this.settings.enabledModels; } + getDefaultTools(): string[] | undefined { + const tools = this.settings.defaultTools; + return tools ? [...tools] : undefined; + } + setEnabledModels(patterns: string[] | undefined): void { this.globalSettings.enabledModels = patterns; this.markModified("enabledModels"); diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index c104c856454..464129b64b4 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -114,10 +114,10 @@ function validateName(name: string): string[] { /** * Validate description per Agent Skills spec. */ -function validateDescription(description: string | undefined): string[] { +function validateDescription(description: unknown): string[] { const errors: string[] = []; - if (!description || description.trim() === "") { + if (typeof description !== "string" || description.trim() === "") { errors.push("description is required"); } else if (description.length > MAX_DESCRIPTION_LENGTH) { errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); @@ -279,49 +279,69 @@ function loadSkillFromFile( source: string, ): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } { const diagnostics: ResourceDiagnostic[] = []; + const isDeclaredSkill = basename(filePath) === "SKILL.md"; + let rawContent: string; try { - const rawContent = readFileSync(filePath, "utf-8"); - const { frontmatter } = parseFrontmatter(rawContent); - const skillDir = dirname(filePath); - const parentDirName = basename(skillDir); - - // Validate description - const descErrors = validateDescription(frontmatter.description); - for (const error of descErrors) { - diagnostics.push({ type: "warning", message: error, path: filePath }); + rawContent = readFileSync(filePath, "utf-8"); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); + return { skill: null, diagnostics }; + } + + let frontmatter: SkillFrontmatter; + try { + ({ frontmatter } = parseFrontmatter(rawContent)); + } catch (error) { + if (isDeclaredSkill) { + const message = error instanceof Error ? error.message : "failed to parse skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); } + return { skill: null, diagnostics }; + } + + const description = frontmatter.description; + const hasDescription = typeof description === "string" && description.trim() !== ""; + if (!isDeclaredSkill && !hasDescription) { + return { skill: null, diagnostics }; + } - // Use name from frontmatter, or fall back to parent directory name - const name = frontmatter.name || parentDirName; + const skillDir = dirname(filePath); + const parentDirName = basename(skillDir); - // Validate name - const nameErrors = validateName(name); - for (const error of nameErrors) { - diagnostics.push({ type: "warning", message: error, path: filePath }); - } + // Validate description + const descErrors = validateDescription(description); + for (const error of descErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } - // Still load the skill even with warnings (unless description is completely missing) - if (!frontmatter.description || frontmatter.description.trim() === "") { - return { skill: null, diagnostics }; - } + // Use name from frontmatter, or fall back to parent directory name + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; - return { - skill: { - name, - description: frontmatter.description, - filePath, - baseDir: skillDir, - sourceInfo: createSkillSourceInfo(filePath, skillDir, source), - disableModelInvocation: frontmatter["disable-model-invocation"] === true, - }, - diagnostics, - }; - } catch (error) { - const message = error instanceof Error ? error.message : "failed to parse skill file"; - diagnostics.push({ type: "warning", message, path: filePath }); + // Validate name + const nameErrors = validateName(name); + for (const error of nameErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Still load the skill even with warnings, unless description is missing or empty. + if (!hasDescription) { return { skill: null, diagnostics }; } + + return { + skill: { + name, + description, + filePath, + baseDir: skillDir, + sourceInfo: createSkillSourceInfo(filePath, skillDir, source), + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; } /** diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 7204988fe77..13326d2dabd 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -19,6 +19,8 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "thinking", description: "Set thinking level", argumentHint: "" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, @@ -30,7 +32,6 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "fork", description: "Create a new fork from a previous user message" }, { name: "clone", description: "Duplicate the current session at the current position" }, - { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "trust", description: "Save project trust decision for future sessions" }, { name: "login", description: "Configure provider authentication", argumentHint: "" }, { name: "logout", description: "Remove provider authentication" }, diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 35f4ca40819..ec450678a35 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -66,7 +66,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { prompt += formatSkillsForPrompt(skills); } - prompt += `\nCurrent working directory: ${promptCwd}`; + prompt += `\nCurrent working directory: ${promptCwd}\n`; return prompt; } @@ -95,14 +95,21 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { }; const hasBash = tools.includes("bash"); + const hasPowerShell = tools.includes("powershell"); const hasGrep = tools.includes("grep"); const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); const hasRead = tools.includes("read"); // File exploration guidelines - if (hasBash && !hasGrep && !hasFind && !hasLs) { - addGuideline("Use bash for file operations like ls, rg, find"); + if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) { + if (hasBash && hasPowerShell) { + addGuideline("Use bash or PowerShell for file operations like listing, searching, and finding files"); + } else if (hasPowerShell) { + addGuideline("Use PowerShell for file operations like listing, searching, and finding files"); + } else { + addGuideline("Use bash for file operations like ls, rg, find"); + } } for (const guideline of promptGuidelines ?? []) { diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 1c245cbb4b0..b3139b85db7 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -12,9 +12,11 @@ import { getShellConfig, getShellEnv, killProcessTree, + type ShellConfig, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ExtensionContext, ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { OutputAccumulator } from "./output-accumulator.ts"; import { getTextOutput, invalidArgText, str } from "./render-utils.ts"; @@ -38,10 +40,15 @@ function resolveTimeoutMs(timeout: number | undefined): number | undefined { } const bashSchema = Type.Object({ - command: Type.String({ description: "Bash command to execute" }), + command: Type.String({ description: "Shell command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); +export const bashToolSystemPromptContribution = { + snippet: "Execute bash commands (ls, grep, find, etc.)", + guidelines: ["You can inspect PI_* environment variables for current model and session details."], +} as const; + export type BashToolInput = Static; export interface BashToolDetails { @@ -73,24 +80,19 @@ export interface BashOperations { ) => Promise<{ exitCode: number | null }>; } -/** - * Create bash operations using pi's built-in local shell execution backend. - * - * This is useful for extensions that intercept user_bash and still want pi's - * standard local shell behavior while wrapping or rewriting commands. - */ -export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { +/** Shared process execution used by the built-in shell tools. */ +export function createLocalShellOperations(shellName: string, resolveShellConfig: () => ShellConfig): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { const timeoutMs = resolveTimeoutMs(timeout); if (signal?.aborted) { throw new Error("aborted"); } - const shellConfig = getShellConfig(options?.shellPath); + const shellConfig = resolveShellConfig(); try { await fsAccess(cwd, constants.F_OK); } catch { - throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); + throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`); } const commandFromStdin = shellConfig.commandTransport === "stdin"; @@ -147,6 +149,16 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas }; } +/** + * Create bash operations using pi's built-in local shell execution backend. + * + * This is useful for extensions that intercept user_bash and still want pi's + * standard local shell behavior while wrapping or rewriting commands. + */ +export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { + return createLocalShellOperations("bash", () => getShellConfig(options?.shellPath)); +} + export interface BashSpawnContext { command: string; cwd: string; @@ -199,7 +211,7 @@ export interface BashToolOptions { const BASH_PREVIEW_LINES = 5; const BASH_UPDATE_THROTTLE_MS = 100; -type BashRenderState = { +export type BashRenderState = { startedAt: number | undefined; endedAt: number | undefined; interval: NodeJS.Timeout | undefined; @@ -223,12 +235,12 @@ function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } -function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { +function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): string { const command = str(args?.command); const timeout = args?.timeout as number | undefined; const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); - return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; + return theme.fg("toolTitle", theme.bold(`${prompt} ${commandDisplay}`)) + timeoutSuffix; } function rebuildBashResultRenderComponent( @@ -313,8 +325,19 @@ function rebuildBashResultRenderComponent( } } -export function createBashToolDefinition( +export interface ShellToolConfig { + name: string; + label: string; + shellName: string; + prompt: string; + promptSnippet: string; + promptGuidelines?: readonly string[]; + tempFilePrefix: string; +} + +export function createShellToolDefinition( cwd: string, + config: ShellToolConfig, options?: BashToolOptions, ): ToolDefinition { const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); @@ -322,14 +345,13 @@ export function createBashToolDefinition( const exposeSessionEnvironment = options?.exposeSessionEnvironment ?? true; const spawnHook = options?.spawnHook; return { - name: "bash", - label: "bash", - description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, - promptSnippet: "Execute bash commands (ls, grep, find, etc.)", - promptGuidelines: exposeSessionEnvironment - ? ["Inspect PI_* environment variables for current model and session details."] - : undefined, + name: config.name, + label: config.label, + description: `Execute a ${config.shellName} command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + promptSnippet: config.promptSnippet, + promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined, parameters: bashSchema, + constrainedSampling: getExperimentalToolSampling(), async execute( _toolCallId, { command, timeout }: { command: string; timeout?: number }, @@ -339,7 +361,7 @@ export function createBashToolDefinition( ) { const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook, exposeSessionEnvironment, ctx); - const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" }); + const output = new OutputAccumulator({ tempFilePrefix: config.tempFilePrefix }); let acceptingOutput = true; let updateTimer: NodeJS.Timeout | undefined; let updateDirty = false; @@ -463,7 +485,7 @@ export function createBashToolDefinition( state.endedAt = undefined; } const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatBashCall(args)); + text.setText(formatShellCall(args, config.prompt)); return text; }, renderResult(result, options, _theme, context) { @@ -494,6 +516,23 @@ export function createBashToolDefinition( }; } +const bashToolConfig: ShellToolConfig = { + name: "bash", + label: "bash", + shellName: "bash", + prompt: "$", + promptSnippet: bashToolSystemPromptContribution.snippet, + promptGuidelines: bashToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-bash", +}; + +export function createBashToolDefinition( + cwd: string, + options?: BashToolOptions, +): ToolDefinition { + return createShellToolDefinition(cwd, bashToolConfig, options); +} + export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { const definition = createBashToolDefinition(cwd, options); const tool = wrapToolDefinition(definition); diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 5a4d966b0e6..c79f5eb7b36 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -5,6 +5,7 @@ import * as Diff from "diff"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; +import { splitBom } from "../../utils/text.ts"; import { resolveToCwd } from "./path-utils.ts"; export function detectLineEnding(content: string): "\r\n" | "\n" { @@ -243,11 +244,6 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul }; } -/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ -export function stripBom(content: string): { bom: string; text: string } { - return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; -} - function countOccurrences(content: string, oldText: string): number { const fuzzyContent = normalizeForFuzzyMatch(content); const fuzzyOldText = normalizeForFuzzyMatch(oldText); @@ -535,7 +531,7 @@ export async function computeEditsDiff( const rawContent = await readFile(absolutePath, "utf-8"); // Strip BOM before matching (LLM won't include invisible BOM in oldText) - const { text: content } = stripBom(rawContent); + const { text: content } = splitBom(rawContent); const normalizedContent = normalizeToLF(content); const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index feaa7176f8f..eb124864c71 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -5,6 +5,8 @@ import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } import { type Static, Type } from "typebox"; import { renderDiff } from "../../modes/interactive/components/diff.ts"; import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import { splitBom } from "../../utils/text.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition } from "../extensions/types.ts"; import { applyEditsToNormalizedContent, @@ -17,7 +19,6 @@ import { generateUnifiedPatch, normalizeToLF, restoreLineEndings, - stripBom, } from "./edit-diff.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; @@ -52,12 +53,33 @@ const editSchema = Type.Object( {}, ); +export const editToolSystemPromptContribution = { + snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", + guidelines: [ + "Use edit for precise changes (edits[].oldText must match exactly)", + "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls", + "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.", + "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.", + ], +} as const; + export type EditToolInput = Static; type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown; }; +type SingleEditInput = { oldText: string; newText: string }; + +function isSingleEditInput(value: unknown): value is SingleEditInput { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const edit = value as Record; + return typeof edit.oldText === "string" && typeof edit.newText === "string"; +} + export interface EditToolDetails { /** Display-oriented diff of the changes made */ diff: string; @@ -98,12 +120,19 @@ function prepareEditArguments(input: unknown): EditToolInput { const args = input as Record; - // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array + // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array. + // Others send a single edit object instead of a one-element edits array. if (typeof args.edits === "string") { try { const parsed = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; + if (Array.isArray(parsed)) { + args.edits = parsed; + } else if (isSingleEditInput(parsed)) { + args.edits = [parsed]; + } } catch {} + } else if (isSingleEditInput(args.edits)) { + args.edits = [args.edits]; } const legacy = args as LegacyEditToolInput; @@ -294,15 +323,10 @@ export function createEditToolDefinition( label: "edit", description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.", - promptSnippet: - "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", - promptGuidelines: [ - "Use edit for precise changes (edits[].oldText must match exactly)", - "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls", - "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.", - "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.", - ], + promptSnippet: editToolSystemPromptContribution.snippet, + promptGuidelines: [...editToolSystemPromptContribution.guidelines], parameters: editSchema, + constrainedSampling: getExperimentalToolSampling(), renderShell: "self", prepareArguments: prepareEditArguments, async execute(_toolCallId, input: EditToolInput, signal?: AbortSignal, _onUpdate?, _ctx?) { @@ -337,7 +361,7 @@ export function createEditToolDefinition( throwIfAborted(); // Strip BOM before matching. The model will not include an invisible BOM in oldText. - const { bom, text: content } = stripBom(rawContent); + const { bom, text: content } = splitBom(rawContent); const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index ce6caf6727b..f3228ef77e7 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -34,6 +34,11 @@ const findSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), }); +export const findToolSystemPromptContribution = { + snippet: "Find files by glob pattern (respects .gitignore)", + guidelines: [], +} as const; + export type FindToolInput = Static; const DEFAULT_LIMIT = 1000; @@ -124,7 +129,7 @@ export function createFindToolDefinition( name: "find", label: "find", description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, - promptSnippet: "Find files by glob pattern (respects .gitignore)", + promptSnippet: findToolSystemPromptContribution.snippet, parameters: findSchema, async execute( _toolCallId, @@ -217,7 +222,7 @@ export function createFindToolDefinition( } // Default implementation uses fd. - const fdPath = await ensureTool("fd", true); + const fdPath = await ensureTool("fd"); if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index e4ed36d1b68..a65cab9dafe 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -35,6 +35,11 @@ const grepSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })), }); +export const grepToolSystemPromptContribution = { + snippet: "Search file contents for patterns (respects .gitignore)", + guidelines: [], +} as const; + export type GrepToolInput = Static; const DEFAULT_LIMIT = 100; @@ -129,7 +134,7 @@ export function createGrepToolDefinition( name: "grep", label: "grep", description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, - promptSnippet: "Search file contents for patterns (respects .gitignore)", + promptSnippet: grepToolSystemPromptContribution.snippet, parameters: grepSchema, async execute( _toolCallId, @@ -169,7 +174,7 @@ export function createGrepToolDefinition( (async () => { try { - const rgPath = await ensureTool("rg", true); + const rgPath = await ensureTool("rg"); if (!rgPath) { settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded"))); return; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index e55f914062d..5e8fd5b3691 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -42,6 +42,17 @@ export { type LsToolInput, type LsToolOptions, } from "./ls.ts"; +export { + createLocalPowerShellOperations, + createPowerShellTool, + createPowerShellToolDefinition, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, +} from "./powershell.ts"; export { createReadTool, createReadToolDefinition, @@ -75,17 +86,28 @@ import { createEditTool, createEditToolDefinition, type EditToolOptions } from " import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createPowerShellTool, createPowerShellToolDefinition, type PowerShellToolOptions } from "./powershell.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls"; +export const allToolNames: Set = new Set([ + "read", + "bash", + "powershell", + "edit", + "write", + "grep", + "find", + "ls", +]); export interface ToolsOptions { read?: ReadToolOptions; bash?: BashToolOptions; + powershell?: PowerShellToolOptions; write?: WriteToolOptions; edit?: EditToolOptions; grep?: GrepToolOptions; @@ -99,6 +121,8 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createReadToolDefinition(cwd, options?.read); case "bash": return createBashToolDefinition(cwd, options?.bash); + case "powershell": + return createPowerShellToolDefinition(cwd, options?.powershell); case "edit": return createEditToolDefinition(cwd, options?.edit); case "write": @@ -120,6 +144,8 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createReadTool(cwd, options?.read); case "bash": return createBashTool(cwd, options?.bash); + case "powershell": + return createPowerShellTool(cwd, options?.powershell); case "edit": return createEditTool(cwd, options?.edit); case "write": @@ -157,6 +183,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R return { read: createReadToolDefinition(cwd, options?.read), bash: createBashToolDefinition(cwd, options?.bash), + powershell: createPowerShellToolDefinition(cwd, options?.powershell), edit: createEditToolDefinition(cwd, options?.edit), write: createWriteToolDefinition(cwd, options?.write), grep: createGrepToolDefinition(cwd, options?.grep), @@ -187,6 +214,7 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record; const DEFAULT_LIMIT = 500; @@ -101,7 +106,7 @@ export function createLsToolDefinition( name: "ls", label: "ls", description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, - promptSnippet: "List directory contents", + promptSnippet: lsToolSystemPromptContribution.snippet, parameters: lsSchema, async execute( _toolCallId, diff --git a/packages/coding-agent/src/core/tools/powershell.ts b/packages/coding-agent/src/core/tools/powershell.ts new file mode 100644 index 00000000000..ac8a491170e --- /dev/null +++ b/packages/coding-agent/src/core/tools/powershell.ts @@ -0,0 +1,67 @@ +import { getPowerShellConfig } from "../../utils/shell.ts"; +import { + type BashOperations, + type BashSpawnContext, + type BashSpawnHook, + type BashToolDetails, + type BashToolInput, + type BashToolOptions, + type createBashTool, + createLocalShellOperations, + createShellToolDefinition, + type ShellToolConfig, +} from "./bash.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; + +const UTF8_OUTPUT_PREFIX = "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n"; + +export const powershellToolSystemPromptContribution = { + snippet: "Execute PowerShell commands", + guidelines: ["You can inspect PI_* environment variables for current model and session details."], +} as const; + +export type PowerShellOperations = BashOperations; +export type PowerShellSpawnContext = BashSpawnContext; +export type PowerShellSpawnHook = BashSpawnHook; +export type PowerShellToolDetails = BashToolDetails; +export type PowerShellToolInput = BashToolInput; + +export interface PowerShellToolOptions + extends Pick {} + +export function createLocalPowerShellOperations(): PowerShellOperations { + const operations = createLocalShellOperations("PowerShell", getPowerShellConfig); + return { + exec: (command, cwd, options) => operations.exec(`${UTF8_OUTPUT_PREFIX}${command}`, cwd, options), + }; +} + +const powershellToolConfig: ShellToolConfig = { + name: "powershell", + label: "powershell", + shellName: "PowerShell", + prompt: "PS>", + promptSnippet: powershellToolSystemPromptContribution.snippet, + promptGuidelines: powershellToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-powershell", +}; + +export function createPowerShellToolDefinition( + cwd: string, + options?: PowerShellToolOptions, +): ReturnType { + return createShellToolDefinition(cwd, powershellToolConfig, { + ...options, + operations: options?.operations ?? createLocalPowerShellOperations(), + }); +} + +export function createPowerShellTool(cwd: string, options?: PowerShellToolOptions): ReturnType { + const definition = createPowerShellToolDefinition(cwd, options); + const tool = wrapToolDefinition(definition); + Object.assign(tool, { + promptSnippet: definition.promptSnippet, + promptGuidelines: definition.promptGuidelines, + }); + return tool; +} diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 9442e98737b..1f766610175 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -11,6 +11,7 @@ import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/inte import { processImage } from "../../utils/image-process.ts"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts"; import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.ts"; @@ -23,6 +24,11 @@ const readSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), }); +export const readToolSystemPromptContribution = { + snippet: "Read file contents", + guidelines: ["Use read to examine files instead of cat or sed."], +} as const; + export type ReadToolInput = Static; export interface ReadToolDetails { @@ -210,9 +216,10 @@ export function createReadToolDefinition( name: "read", label: "read", description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, - promptSnippet: "Read file contents", - promptGuidelines: ["Use read to examine files instead of cat or sed."], + promptSnippet: readToolSystemPromptContribution.snippet, + promptGuidelines: [...readToolSystemPromptContribution.guidelines], parameters: readSchema, + constrainedSampling: getExperimentalToolSampling(), async execute( _toolCallId, { path, offset, limit }: { path: string; offset?: number; limit?: number }, diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 12668e61a76..d25435b0b4e 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -5,6 +5,7 @@ import { dirname } from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; @@ -16,6 +17,11 @@ const writeSchema = Type.Object({ content: Type.String({ description: "Content to write to the file" }), }); +export const writeToolSystemPromptContribution = { + snippet: "Create or overwrite files", + guidelines: ["Use write only for new files or complete rewrites."], +} as const; + export type WriteToolInput = Static; /** @@ -188,9 +194,10 @@ export function createWriteToolDefinition( label: "write", description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", - promptSnippet: "Create or overwrite files", - promptGuidelines: ["Use write only for new files or complete rewrites."], + promptSnippet: writeToolSystemPromptContribution.snippet, + promptGuidelines: [...writeToolSystemPromptContribution.guidelines], parameters: writeSchema, + constrainedSampling: getExperimentalToolSampling(), async execute( _toolCallId, { path, content }: { path: string; content: string }, diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 9c494b47a39..0a560f7f92a 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME } from "../config.ts"; import { canonicalizePath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; export type ProjectTrustDecision = boolean | null; @@ -101,7 +102,7 @@ function readTrustFile(path: string): TrustFile { let parsed: unknown; try { - parsed = JSON.parse(readFileSync(path, "utf-8")); + parsed = JSON.parse(stripBom(readFileSync(path, "utf-8"))); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read trust store ${path}: ${message}`); diff --git a/packages/coding-agent/src/extensions/llama/client.ts b/packages/coding-agent/src/extensions/llama/client.ts index 45cd8f3a650..c071f518ccc 100644 --- a/packages/coding-agent/src/extensions/llama/client.ts +++ b/packages/coding-agent/src/extensions/llama/client.ts @@ -28,6 +28,10 @@ export interface LlamaModelsResponse { object?: string; } +export interface LlamaServerProps { + models_autoload?: boolean; +} + export interface LlamaModelEvent { model: string; event: string; @@ -189,6 +193,13 @@ export class LlamaClient { return data; } + async props(options: { signal?: AbortSignal } = {}): Promise { + const payload = await this.request("/props", { signal: options.signal }); + if (typeof payload !== "object" || payload === null) return {}; + const { models_autoload: modelsAutoload } = payload as Record; + return typeof modelsAutoload === "boolean" ? { models_autoload: modelsAutoload } : {}; + } + async load(model: string, signal?: AbortSignal): Promise { await this.request("/models/load", { method: "POST", body: JSON.stringify({ model }), signal }); } diff --git a/packages/coding-agent/src/extensions/llama/index.ts b/packages/coding-agent/src/extensions/llama/index.ts index 0cf72141f00..1e3b9ce916f 100644 --- a/packages/coding-agent/src/extensions/llama/index.ts +++ b/packages/coding-agent/src/extensions/llama/index.ts @@ -53,6 +53,8 @@ export default function llamaExtension(pi: ExtensionAPI): void { provider.setCatalog(current, client.serverUrl); const result = await ctx.modelRegistry.refresh({ providers: [LLAMA_PROVIDER_ID], + // /llama already contacted the configured llama.cpp server, so keep this refresh live even in PI_OFFLINE. + allowNetwork: true, signal, }); if (result.aborted) throw new Error("Model catalog refresh timed out."); diff --git a/packages/coding-agent/src/extensions/llama/provider.ts b/packages/coding-agent/src/extensions/llama/provider.ts index 518007cb464..6a685938d08 100644 --- a/packages/coding-agent/src/extensions/llama/provider.ts +++ b/packages/coding-agent/src/extensions/llama/provider.ts @@ -25,6 +25,27 @@ async function resolveServerUrl( return configured ? normalizeLlamaServerUrl(configured) : undefined; } +function modelIsSelectable(model: LlamaModelInfo, routerAutoload: boolean): boolean { + if (model.status.value === "loaded") return true; + // llama.cpp reports idle-slept models as "sleeping"; requests wake them automatically. + if (model.status.value === "sleeping") return true; + // Unloaded presets are routable only when llama.cpp router autoload can load them on first use. + return routerAutoload && model.status.value === "unloaded" && !model.status.failed && model.source === "preset"; +} + +async function routerAutoloadEnabled( + client: LlamaClient, + catalog: readonly LlamaModelInfo[], + signal: AbortSignal, +): Promise { + if (!catalog.some((model) => model.status.value === "unloaded" && model.source === "preset")) return false; + try { + return (await client.props({ signal })).models_autoload === true; + } catch { + return false; + } +} + function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-completions"> { const reportedContextWindow = model.meta?.n_ctx ?? model.meta?.n_ctx_train; const contextWindow = reportedContextWindow && reportedContextWindow > 0 ? reportedContextWindow : 128000; @@ -52,14 +73,20 @@ function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-comp export interface LlamaProviderController { provider: Provider<"openai-completions">; - setCatalog(models: readonly LlamaModelInfo[], serverUrl: string): void; + setCatalog(models: readonly LlamaModelInfo[], serverUrl: string, options?: { routerAutoload?: boolean }): void; } export function createLlamaProvider(): LlamaProviderController { let models: readonly Model<"openai-completions">[] = []; - const setCatalog = (catalog: readonly LlamaModelInfo[], serverUrl: string): void => { - models = catalog.filter((model) => model.status.value === "loaded").map((model) => toPiModel(model, serverUrl)); + const setCatalog = ( + catalog: readonly LlamaModelInfo[], + serverUrl: string, + options: { routerAutoload?: boolean } = {}, + ): void => { + models = catalog + .filter((model) => modelIsSelectable(model, options.routerAutoload === true)) + .map((model) => toPiModel(model, serverUrl)); }; const provider: Provider<"openai-completions"> = { @@ -130,10 +157,13 @@ export function createLlamaProvider(): LlamaProviderController { if (!context.allowNetwork || context.signal.aborted || context.credential?.type !== "api_key") return; const serverUrl = credentialServerUrl(context.credential); if (!serverUrl) return; - const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal }); + const client = new LlamaClient(serverUrl, context.credential.key); + const catalog = await client.list({ signal: context.signal }); + if (context.signal.aborted) return; + const routerAutoload = await routerAutoloadEnabled(client, catalog, context.signal); if (context.signal.aborted) return; const refreshed = catalog - .filter((model) => model.status.value === "loaded") + .filter((model) => modelIsSelectable(model, routerAutoload)) .map((model) => toPiModel(model, serverUrl)); await context.publish({ persist: { models: refreshed, checkedAt: Date.now() }, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d05c2af462..52d8f9b0627 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -107,6 +107,7 @@ export type { MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, + PowerShellToolCallEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -159,6 +160,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, @@ -218,6 +220,7 @@ export { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -252,6 +255,7 @@ export { export { type CompactionSettings, type DefaultProjectTrust, + type FullscreenExitOutput, type ImageSettings, type PackageSource, type RetrySettings, @@ -284,7 +288,9 @@ export { createFindToolDefinition, createGrepToolDefinition, createLocalBashOperations, + createLocalPowerShellOperations, createLsToolDefinition, + createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, @@ -306,6 +312,12 @@ export { type LsToolDetails, type LsToolInput, type LsToolOptions, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, @@ -403,5 +415,6 @@ export { copyToClipboard } from "./utils/clipboard.ts"; export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; export { convertToPng } from "./utils/image-convert.ts"; export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; +export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.ts"; // Shell utilities -export { getShellConfig } from "./utils/shell.ts"; +export { getPowerShellConfig, getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a100c7c7fef..23b4d42e2e3 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -8,23 +8,31 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; -import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; +import { type Args, type Mode, normalizeSessionName, parseArgs, printHelp } from "./cli/args.ts"; import { - type CredentialPrintCommand, - CredentialPrintError, - isCredentialPrintHelp, - parseCredentialPrintCommand, - printCredentialPrintHelp, - resolveCredentialForPrint, - validateCredentialPrintArgs, -} from "./cli/credential-print.ts"; + type AuthCheckResult, + checkProviderAuth, + createAuthCheckModelRuntime, + getProviderCredential, +} from "./cli/auth-check.ts"; +import { + type AuthCommand, + AuthCommandError, + getAuthCommandName, + getAuthCommandUsage, + isAuthCommandHelp, + parseAuthCommand, + printAuthCommandHelp, + validateAuthCommandArgs, +} from "./cli/auth-command.ts"; +import { resolveCredentialForPrint } from "./cli/credential-print.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; -import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; +import { APP_NAME, ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { type AgentSessionRuntimeDiagnostic, @@ -32,6 +40,7 @@ import { createAgentSessionServices, } from "./core/agent-session-services.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; +import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; @@ -47,6 +56,7 @@ import { type SessionCwdIssue, } from "./core/session-cwd.ts"; import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; +import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/settings-diagnostics.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; @@ -54,11 +64,11 @@ import { builtInExtensions } from "./extensions/index.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; +import { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; -const EXTENSION_LOAD_FAILURE_HINT = 'Hint: Start without extensions using "pi -ne".'; +const EXTENSION_LOAD_FAILURE_HINT = `Hint: Start without extensions using "${APP_NAME} -ne".`; /** * Read all content from piped stdin. @@ -83,16 +93,6 @@ async function readPipedStdin(): Promise { }); } -function collectSettingsDiagnostics( - settingsManager: SettingsManager, - context: string, -): AgentSessionRuntimeDiagnostic[] { - return settingsManager.drainErrors().map(({ scope, error }) => ({ - type: "warning", - message: `(${context}, ${scope} settings) ${error.message}`, - })); -} - function reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void { for (const diagnostic of diagnostics) { const color = diagnostic.type === "error" ? chalk.red : diagnostic.type === "warning" ? chalk.yellow : chalk.dim; @@ -127,17 +127,17 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean { return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); } -async function runCredentialPrintCommand(args: string[]): Promise { - if (isCredentialPrintHelp(args)) { - printCredentialPrintHelp(); +async function runAuthCommand(args: string[]): Promise { + if (isAuthCommandHelp(args)) { + printAuthCommandHelp(); return true; } - let command: CredentialPrintCommand | undefined; + let command: AuthCommand | undefined; try { - command = parseCredentialPrintCommand(args); + command = parseAuthCommand(args); } catch (error) { - const message = error instanceof CredentialPrintError ? error.message : "Failed to parse auth command"; + const message = error instanceof AuthCommandError ? error.message : "Failed to parse auth command"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; return true; @@ -145,30 +145,62 @@ async function runCredentialPrintCommand(args: string[]): Promise { if (!command) return false; const parsed = parseArgs(command.args); - if (parsed.diagnostics.length > 0) { - for (const diagnostic of parsed.diagnostics) { - console.error(chalk.red(`Error: ${diagnostic.message}`)); - } + if (parsed.unknownFlags.size > 0) { + const option = parsed.unknownFlags.keys().next().value; + console.error(chalk.red(`Unknown option --${option} for "${getAuthCommandName(command.kind)}".`)); + console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getAuthCommandUsage(command.kind)}".`)); process.exitCode = 1; return true; } - try { - validateCredentialPrintArgs(parsed); - const signal = AbortSignal.timeout(15_000); - const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); - const credential = await resolveCredentialForPrint( - parsed, - modelRuntime, - command.kind, - command.minExpiryMs, - signal, - ); - process.stdout.write(`${credential}\n`); + if (parsed.diagnostics.length > 0) { + throw new AuthCommandError(parsed.diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + } + if (command.kind !== "check") { + const signal = AbortSignal.timeout(15_000); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); + const credential = await resolveCredentialForPrint( + parsed, + modelRuntime, + command.kind, + command.minExpiryMs, + signal, + ); + process.stdout.write(`${credential}\n`); + return true; + } + + const requestedAuth = validateAuthCommandArgs(parsed, command.kind); + let result: AuthCheckResult; + let credential: string | undefined; + try { + const credentials = command.noRefresh ? new ReadOnlyAuthStorage() : AuthStorage.create(); + const modelRuntime = await createAuthCheckModelRuntime(credentials); + result = await checkProviderAuth(parsed, modelRuntime, { refresh: !command.noRefresh }); + if (command.credentials && result.status === "ready") { + credential = await getProviderCredential(result.provider, modelRuntime, credentials, { + refresh: !command.noRefresh, + }); + if (!credential) { + result = { status: "not_ready", provider: result.provider, reason: "credential_not_available" }; + } + } + } catch { + result = { + status: "invalid", + provider: requestedAuth.provider ?? requestedAuth.model!, + reason: "invalid_state", + }; + } + const output = command.json + ? JSON.stringify({ ...result, ...(credential ? { credentials: credential } : {}) }) + : (credential ?? result.status); + process.stdout.write(`${output}\n`); + process.exitCode = result.status === "ready" ? 0 : result.status === "not_ready" ? 1 : 2; } catch (error) { - const message = error instanceof CredentialPrintError ? error.message : "Failed to resolve credential"; + const message = error instanceof AuthCommandError ? error.message : "Failed to resolve credential"; console.error(chalk.red(`Error: ${message}`)); - process.exitCode = 1; + process.exitCode = command.kind === "check" ? 2 : 1; } return true; } @@ -316,7 +348,7 @@ function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string, } } -async function createSessionManager( +export async function createSessionManager( parsed: Args, cwd: string, sessionDir: string | undefined, @@ -534,9 +566,14 @@ export async function main(args: string[], options?: MainOptions) { process.env.PI_SKIP_VERSION_CHECK = "1"; } + if (await runAuthCommand(args)) { + return; + } + if (process.platform === "win32") { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } + cleanupManagedInstall(); const cwd = process.cwd(); const agentDir = getAgentDir(); @@ -561,10 +598,6 @@ export async function main(args: string[], options?: MainOptions) { return; } - if (await runCredentialPrintCommand(args)) { - return; - } - const parsed = parseArgs(args); if (parsed.diagnostics.length > 0) { for (const d of parsed.diagnostics) { @@ -615,7 +648,7 @@ export async function main(args: string[], options?: MainOptions) { time("runMigrations"); const startupSettingsManager = SettingsManager.create(cwd, agentDir); - reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + const startupSettingsDiagnostics = collectSettingsDiagnostics(startupSettingsManager); // Experimental first-time setup: theme choice and analytics opt-in. // Runs before any runtime services are created so the chosen settings apply everywhere. @@ -624,6 +657,10 @@ export async function main(args: string[], options?: MainOptions) { time("firstTimeSetup"); } + if (appMode === "interactive" && parsed.useTheme !== undefined) { + startupSettingsManager.applyOverrides({ theme: parsed.useTheme }); + } + // Decide the final runtime cwd before creating cwd-bound runtime services. // --session and --resume may select a session from another project, so project-local // settings, resources, provider registrations, and models must be resolved only after @@ -649,8 +686,8 @@ export async function main(args: string[], options?: MainOptions) { } } if (parsed.name !== undefined) { - const name = parsed.name.trim(); - if (!name) { + const name = normalizeSessionName(parsed.name); + if (name === undefined) { console.error(chalk.red("Error: --name requires a non-empty value")); process.exit(1); } @@ -739,7 +776,7 @@ export async function main(args: string[], options?: MainOptions) { const diagnostics: AgentSessionRuntimeDiagnostic[] = [ ...projectTrustDiagnostics, ...services.diagnostics, - ...collectSettingsDiagnostics(settingsManager, "runtime creation"), + ...collectSettingsDiagnostics(settingsManager), ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ type: "error" as const, message: `Failed to load extension "${path}": ${error}`, @@ -811,6 +848,7 @@ export async function main(args: string[], options?: MainOptions) { configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); if (parsed.help) { + reportDiagnostics(startupSettingsDiagnostics); const extensionFlags = resourceLoader .getExtensions() .extensions.flatMap((extension) => Array.from(extension.flags.values())); @@ -819,6 +857,7 @@ export async function main(args: string[], options?: MainOptions) { } if (parsed.listModels !== undefined) { + reportDiagnostics(startupSettingsDiagnostics); const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; await listModels(modelRuntime, searchPattern, AbortSignal.timeout(15_000)); process.exit(0); @@ -849,8 +888,12 @@ export async function main(args: string[], options?: MainOptions) { } time("resolveModelScope"); - reportDiagnostics(runtime.diagnostics); - if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) { + const startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]); + const hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === "error"); + if (appMode !== "interactive" || hasRuntimeErrors) { + reportDiagnostics(startupDiagnostics); + } + if (hasRuntimeErrors) { if (runtime.diagnostics.some((diagnostic) => diagnostic.message.includes("Failed to load extension"))) { console.error(chalk.yellow(EXTENSION_LOAD_FAILURE_HINT)); } @@ -885,6 +928,7 @@ export async function main(args: string[], options?: MainOptions) { } else if (appMode === "interactive") { const interactiveMode = new InteractiveMode(runtime, { migratedProviders, + startupDiagnostics, modelFallbackMessage, autoTrustOnReloadCwd, initialMessage, @@ -892,6 +936,7 @@ export async function main(args: string[], options?: MainOptions) { initialMessages: parsed.messages, verbose: parsed.verbose, tuiMode: parsed.tuiMode, + initialThemeSetting: parsed.useTheme, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 39aeea0438d..e1aa7941873 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, w import { dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts"; import { migrateKeybindingsConfig } from "./core/keybindings.ts"; +import { stripBom } from "./utils/text.ts"; const MIGRATION_GUIDE_URL = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; @@ -33,7 +34,7 @@ export function migrateAuthToAuthJson(): string[] { // Migrate oauth.json if (existsSync(oauthPath)) { try { - const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); + const oauth = JSON.parse(stripBom(readFileSync(oauthPath, "utf-8"))); for (const [provider, cred] of Object.entries(oauth)) { migrated[provider] = { type: "oauth", ...(cred as object) }; providers.push(provider); @@ -48,7 +49,7 @@ export function migrateAuthToAuthJson(): string[] { if (existsSync(settingsPath)) { try { const content = readFileSync(settingsPath, "utf-8"); - const settings = JSON.parse(content); + const settings = JSON.parse(stripBom(content)); if (settings.apiKeys && typeof settings.apiKeys === "object") { for (const [provider, key] of Object.entries(settings.apiKeys)) { if (!migrated[provider] && typeof key === "string") { @@ -159,7 +160,7 @@ function migrateKeybindingsConfigFile(): void { if (!existsSync(configPath)) return; try { - const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown; + const parsed = JSON.parse(stripBom(readFileSync(configPath, "utf-8"))) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return; } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 6f2668a491b..d1f7788b179 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -5,12 +5,13 @@ import { fuzzyFilter, getKeybindings, Input, + matchesKey, Spacer, Text, type TUI, } from "@earendil-works/pi-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; -import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { refreshModelCatalogs } from "../model-catalog-refresh.ts"; import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -27,6 +28,11 @@ interface ScopedModelItem { thinkingLevel?: string; } +interface DefaultModelReference { + provider: string; + id: string; +} + type ModelScope = "all" | "scoped"; /** @@ -51,15 +57,16 @@ export class ModelSelectorComponent extends Container implements Focusable { private filteredModels: ModelItem[] = []; private selectedIndex: number = 0; private currentModel?: Model; - private settingsManager: SettingsManager; private modelRuntime: ModelRuntime; private onSelectCallback: (model: Model) => void; + private onSelectAsDefaultCallback?: (model: Model) => void; private onCancelCallback: () => void; private errorMessage?: string; private refreshStatusMessage = "Refreshing model catalogs…"; private refreshStatusSuccess = false; private tui: TUI; private scopedModels: ReadonlyArray; + private defaultModel?: DefaultModelReference; private scope: ModelScope = "all"; private scopeText?: Text; private scopeHintText?: Text; @@ -70,22 +77,24 @@ export class ModelSelectorComponent extends Container implements Focusable { constructor( tui: TUI, currentModel: Model | undefined, - settingsManager: SettingsManager, modelRuntime: ModelRuntime, scopedModels: ReadonlyArray, onSelect: (model: Model) => void, onCancel: () => void, initialSearchInput?: string, + onSelectAsDefault?: (model: Model) => void, + defaultModel?: DefaultModelReference, ) { super(); this.tui = tui; this.currentModel = currentModel; - this.settingsManager = settingsManager; this.modelRuntime = modelRuntime; this.scopedModels = scopedModels; + this.defaultModel = defaultModel; this.scope = scopedModels.length > 0 ? "scoped" : "all"; this.onSelectCallback = onSelect; + this.onSelectAsDefaultCallback = onSelectAsDefault; this.onCancelCallback = onCancel; // Add top border @@ -125,6 +134,13 @@ export class ModelSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); + // Hint + if (this.onSelectAsDefaultCallback) { + this.addChild( + new Text(theme.fg("dim", " Enter to select \u00b7 Ctrl+S to set as default \u00b7 Esc to cancel"), 0, 0), + ); + } + // Add bottom border this.addChild(new DynamicBorder()); @@ -167,7 +183,7 @@ export class ModelSelectorComponent extends Container implements Focusable { this.refreshAbortController.abort(); }, timeoutMs); try { - const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal }); + const result = await refreshModelCatalogs(this.modelRuntime, this.refreshAbortController.signal); if (this.closed) return; this.refreshStatusMessage = ""; if (result.aborted && timedOut) { @@ -208,12 +224,16 @@ export class ModelSelectorComponent extends Container implements Focusable { private sortModels(models: ModelItem[]): ModelItem[] { const sorted = [...models]; - // Sort: current model first, then by provider + // Sort: current model first, default model second, then by provider. sorted.sort((a, b) => { const aIsCurrent = modelsAreEqual(this.currentModel, a.model); const bIsCurrent = modelsAreEqual(this.currentModel, b.model); if (aIsCurrent && !bIsCurrent) return -1; if (!aIsCurrent && bIsCurrent) return 1; + const aIsDefault = this.isDefaultModel(a.model); + const bIsDefault = this.isDefaultModel(b.model); + if (aIsDefault && !bIsDefault) return -1; + if (!aIsDefault && bIsDefault) return 1; return a.provider.localeCompare(b.provider); }); return sorted; @@ -229,6 +249,15 @@ export class ModelSelectorComponent extends Container implements Focusable { return keyHint("tui.input.tab", "scope") + theme.fg("muted", " (all/scoped)"); } + private isDefaultModel(model: Model): boolean { + return this.defaultModel?.provider === model.provider && this.defaultModel.id === model.id; + } + + private isDefaultSearch(query: string): boolean { + const normalized = query.trim().toLowerCase(); + return normalized.length > 0 && "default".startsWith(normalized); + } + private setScope(scope: ModelScope): void { if (this.scope === scope) return; this.scope = scope; @@ -242,11 +271,24 @@ export class ModelSelectorComponent extends Container implements Focusable { } private filterModels(query: string): void { - this.filteredModels = query - ? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) => - getModelSelectorSearchText({ id, provider, name: model.name }), - ) - : this.activeModels; + if (query) { + const filtered = fuzzyFilter(this.activeModels, query, (item) => { + const defaultText = this.isDefaultModel(item.model) ? " default" : ""; + return `${getModelSelectorSearchText({ id: item.id, provider: item.provider, name: item.model.name })}${defaultText}`; + }); + if (this.isDefaultSearch(query)) { + const defaultItems = this.activeModels.filter((item) => this.isDefaultModel(item.model)); + const defaultKeys = new Set(defaultItems.map((item) => `${item.provider}\0${item.id}`)); + this.filteredModels = [ + ...defaultItems, + ...filtered.filter((item) => !defaultKeys.has(`${item.provider}\0${item.id}`)), + ]; + } else { + this.filteredModels = filtered; + } + } else { + this.filteredModels = this.activeModels; + } // When filtering by a query, move the selector to the top row so the best // match is highlighted. When the query is cleared, keep the current position // clamped to the (restored) list length. @@ -271,6 +313,8 @@ export class ModelSelectorComponent extends Container implements Focusable { const isSelected = i === this.selectedIndex; const isCurrent = modelsAreEqual(this.currentModel, item.model); + const isDefault = this.isDefaultModel(item.model); + const defaultBadge = isDefault ? theme.fg("muted", " · default") : ""; let line = ""; if (isSelected) { @@ -278,12 +322,12 @@ export class ModelSelectorComponent extends Container implements Focusable { const modelText = `${item.id}`; const providerBadge = theme.fg("muted", `[${item.provider}]`); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`; + line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${defaultBadge}${checkmark}`; } else { const modelText = ` ${item.id}`; const providerBadge = theme.fg("muted", `[${item.provider}]`); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - line = `${modelText} ${providerBadge}${checkmark}`; + line = `${modelText} ${providerBadge}${defaultBadge}${checkmark}`; } this.listContainer.addChild(new Text(line, 0, 0)); @@ -353,6 +397,14 @@ export class ModelSelectorComponent extends Container implements Focusable { this.dispose(); this.onCancelCallback(); } + // Ctrl+S — select and save as default + else if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefaultCallback) { + const selectedModel = this.filteredModels[this.selectedIndex]; + if (selectedModel) { + this.dispose(); + this.onSelectAsDefaultCallback(selectedModel.model); + } + } // Pass everything else to search input else { this.searchInput.handleInput(keyData); @@ -362,8 +414,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private handleSelect(model: Model): void { this.dispose(); - // Save as new default - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.onSelectCallback(model); } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index c0c49f544f5..6dac4def309 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,13 +1,11 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Transport } from "@earendil-works/pi-ai"; +import { getSupportedThinkingLevels, type Model, type Transport } from "@earendil-works/pi-ai"; import { type Component, Container, getCapabilities, type ScrollViewScrollbar, type SelectItem, - SelectList, - type SelectListLayoutOptions, type SettingItem, SettingsList, Spacer, @@ -16,24 +14,17 @@ import { import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; import type { DefaultProjectTrust, + FullscreenExitOutput, MermaidRenderingMode, TuiMode, WarningSettings, } from "../../../core/settings-manager.ts"; -import { - getSelectListTheme, - getSettingsListTheme, - parseAutoThemeSetting, - type TerminalTheme, - theme, -} from "../theme/theme.ts"; +import { getSettingsListTheme, parseAutoThemeSetting, type TerminalTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; +import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settings-submenu.ts"; -const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { - minPrimaryColumnWidth: 12, - maxPrimaryColumnWidth: 32, -}; +const MODEL_PICKER_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 46 }; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", @@ -57,6 +48,9 @@ const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( export interface SettingsConfig { autoCompact: boolean; + defaultModel: string; + currentModel?: Model; + availableDefaultModels: readonly Model[]; showImages: boolean; imageWidthCells: number; autoResizeImages: boolean; @@ -68,6 +62,7 @@ export interface SettingsConfig { httpIdleTimeoutMs: number; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; + modelThinkingLevels: Record; currentTheme: string; terminalTheme: TerminalTheme; availableThemes: string[]; @@ -87,6 +82,7 @@ export interface SettingsConfig { clearOnShrink: boolean; showTerminalProgress: boolean; tuiMode: TuiMode; + fullscreenExitOutput: FullscreenExitOutput; fullscreenScrollbar: ScrollViewScrollbar; warnings: WarningSettings; } @@ -102,7 +98,8 @@ export interface SettingsCallbacks { onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; - onThinkingLevelChange: (level: ThinkingLevel) => void; + onModelThinkingLevelChange: (provider: string, modelId: string, level: ThinkingLevel) => void; + onModelThinkingLevelRemove: (provider: string, modelId: string) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; @@ -121,6 +118,7 @@ export interface SettingsCallbacks { onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; onTuiModeChange: (mode: TuiMode) => void; + onFullscreenExitOutputChange: (output: FullscreenExitOutput) => void; onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void; onWarningsChange: (warnings: WarningSettings) => void; onCancel: () => void; @@ -171,68 +169,24 @@ class WarningSettingsSubmenu extends Container { } } -class SelectSubmenu extends Container { - private selectList: SelectList; - - constructor( - title: string, - description: string, - options: SelectItem[], - currentValue: string, - onSelect: (value: string) => void, - onCancel: () => void, - onSelectionChange?: (value: string) => void, - ) { - super(); - - // Title - this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); - - // Description - if (description) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", description), 0, 0)); - } - - // Spacer - this.addChild(new Spacer(1)); - - // Select list - this.selectList = new SelectList( - options, - Math.min(options.length, 10), - getSelectListTheme(), - SETTINGS_SUBMENU_SELECT_LIST_LAYOUT, - ); - - // Pre-select current value - const currentIndex = options.findIndex((o) => o.value === currentValue); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value); - }; - - this.selectList.onCancel = onCancel; +const CLEAR_OVERRIDE_VALUE = "__clear__"; - if (onSelectionChange) { - this.selectList.onSelectionChange = (item) => { - onSelectionChange(item.value); - }; - } +function modelSettingKey(model: Model): string { + return `${model.provider}/${model.id}`; +} - this.addChild(this.selectList); +function modelDisplayLabel(model: Model): string { + return `${model.id} [${model.provider}]`; +} - // Hint - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); - } +function modelThinkingOverridesSummary(overrides: Record): string { + const count = Object.keys(overrides).length; + if (count === 0) return "none"; + return `${count} configured`; +} - handleInput(data: string): void { - this.selectList.handleInput(data); - } +function modelItemLabel(model: Model): string { + return `${model.id} ${theme.fg("muted", `[${model.provider}]`)}`; } function themeItems(availableThemes: string[]): SelectItem[] { @@ -489,7 +443,14 @@ export class SettingsSelectorComponent extends Container { const supportsImages = getCapabilities().images; const followUpKey = keyDisplayText("app.message.followUp"); + const cycleThinkingKey = keyDisplayText("app.thinking.cycle"); let currentWarnings = { ...config.warnings }; + const currentModelThinkingLevels = { ...config.modelThinkingLevels }; + const defaultModelByValue = new Map( + config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), + ); + const currentDefaultModelKey = defaultModelByValue.has(config.defaultModel) ? config.defaultModel : undefined; + const currentModelKey = config.currentModel ? modelSettingKey(config.currentModel) : undefined; const items: SettingItem[] = [ { @@ -546,7 +507,7 @@ export class SettingsSelectorComponent extends Container { { id: "cache-miss-notices", label: "Cache miss notices", - description: "Show transcript notices for significant prompt-cache misses", + description: "Show transcript notices for significant prompt-cache misses and compaction costs", currentValue: config.showCacheMissNotices ? "true" : "false", values: ["true", "false"], }, @@ -608,26 +569,104 @@ export class SettingsSelectorComponent extends Container { ), }, { - id: "thinking", - label: "Thinking level", - description: "Reasoning depth for thinking-capable models", - currentValue: config.thinkingLevel, - submenu: (currentValue, done) => - new SelectSubmenu( - "Thinking Level", - "Select reasoning depth for thinking-capable models", - config.availableThinkingLevels.map((level) => ({ - value: level, - label: level, - description: THINKING_DESCRIPTIONS[level], - })), - currentValue, - (value) => { - callbacks.onThinkingLevelChange(value as ThinkingLevel); - done(value); + id: "model-thinking", + label: "Default thinking level per model", + description: `Override the default thinking level for specific models. ${cycleThinkingKey} cycles in-session.`, + currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels), + submenu: (_currentValue, done) => { + const steps: SteppedSubmenuStep[] = [ + { + key: "model", + title: "Per-Model Thinking Level", + description: "Select a model to configure", + options: () => { + const sorted = [...config.availableDefaultModels].sort((a, b) => { + const aKey = modelSettingKey(a); + const bKey = modelSettingKey(b); + if (aKey === currentModelKey) return -1; + if (bKey === currentModelKey) return 1; + if (aKey === currentDefaultModelKey) return -1; + if (bKey === currentDefaultModelKey) return 1; + return a.provider.localeCompare(b.provider); + }); + const items: SelectItem[] = sorted.map((model) => { + const key = modelSettingKey(model); + const override = currentModelThinkingLevels[key]; + return { + value: key, + label: modelItemLabel(model), + description: override ?? undefined, + }; + }); + if (items.length === 0) { + items.push({ + value: "__none__", + label: "No models available", + description: "Log in to a provider or configure an API key first", + }); + } + return items; + }, + preselect: () => currentModelKey ?? currentDefaultModelKey, + searchable: true, + layout: MODEL_PICKER_LAYOUT, }, - () => done(), - ), + { + key: "level", + title: (ctx) => { + const m = defaultModelByValue.get(ctx.model); + return `Thinking Level for ${m ? modelDisplayLabel(m) : ctx.model}`; + }, + description: "Select default thinking level for this model", + options: (ctx) => { + const model = defaultModelByValue.get(ctx.model); + if (!model) return []; + const levels = ( + model.reasoning ? getSupportedThinkingLevels(model) : ["off"] + ) as ThinkingLevel[]; + const items: SelectItem[] = levels.map((level) => ({ + value: level, + label: level, + description: THINKING_DESCRIPTIONS[level], + })); + if (currentModelThinkingLevels[ctx.model] !== undefined) { + items.push({ + value: CLEAR_OVERRIDE_VALUE, + label: "(clear override)", + description: `Revert to global default (${config.thinkingLevel})`, + }); + } + return items; + }, + preselect: (ctx) => currentModelThinkingLevels[ctx.model], + }, + ]; + + const summary = () => modelThinkingOverridesSummary(currentModelThinkingLevels); + + return new SteppedSubmenu( + steps, + (selections) => { + const model = defaultModelByValue.get(selections.model); + if (!model) return; + if (selections.level === CLEAR_OVERRIDE_VALUE) { + callbacks.onModelThinkingLevelRemove(model.provider, model.id); + delete currentModelThinkingLevels[selections.model]; + } else { + callbacks.onModelThinkingLevelChange( + model.provider, + model.id, + selections.level as ThinkingLevel, + ); + currentModelThinkingLevels[selections.model] = selections.level as ThinkingLevel; + } + }, + () => { + done(summary()); + }, + { loop: true }, + ); + }, }, { id: "tui-mode", @@ -636,6 +675,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.tuiMode, values: ["regular", "fullscreen"], }, + { + id: "fullscreen-exit-output", + label: "Fullscreen exit output", + description: "Print the transcript or only a session resume hint when exiting fullscreen mode", + currentValue: config.fullscreenExitOutput, + values: ["transcript", "resume-hint"], + }, { id: "fullscreen-scrollbar", label: "Fullscreen scrollbar", @@ -858,6 +904,9 @@ export class SettingsSelectorComponent extends Container { case "tui-mode": callbacks.onTuiModeChange(newValue as TuiMode); break; + case "fullscreen-exit-output": + callbacks.onFullscreenExitOutputChange(newValue as FullscreenExitOutput); + break; case "fullscreen-scrollbar": callbacks.onFullscreenScrollbarChange(newValue as ScrollViewScrollbar); break; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts new file mode 100644 index 00000000000..81703323a34 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -0,0 +1,258 @@ +import { + type Component, + Container, + fuzzyFilter, + getKeybindings, + Input, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme, theme } from "../theme/theme.ts"; + +const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +export interface SelectSubmenuOptions { + /** Enable type-to-search fuzzy filtering. */ + searchable?: boolean; + /** Override the select list layout (column widths). */ + layout?: SelectListLayoutOptions; +} + +/** + * Single-step submenu that shows a titled select list. + * With `searchable: true`, typing filters the list using fuzzy matching. + */ +export class SelectSubmenu extends Container { + private selectList: SelectList; + private listChildIndex: number; + private allOptions: SelectItem[]; + private listLayout: SelectListLayoutOptions; + private searchInput: Input | undefined; + private onSelectCb: (value: string) => void; + private onCancelCb: () => void; + private onSelectionChangeCb?: (value: string) => void; + + constructor( + title: string, + description: string, + options: SelectItem[], + currentValue: string, + onSelect: (value: string) => void, + onCancel: () => void, + onSelectionChange?: (value: string) => void, + submenuOptions?: SelectSubmenuOptions, + ) { + super(); + + this.allOptions = options; + this.listLayout = submenuOptions?.layout ?? SUBMENU_SELECT_LIST_LAYOUT; + this.onSelectCb = onSelect; + this.onCancelCb = onCancel; + this.onSelectionChangeCb = onSelectionChange; + + // Title + this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); + + // Description + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + + // Search input + if (submenuOptions?.searchable) { + this.addChild(new Spacer(1)); + this.searchInput = new Input(); + this.searchInput.onSubmit = () => { + this.selectList.handleInput("\r"); + }; + this.addChild(this.searchInput); + } + + // Spacer + this.addChild(new Spacer(1)); + + // Select list + this.selectList = this.buildSelectList(options, currentValue); + this.listChildIndex = this.children.length; + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + const hint = submenuOptions?.searchable + ? " Type to filter \u00b7 Enter to select \u00b7 Esc to go back" + : " Enter to select \u00b7 Esc to go back"; + this.addChild(new Text(theme.fg("dim", hint), 0, 0)); + } + + private buildSelectList(options: SelectItem[], preselect: string): SelectList { + const list = new SelectList(options, Math.min(options.length, 10), getSelectListTheme(), this.listLayout); + + const idx = options.findIndex((o) => o.value === preselect); + if (idx !== -1) list.setSelectedIndex(idx); + + list.onSelect = (item) => this.onSelectCb(item.value); + list.onCancel = this.onCancelCb; + if (this.onSelectionChangeCb) { + const cb = this.onSelectionChangeCb; + list.onSelectionChange = (item) => cb(item.value); + } + + return list; + } + + private applyFilter(query: string): void { + const filtered = query + ? fuzzyFilter(this.allOptions, query, (item) => `${item.label} ${item.description ?? ""}`) + : this.allOptions; + + const newList = this.buildSelectList(filtered, ""); + this.children[this.listChildIndex] = newList; + this.selectList = newList; + } + + handleInput(data: string): void { + if (this.searchInput) { + const kb = getKeybindings(); + const isNav = + kb.matches(data, "tui.select.up") || + kb.matches(data, "tui.select.down") || + kb.matches(data, "tui.select.confirm") || + kb.matches(data, "tui.select.cancel"); + if (isNav) { + this.selectList.handleInput(data); + } else { + this.searchInput.handleInput(data); + this.applyFilter(this.searchInput.getValue()); + } + } else { + this.selectList.handleInput(data); + } + } +} + +// ============================================================================ +// SteppedSubmenu — reusable multi-step selector +// ============================================================================ + +/** One step in a {@link SteppedSubmenu}. */ +export interface SteppedSubmenuStep { + /** Unique key \u2014 the selected value is stored in the result context under this key. */ + key: string; + /** Title shown at the top of the step. Receives prior selections. */ + title: string | ((context: Record) => string); + /** Description shown below the title. Receives prior selections. */ + description: string | ((context: Record) => string); + /** Build the option list for this step. Called fresh each time the step is shown. */ + options: (context: Record) => SelectItem[]; + /** Optionally pre-select a value when entering this step. */ + preselect?: (context: Record) => string | undefined; + /** Enable type-to-search fuzzy filtering for this step. */ + searchable?: boolean; + /** Override the select list layout (column widths) for this step. */ + layout?: SelectListLayoutOptions; +} + +interface SteppedSubmenuOptions { + /** Start at this step index (0-based), skipping earlier steps. Requires initialContext for skipped keys. */ + startAtStep?: number; + /** Pre-fill selections for skipped steps. */ + initialContext?: Record; + /** After completing the last step, loop back to step 0 instead of closing. */ + loop?: boolean; +} + +/** + * Generic N-step submenu built on top of {@link SelectSubmenu}. + * + * Each step's options can depend on prior selections via the shared context. + * Esc goes back one step; Esc at step 0 cancels. + * With `loop: true`, completing the final step invokes `onComplete` then returns to step 0. + */ +export class SteppedSubmenu extends Container { + private readonly steps: SteppedSubmenuStep[]; + private readonly onComplete: (context: Record) => void; + private readonly onCancel: () => void; + private readonly opts: SteppedSubmenuOptions; + private activeComponent: Component; + private context: Record; + + constructor( + steps: SteppedSubmenuStep[], + onComplete: (context: Record) => void, + onCancel: () => void, + opts: SteppedSubmenuOptions = {}, + ) { + super(); + this.steps = steps; + this.onComplete = onComplete; + this.onCancel = onCancel; + this.opts = opts; + this.context = { ...(opts.initialContext ?? {}) }; + this.activeComponent = this.buildStep(opts.startAtStep ?? 0); + } + + private buildStep(stepIndex: number): Component { + const step = this.steps[stepIndex]; + const total = this.steps.length; + const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} \u00b7 ` : ""; + + const title = typeof step.title === "function" ? step.title(this.context) : step.title; + const desc = typeof step.description === "function" ? step.description(this.context) : step.description; + const items = step.options(this.context); + const preselect = step.preselect?.(this.context) ?? ""; + + return new SelectSubmenu( + title, + `${stepLabel}${desc}`, + items, + preselect, + (value) => { + this.context[step.key] = value; + + if (stepIndex < total - 1) { + // Advance to next step + this.activeComponent = this.buildStep(stepIndex + 1); + } else { + // Final step \u2014 deliver result + this.onComplete({ ...this.context }); + + if (this.opts.loop) { + this.context = {}; + this.activeComponent = this.buildStep(0); + } else { + this.onCancel(); + } + } + }, + () => { + if (stepIndex > 0) { + delete this.context[step.key]; + this.activeComponent = this.buildStep(stepIndex - 1); + } else { + this.onCancel(); + } + }, + undefined, + step.searchable || step.layout ? { searchable: step.searchable, layout: step.layout } : undefined, + ); + } + + render(width: number): string[] { + return this.activeComponent.render(width); + } + + handleInput(data: string): void { + this.activeComponent.handleInput?.(data); + } + + invalidate(): void { + this.activeComponent.invalidate?.(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts index bc71bafc2c7..c67ccc990c6 100644 --- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -1,7 +1,20 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; -import { getSelectListTheme } from "../theme/theme.ts"; +import { + Container, + type Focusable, + fuzzyFilter, + getKeybindings, + Input, + matchesKey, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; +import { keyDisplayText } from "./keybinding-hints.ts"; const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, @@ -21,52 +34,110 @@ const LEVEL_DESCRIPTIONS: Record = { /** * Component that renders a thinking level selector with borders */ -export class ThinkingSelectorComponent extends Container { +export class ThinkingSelectorComponent extends Container implements Focusable { + private searchInput: Input; private selectList: SelectList; + private selectListChildIndex: number; + private allItems: SelectItem[]; + private onSelect: (level: ThinkingLevel) => void; + private onCancel: () => void; + private onSelectAsDefault?: (level: ThinkingLevel) => void; + private _focused = false; + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } constructor( currentLevel: ThinkingLevel, availableLevels: ThinkingLevel[], onSelect: (level: ThinkingLevel) => void, onCancel: () => void, + onSelectAsDefault?: (level: ThinkingLevel) => void, + defaultThinkingLevel?: ThinkingLevel, ) { super(); + this.onSelect = onSelect; + this.onCancel = onCancel; + this.onSelectAsDefault = onSelectAsDefault; - const thinkingLevels: SelectItem[] = availableLevels.map((level) => ({ + this.allItems = availableLevels.map((level) => ({ value: level, label: level, - description: LEVEL_DESCRIPTIONS[level], + description: + level === defaultThinkingLevel ? `${LEVEL_DESCRIPTIONS[level]} · default` : LEVEL_DESCRIPTIONS[level], })); // Add top border this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text("Thinking Level", 0, 0)); + this.addChild(new Spacer(1)); + this.addChild(new Text(`${keyDisplayText("app.thinking.cycle")} cycles thinking levels in-session`, 0, 0)); + this.addChild(new Spacer(1)); + + this.searchInput = new Input(); + this.searchInput.onSubmit = () => this.selectList.handleInput("\r"); + this.addChild(this.searchInput); + this.addChild(new Spacer(1)); // Create selector - this.selectList = new SelectList( - thinkingLevels, - thinkingLevels.length, - getSelectListTheme(), - THINKING_SELECT_LIST_LAYOUT, - ); - - // Preselect current level - const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel); + this.selectList = this.buildSelectList(this.allItems, currentLevel); + this.selectListChildIndex = this.children.length; + this.addChild(this.selectList); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0)); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + private buildSelectList(items: SelectItem[], preselect?: ThinkingLevel): SelectList { + const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme(), THINKING_SELECT_LIST_LAYOUT); + const currentIndex = items.findIndex((item) => item.value === preselect); if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); + list.setSelectedIndex(currentIndex); } + list.onSelect = (item) => this.onSelect(item.value as ThinkingLevel); + list.onCancel = () => this.onCancel(); + return list; + } - this.selectList.onSelect = (item) => { - onSelect(item.value as ThinkingLevel); - }; + private applyFilter(query: string): void { + const filtered = query + ? fuzzyFilter(this.allItems, query, (item) => `${item.label} ${item.description ?? ""}`) + : this.allItems; + const selectedValue = this.selectList.getSelectedItem()?.value as ThinkingLevel | undefined; + const newList = this.buildSelectList(filtered, selectedValue); + this.children[this.selectListChildIndex] = newList; + this.selectList = newList; + } - this.selectList.onCancel = () => { - onCancel(); - }; + handleInput(keyData: string): void { + if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) { + const item = this.selectList.getSelectedItem(); + if (item) this.onSelectAsDefault(item.value as ThinkingLevel); + return; + } - this.addChild(this.selectList); + const kb = getKeybindings(); + const isNav = + kb.matches(keyData, "tui.select.up") || + kb.matches(keyData, "tui.select.down") || + kb.matches(keyData, "tui.select.confirm") || + kb.matches(keyData, "tui.select.cancel"); + if (isNav) { + this.selectList.handleInput(keyData); + return; + } - // Add bottom border - this.addChild(new DynamicBorder()); + this.searchInput.handleInput(keyData); + this.applyFilter(this.searchInput.getValue()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index ad84f441334..b1420b36c94 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -4,6 +4,9 @@ import { createAllToolDefinitions, type ToolName } from "../../../core/tools/ind import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts"; import { convertToPng } from "../../../utils/image-convert.ts"; import { theme } from "../theme/theme.ts"; +import { keyHint } from "./keybinding-hints.ts"; + +const FALLBACK_PREVIEW_LINES = 10; export interface ToolExecutionOptions { showImages?: boolean; @@ -141,7 +144,15 @@ export class ToolExecutionComponent extends Container { if (!output) { return undefined; } - return new Text(theme.fg("toolOutput", output), 0, 0); + + const lines = output.split("\n"); + const displayLines = this.expanded ? lines : lines.slice(0, FALLBACK_PREVIEW_LINES); + const remaining = lines.length - displayLines.length; + let text = displayLines.map((line) => theme.fg("toolOutput", line)).join("\n"); + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + return new Text(text, 0, 0); } updateArgs(args: any): void { diff --git a/packages/coding-agent/src/modes/interactive/external-editor.ts b/packages/coding-agent/src/modes/interactive/external-editor.ts index 45fb572f80d..d672d4e0807 100644 --- a/packages/coding-agent/src/modes/interactive/external-editor.ts +++ b/packages/coding-agent/src/modes/interactive/external-editor.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { stripBom } from "../../utils/text.ts"; export interface ExternalEditorOptions { command: string; @@ -34,7 +35,7 @@ export async function editInExternalEditor(options: ExternalEditorOptions): Prom return { status: "failed" }; } - return { status: "complete", content: readFileSync(filePath, "utf-8").replace(/\n$/, "") }; + return { status: "complete", content: stripBom(readFileSync(filePath, "utf-8")).replace(/\n$/, "") }; } finally { try { rmSync(directory, { recursive: true, force: true }); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a306d900bb1..acf96ae2f77 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7,9 +7,9 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; -import type { AssistantMessage, ImageContent, Message, Model } from "@earendil-works/pi-ai/compat"; +import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; import type { AutocompleteItem, AutocompleteProvider, @@ -44,7 +44,7 @@ import { visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { spawn, spawnSync } from "child_process"; +import { spawn } from "child_process"; import { APP_NAME, APP_TITLE, @@ -53,11 +53,11 @@ import { getAuthPath, getDebugLogPath, getDocsPath, - getShareViewerUrl, VERSION, } from "../../config.ts"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; +import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.ts"; import { CACHE_TTL_MS, type CacheMiss, @@ -65,6 +65,7 @@ import { computeCacheWaste, detectCacheMiss, } from "../../core/cache-stats.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts"; import type { AutocompleteProviderFactory, EditorFactory, @@ -92,7 +93,7 @@ import { DefaultPackageManager } from "../../core/package-manager.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts"; -import type { TuiMode } from "../../core/settings-manager.ts"; +import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.ts"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; @@ -107,12 +108,12 @@ import { openBrowser } from "../../utils/open-browser.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; -import { ensureTool } from "../../utils/tools-manager.ts"; +import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts"; +import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts"; import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts"; import { ArminComponent } from "./components/armin.ts"; import { AssistantMessageComponent } from "./components/assistant-message.ts"; import { BashExecutionComponent } from "./components/bash-execution.ts"; -import { BorderedLoader } from "./components/bordered-loader.ts"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts"; import { CustomEditor } from "./components/custom-editor.ts"; @@ -146,13 +147,16 @@ import { type StatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; +import { ThinkingSelectorComponent } from "./components/thinking-selector.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { editInExternalEditor } from "./external-editor.ts"; +import { refreshModelCatalogs } from "./model-catalog-refresh.ts"; import { getModelSearchText } from "./model-search.ts"; +import { shareSession } from "./session-share.ts"; import { getAvailableThemes, getAvailableThemesWithPaths, @@ -203,12 +207,22 @@ type CompactionQueuedMessage = { mode: "steer" | "followUp"; }; -type RenderSessionItem = AgentMessage | Extract; +type CompactionCostNotice = { + type: "compaction_cost"; + kind: "compaction" | "branch_summary"; + usage: Usage; +}; + +type RenderSessionItem = AgentMessage | Extract | CompactionCostNotice; function isCustomSessionEntry(item: RenderSessionItem): item is Extract { return "type" in item && item.type === "custom"; } +function isCompactionCostNotice(item: RenderSessionItem): item is CompactionCostNotice { + return "type" in item && item.type === "compaction_cost"; +} + const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]); function isDeadTerminalError(error: unknown): boolean { @@ -256,6 +270,12 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof return providerId in defaultModelPerProvider; } +function llamaCppPostLoginGuidance(actionLabel: string, loadedModelCount: number): string { + return loadedModelCount === 0 + ? `${actionLabel}. No llama.cpp models are loaded. Use /llama to load a model, then /model to select it.` + : `${actionLabel}. Use /model to select a loaded llama.cpp model, or /llama to manage models.`; +} + type LoginProviderCompletionOption = { id: string; name: string; @@ -315,6 +335,8 @@ function formatLoginProviderCompletionDescription(provider: LoginProviderComplet export interface InteractiveModeOptions { /** Providers that were migrated to auth.json (shows warning) */ migratedProviders?: string[]; + /** Diagnostics collected before the interactive TUI was initialized. */ + startupDiagnostics?: AgentSessionRuntimeDiagnostic[]; /** Warning message if session model couldn't be restored */ modelFallbackMessage?: string; /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */ @@ -329,6 +351,8 @@ export interface InteractiveModeOptions { verbose?: boolean; /** TUI layout mode. */ tuiMode?: TuiMode; + /** Initial interactive theme setting for this invocation. */ + initialThemeSetting?: string; } interface InteractiveTuiOptions { @@ -336,13 +360,28 @@ interface InteractiveTuiOptions { showHardwareCursor: boolean; logDirectory: string; terminal?: Terminal; + onRightClickPaste?: () => void; } /** Composition root for selecting the interactive terminal renderer. */ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen { const terminal = options.terminal ?? new ProcessTerminal(); if (options.tuiMode === "fullscreen") { - return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser }); + const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text)); + return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { + searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)), + searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), + openUrl: openBrowser, + onRightClickPaste: options.onRightClickPaste, + copySelection: async (text) => { + try { + await copyToClipboard(text); + return true; + } catch { + return false; + } + }, + }); } return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory); } @@ -354,11 +393,19 @@ export function createInteractiveTuiReference(getTui: () => TUI): TUI { const tui = getTui(); const value = Reflect.get(tui, property, tui); if (typeof value !== "function") return value; + let methodTui = tui; + let method = value; return (...args: unknown[]) => { - const tui = getTui(); - const method = Reflect.get(tui, property, tui); - if (typeof method !== "function") throw new TypeError(`TUI property ${String(property)} is not callable`); - return Reflect.apply(method, tui, args); + const currentTui = getTui(); + if (currentTui !== methodTui) { + const currentMethod = Reflect.get(currentTui, property, currentTui); + if (typeof currentMethod !== "function") { + throw new TypeError(`TUI property ${String(property)} is not callable`); + } + methodTui = currentTui; + method = currentMethod; + } + return Reflect.apply(method, methodTui, args); }; }, set: (_target, property, value) => { @@ -418,6 +465,7 @@ export class InteractiveMode { // Status line tracking (for mutating immediately-sequential status updates) private lastStatusSpacer: Spacer | undefined = undefined; private lastStatusText: Text | undefined = undefined; + private managedToolStatusStarted = false; // Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; @@ -493,6 +541,9 @@ export class InteractiveMode { private customHeader: (Component & { dispose?(): void }) | undefined = undefined; private options: InteractiveModeOptions; + private readonly onRightClickPaste = (): void => { + void this.handleRightClickPaste(); + }; private autoTrustOnReloadCwd: string | undefined; private themeController: InteractiveThemeController; @@ -520,12 +571,14 @@ export class InteractiveMode { }); this.runtimeHost.setRebindSession(async () => { await this.rebindCurrentSession({ renderBeforeBind: true }); + await this.themeController.applyFromSettings(); }); this.version = VERSION; this.renderer = createInteractiveTui({ tuiMode, showHardwareCursor: this.settingsManager.getShowHardwareCursor(), logDirectory: getAgentDir(), + onRightClickPaste: this.onRightClickPaste, }); this.ui = createInteractiveTuiReference(() => this.renderer); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); @@ -563,12 +616,12 @@ export class InteractiveMode { // Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - this.themeController = new InteractiveThemeController( - this.ui, - this.settingsManager, - (message) => this.showError(message), - () => this.updateEditorBorderColor(), - ); + this.themeController = new InteractiveThemeController(this.ui, { + getSettingsManager: () => this.settingsManager, + showError: (message) => this.showError(message), + onChanged: () => this.updateEditorBorderColor(), + initialThemeSetting: options.initialThemeSetting, + }); } private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { @@ -653,6 +706,21 @@ export class InteractiveMode { }; } + const thinkingCommand = slashCommands.find((command) => command.name === "thinking"); + if (thinkingCommand) { + thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + return createFuzzyAutocompleteItems( + this.session.getAvailableThinkingLevels(), + prefix, + (level) => level, + (level) => ({ + value: level, + label: level, + }), + ); + }; + } + const loginCommand = slashCommands.find((command) => command.name === "login"); if (loginCommand) { loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { @@ -760,13 +828,13 @@ export class InteractiveMode { } } - private stopInteractiveTui(): void { - if (this.renderer.mode === "fullscreen") { + private stopInteractiveTui(fullscreenExitOutput: FullscreenExitOutput): void { + if (this.renderer.mode === "fullscreen" && fullscreenExitOutput === "transcript") { while (this.renderer.hasOverlayEntries) this.renderer.hideOverlay(); this.switchTuiMode("regular", false, false); this.renderer.renderNow(); } - this.ui.stop(); + this.ui.stop({ preserveScreen: this.renderer.mode === "fullscreen" }); } private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean { @@ -794,6 +862,7 @@ export class InteractiveMode { showHardwareCursor, logDirectory: getAgentDir(), terminal, + onRightClickPaste: this.onRightClickPaste, }); nextUi.setClearOnShrink(clearOnShrink); nextUi.onDebug = onDebug; @@ -827,11 +896,6 @@ export class InteractiveMode { // Load changelog (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay(); - // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) - // Both are needed: fd for autocomplete, rg for grep tool and bash commands - const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); - this.fdPath = fdPath; - if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { const modelList = this.session.scopedModels .map((sm) => { @@ -877,11 +941,12 @@ export class InteractiveMode { this.widgetContainerBelow, this.footerContainer, ]); + // Accept text while startup completes, but only enable interrupt, exit, and submission feedback. + this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); + this.defaultEditor.onCtrlD = () => this.handleCtrlD(); + this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text); this.ui.setFocus(this.editor); - this.setupKeyHandlers(); - this.setupEditorSubmitHandler(); - // Start the UI before initializing extensions so session_start handlers can use interactive dialogs this.ui.start(); this.isInitialized = true; @@ -950,6 +1015,20 @@ export class InteractiveMode { } this.ui.requestRender(); + // Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir) + // so slow downloads do not make startup appear frozen. + // Both are needed: fd for autocomplete, rg for grep tool and bash commands. + const [fdPath] = await Promise.all([ + ensureTool("fd", (status) => this.showManagedToolStatus(status)), + ensureTool("rg", (status) => this.showManagedToolStatus(status)), + ]); + this.fdPath = fdPath; + + // Enable the remaining input handlers only after managed-tool setup completes. + this.setupKeyHandlers(); + this.setupEditorSubmitHandler(); + this.ui.requestRender(); + // Initialize extensions first so resources are shown before messages await this.rebindCurrentSession(); @@ -970,6 +1049,14 @@ export class InteractiveMode { // Initialize available provider count for footer display await this.updateAvailableProviderCount(); + + // Flush the completed startup state before loading the remaining syntax grammars. + this.ui.renderNow(); + void loadAllHighlightLanguages().then(() => { + if (!this.isInitialized) return; + this.ui.invalidate(); + this.ui.requestRender(); + }); } /** @@ -995,8 +1082,7 @@ export class InteractiveMode { if (!process.env.PI_OFFLINE) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); - void this.session.modelRuntime - .refresh({ signal: controller.signal }) + void refreshModelCatalogs(this.session.modelRuntime, controller.signal) .then(() => this.updateAvailableProviderCount()) .catch(() => {}) .finally(() => clearTimeout(timeout)); @@ -1032,7 +1118,24 @@ export class InteractiveMode { }); // Show startup warnings - const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options; + const { + migratedProviders, + startupDiagnostics, + modelFallbackMessage, + initialMessage, + initialImages, + initialMessages, + } = this.options; + + for (const diagnostic of startupDiagnostics ?? []) { + if (diagnostic.type === "error") { + this.showError(diagnostic.message); + } else if (diagnostic.type === "warning") { + this.showWarning(diagnostic.message); + } else { + this.showStatus(diagnostic.message); + } + } if (migratedProviders && migratedProviders.length > 0) { this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); @@ -1933,7 +2036,7 @@ export class InteractiveMode { const message = error instanceof Error ? error.message : String(error); this.showError(`${prefix}: ${message}`); stopThemeWatcher(); - this.stop(); + this.stop("transcript"); process.exit(1); } @@ -2811,6 +2914,20 @@ export class InteractiveMode { }; } + private async handleRightClickPaste(): Promise { + const target = this.renderer.getFocusedComponent(); + const handleInput = target?.handleInput; + if (!target || !handleInput) return; + try { + const text = await readClipboardText(); + if (!text || this.renderer.getFocusedComponent() !== target) return; + handleInput.call(target, `\x1b[200~${text}\x1b[201~`); + this.ui.requestRender(); + } catch { + // Silently ignore clipboard errors (may not have permission, etc.) + } + } + private async handleClipboardPaste(): Promise { try { const image = await readClipboardImage(); @@ -2836,6 +2953,11 @@ export class InteractiveMode { } } + private handleStartupSubmit(text: string): void { + this.editor.setText(text); + this.showStatus("Startup is still in progress"); + } + private setupEditorSubmitHandler(): void { this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); @@ -2858,6 +2980,12 @@ export class InteractiveMode { await this.handleModelCommand(searchTerm); return; } + if (text === "/thinking" || text.startsWith("/thinking ")) { + const searchTerm = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined; + this.editor.setText(""); + this.handleThinkingCommand(searchTerm); + return; + } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -3282,8 +3410,13 @@ export class InteractiveMode { this.showStatus("Auto-compaction cancelled"); } } else if (event.result) { + const entries = this.sessionManager.buildContextEntries(); + if (entries[0]?.type !== "compaction") { + throw new Error("Completed compaction is missing from the session context"); + } this.chatContainer.clear(); - this.rebuildChatFromMessages(); + // The latest compaction is prepended for model context; append it below at its chronological position. + this.renderSessionEntries(entries.slice(1)); this.addMessageToChat( createCompactionSummaryMessage( event.result.summary, @@ -3291,6 +3424,13 @@ export class InteractiveMode { new Date().toISOString(), ), ); + if (event.result.usage) { + this.addCompactionCostNotice({ + type: "compaction_cost", + kind: "compaction", + usage: event.result.usage, + }); + } this.footer.invalidate(); } else if (event.errorMessage) { if (event.reason === "manual") { @@ -3371,6 +3511,20 @@ export class InteractiveMode { return textBlocks.map((c) => (c as { text: string }).text).join(""); } + /** Show a managed-tool status update in the chat. */ + private showManagedToolStatus(status: ToolStatus): void { + if (!this.managedToolStatusStarted) { + this.chatContainer.addChild(new Spacer(1)); + this.managedToolStatusStarted = true; + } + const message = status.type === "warning" ? `Warning: ${status.message}` : status.message; + const color = status.type === "warning" ? "warning" : "dim"; + this.chatContainer.addChild(new Text(theme.fg(color, message), 1, 0)); + this.lastStatusSpacer = undefined; + this.lastStatusText = undefined; + this.ui.requestRender(); + } + /** * Show a status message in the chat. * @@ -3548,6 +3702,10 @@ export class InteractiveMode { this.addCustomEntryToChat(item); continue; } + if (isCompactionCostNotice(item)) { + this.addCompactionCostNotice(item); + continue; + } const message = item; // Assistant messages need special handling for tool calls @@ -3625,11 +3783,32 @@ export class InteractiveMode { if (entry.type === "custom") { return [entry]; } - return sessionEntryToContextMessages(entry); + const messages = sessionEntryToContextMessages(entry); + if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) { + return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }]; + } + return messages; }); this.renderSessionItems(items, options); } + /** + * Render billing usage for a compaction or branch summary. The notice is derived + * from persisted summary usage and is not stored as a separate session entry. + */ + private addCompactionCostNotice(notice: CompactionCostNotice): void { + if (!this.settingsManager.getShowCacheMissNotices()) return; + + const { usage } = notice; + const tokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + const cost = usage.cost.total >= 0.01 ? ` (~$${usage.cost.total.toFixed(2)})` : ""; + const label = notice.kind === "compaction" ? "Compaction" : "Branch summary"; + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text(theme.fg("warning", `${label}: ${formatTokens(tokens)} tokens billed${cost}`), 1, 0), + ); + } + /** * Show a transcript notice when a completed assistant message paid for a * significant cache miss. Only states observable facts: the miss itself, @@ -3816,7 +3995,7 @@ export class InteractiveMode { try { this.ui.stop(); } catch {} - console.error("pi exiting due to uncaughtException:"); + console.error(`${APP_NAME} exiting due to uncaughtException:`); console.error(error); process.exit(1); } @@ -4345,9 +4524,15 @@ export class InteractiveMode { private showSettingsSelector(): void { this.showSelector((done) => { let selector: SettingsSelectorComponent | undefined; + const defaultProvider = this.settingsManager.getDefaultProvider(); + const defaultModelId = this.settingsManager.getDefaultModel(); + const defaultModel = defaultProvider && defaultModelId ? `${defaultProvider}/${defaultModelId}` : "not set"; selector = new SettingsSelectorComponent( { autoCompact: this.session.autoCompactionEnabled, + defaultModel, + currentModel: this.session.model, + availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(), showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), autoResizeImages: this.settingsManager.getImageAutoResize(), @@ -4357,9 +4542,10 @@ export class InteractiveMode { followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), - thinkingLevel: this.session.thinkingLevel, - availableThinkingLevels: this.session.getAvailableThinkingLevels(), - currentTheme: this.settingsManager.getThemeSetting() || "dark", + thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, + availableThinkingLevels: [...THINKING_LEVEL_OPTIONS], + modelThinkingLevels: this.settingsManager.getAllModelThinkingLevels(), + currentTheme: this.themeController.getThemeSelection() || "dark", terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, @@ -4378,6 +4564,7 @@ export class InteractiveMode { clearOnShrink: this.settingsManager.getClearOnShrink(), showTerminalProgress: this.settingsManager.getShowTerminalProgress(), tuiMode: this.ui.mode, + fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), warnings: this.settingsManager.getWarnings(), }, @@ -4427,14 +4614,30 @@ export class InteractiveMode { configureHttpDispatcher(timeoutMs); this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); }, - onThinkingLevelChange: (level) => { - this.session.setThinkingLevel(level); - this.footer.invalidate(); - this.updateEditorBorderColor(); + onModelThinkingLevelChange: (provider, modelId, level) => { + this.settingsManager.setModelThinkingLevel(provider, modelId, level); + // If the override is for the current model, apply it to the session too + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + this.session.setThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + }, + onModelThinkingLevelRemove: (provider, modelId) => { + this.settingsManager.removeModelThinkingLevel(provider, modelId); + // If the override was for the current model, revert to global default + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + this.session.setThinkingLevel(globalDefault); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } }, onThemeChange: (themeSetting) => { this.settingsManager.setTheme(themeSetting); - void this.themeController.applyFromSettings(); + void this.themeController.setThemeSetting(themeSetting); }, onThemePreview: (themeName) => this.themeController.preview(themeName), onHideThinkingBlockChange: (hidden) => { @@ -4534,6 +4737,9 @@ export class InteractiveMode { if (!this.activeStatusIndicator) this.statusContainer.clear(); this.showStatus(`TUI mode: ${mode}`); }, + onFullscreenExitOutputChange: (output) => { + this.settingsManager.setFullscreenExitOutput(output); + }, onFullscreenScrollbarChange: (mode) => { this.settingsManager.setFullscreenScrollbar(mode); this.applyFullscreenScrollbarSetting(); @@ -4551,6 +4757,55 @@ export class InteractiveMode { }); } + private handleThinkingCommand(searchTerm?: string): void { + const availableLevels = this.session.getAvailableThinkingLevels(); + if (!searchTerm) { + this.showThinkingSelector(); + return; + } + + const normalized = searchTerm.trim().toLowerCase(); + const level = availableLevels.find((candidate) => candidate.toLowerCase() === normalized); + if (!level) { + this.showError(`Unknown thinking level "${searchTerm}". Available levels: ${availableLevels.join(", ")}.`); + return; + } + + this.selectThinkingLevel(level, false); + } + + private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void { + try { + this.session.setThinkingLevel(level, { persist }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private showThinkingSelector(): void { + this.showSelector((done) => { + const selectLevel = (level: ThinkingLevel, persist: boolean) => { + this.selectThinkingLevel(level, persist); + done(); + }; + const selector = new ThinkingSelectorComponent( + this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL, + this.session.getAvailableThinkingLevels(), + (level) => selectLevel(level, false), + () => { + done(); + this.ui.requestRender(); + }, + (level) => selectLevel(level, true), + this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, + ); + return { component: selector, focus: selector }; + }); + } + private async handleModelCommand(searchTerm?: string): Promise { if (!searchTerm) { this.showModelSelector(); @@ -4560,7 +4815,7 @@ export class InteractiveMode { const model = await this.findExactModelMatch(searchTerm); if (model) { try { - await this.session.setModel(model); + await this.session.setModel(model, { persist: false }); this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Model: ${model.id}`); @@ -4591,7 +4846,7 @@ export class InteractiveMode { controller.abort(); }, 15_000); try { - const result = await this.session.modelRuntime.refresh({ signal: controller.signal }); + const result = await refreshModelCatalogs(this.session.modelRuntime, controller.signal); if (result.aborted && timedOut) { this.showWarning("Model refresh timed out; searching cached models."); } else if (result.errors.size > 0) { @@ -4688,7 +4943,7 @@ export class InteractiveMode { trustStore.setMany(selection.updates); done(); this.showStatus( - `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart ${APP_NAME} for this to take effect.`, ); }, onCancel: () => { @@ -4702,31 +4957,35 @@ export class InteractiveMode { private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { + const selectModel = async (model: Model, persist: boolean) => { + try { + await this.session.setModel(model, { persist }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + done(); + this.showStatus(persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + this.checkDaxnutsEasterEgg(model); + } catch (error) { + done(); + this.showError(error instanceof Error ? error.message : String(error)); + } + }; + const defaultProvider = this.settingsManager.getDefaultProvider(); + const defaultModel = this.settingsManager.getDefaultModel(); const selector = new ModelSelectorComponent( this.ui, this.session.model, - this.settingsManager, this.session.modelRuntime, this.session.scopedModels, - async (model) => { - try { - await this.session.setModel(model); - this.footer.invalidate(); - this.updateEditorBorderColor(); - done(); - this.showStatus(`Model: ${model.id}`); - void this.maybeWarnAboutAnthropicSubscriptionAuth(model); - this.checkDaxnutsEasterEgg(model); - } catch (error) { - done(); - this.showError(error instanceof Error ? error.message : String(error)); - } - }, + (model) => selectModel(model, false), () => { done(); this.ui.requestRender(); }, initialSearchInput, + (model) => selectModel(model, true), + defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined, ); return { component: selector, focus: selector, dispose: () => selector.dispose() }; }); @@ -4807,8 +5066,7 @@ export class InteractiveMode { }, }, ); - void this.session.modelRuntime - .refresh({ signal: controller.signal }) + void refreshModelCatalogs(this.session.modelRuntime, controller.signal) .then((result) => { if (disposed) return; availableModels = [...this.session.modelRuntime.getAvailableSnapshot()]; @@ -5393,7 +5651,10 @@ export class InteractiveMode { if (isUnknownModel(previousModel)) { const availableModels = this.session.modelRuntime.getAvailableSnapshot(); const providerModels = availableModels.filter((model) => model.provider === providerId); - if (!hasDefaultModelProvider(providerId)) { + // Matches LLAMA_PROVIDER_ID from extensions/llama/provider.ts; kept inline to avoid coupling interactive mode to the built-in extension. + if (providerId === "llama.cpp") { + selectionError = llamaCppPostLoginGuidance(actionLabel, providerModels.length); + } else if (!hasDefaultModelProvider(providerId)) { selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; } else if (providerModels.length === 0) { selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`; @@ -5404,7 +5665,7 @@ export class InteractiveMode { selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; } else { try { - await this.session.setModel(selectedModel); + await this.session.setModel(selectedModel, { persist: true }); } catch (error: unknown) { selectedModel = undefined; const errorMessage = error instanceof Error ? error.message : String(error); @@ -5467,7 +5728,11 @@ export class InteractiveMode { providerOption.name, `${providerOption.name} setup`, ); - dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside pi.`, [], true); + dialog.showInfo( + `${providerOption.method?.name ?? "Authentication"} is configured outside ${APP_NAME}.`, + [], + true, + ); this.editorContainer.clear(); this.editorContainer.addChild(dialog); @@ -5740,7 +6005,9 @@ export class InteractiveMode { const filePath = this.session.exportToJsonl(outputPath); this.showStatus(`Session exported to: ${filePath}`); } else { - const filePath = await this.session.exportToHtml(outputPath); + const filePath = await this.session.exportToHtml(outputPath, { + themeName: theme.name, + }); this.showStatus(`Session exported to: ${filePath}`); } } catch (error: unknown) { @@ -5822,97 +6089,14 @@ export class InteractiveMode { } private async handleShareCommand(): Promise { - // Check if gh is available and logged in - try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); - if (authResult.status !== 0) { - this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); - return; - } - } catch { - this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); - return; - } - - // Export to a temp file - const tmpFile = path.join(os.tmpdir(), "session.html"); - try { - await this.session.exportToHtml(tmpFile); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - - // Show cancellable loader, replacing the editor - const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - - const restoreEditor = () => { - loader.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - try { - fs.unlinkSync(tmpFile); - } catch { - // Ignore cleanup errors - } - }; - - // Create a secret gist asynchronously - let proc: ReturnType | null = null; - - loader.onAbort = () => { - proc?.kill(); - restoreEditor(); - this.showStatus("Share cancelled"); - }; - - try { - const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { - proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - proc.on("close", (code) => resolve({ stdout, stderr, code })); - }); - - if (loader.signal.aborted) return; - - restoreEditor(); - - if (result.code !== 0) { - const errorMsg = result.stderr?.trim() || "Unknown error"; - this.showError(`Failed to create gist: ${errorMsg}`); - return; - } - - // Extract gist ID from the URL returned by gh - // gh returns something like: https://gist.github.com/username/GIST_ID - const gistUrl = result.stdout?.trim(); - const gistId = gistUrl?.split("/").pop(); - if (!gistId) { - this.showError("Failed to parse gist ID from gh output"); - return; - } - - // Create the preview URL - const previewUrl = getShareViewerUrl(gistId); - this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); - } catch (error: unknown) { - if (!loader.signal.aborted) { - restoreEditor(); - this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); - } - } + await shareSession({ + session: this.session, + ui: this.ui, + editorContainer: this.editorContainer, + editor: this.editor, + showStatus: (message) => this.showStatus(message), + showError: (message) => this.showError(message), + }); } private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise { @@ -6342,7 +6526,7 @@ export class InteractiveMode { } } - stop(): void { + stop(fullscreenExitOutput = this.settingsManager.getFullscreenExitOutput()): void { this.disposeActiveSelector(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); @@ -6356,7 +6540,7 @@ export class InteractiveMode { this.unsubscribe(); } if (this.isInitialized) { - this.stopInteractiveTui(); + this.stopInteractiveTui(fullscreenExitOutput); this.isInitialized = false; } this.unregisterSignalHandlers(); diff --git a/packages/coding-agent/src/modes/interactive/model-catalog-refresh.ts b/packages/coding-agent/src/modes/interactive/model-catalog-refresh.ts new file mode 100644 index 00000000000..e5915ff49d5 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/model-catalog-refresh.ts @@ -0,0 +1,51 @@ +import type { ModelsRefreshResult } from "@earendil-works/pi-ai"; +import type { ModelRuntime } from "../../core/model-runtime.ts"; +import { raceWithAbortSignal } from "../../utils/abort.ts"; + +type ModelCatalogRuntime = Pick; + +interface ActiveModelCatalogRefresh { + controller: AbortController; + promise: Promise; + waiters: number; +} + +class ModelCatalogRefreshCoordinator { + private readonly activeByRuntime = new WeakMap(); + + refresh(modelRuntime: ModelCatalogRuntime, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let active = this.activeByRuntime.get(modelRuntime); + if (!active) { + const controller = new AbortController(); + let created!: ActiveModelCatalogRefresh; + const operation = modelRuntime.refresh({ signal: controller.signal }); + const promise = raceWithAbortSignal(operation, controller.signal).finally(() => { + if (this.activeByRuntime.get(modelRuntime) === created) { + this.activeByRuntime.delete(modelRuntime); + } + }); + created = { controller, promise, waiters: 0 }; + active = created; + this.activeByRuntime.set(modelRuntime, active); + } + + active.waiters++; + return raceWithAbortSignal(active.promise, signal).finally(() => { + active.waiters--; + if (active.waiters === 0 && this.activeByRuntime.get(modelRuntime) === active) { + active.controller.abort(); + } + }); + } +} + +const modelCatalogRefreshCoordinator = new ModelCatalogRefreshCoordinator(); + +/** Share concurrent interactive all-catalog refreshes while keeping each caller's cancellation independent. */ +export function refreshModelCatalogs( + modelRuntime: ModelCatalogRuntime, + signal: AbortSignal, +): Promise { + return modelCatalogRefreshCoordinator.refresh(modelRuntime, signal); +} diff --git a/packages/coding-agent/src/modes/interactive/session-share.ts b/packages/coding-agent/src/modes/interactive/session-share.ts new file mode 100644 index 00000000000..f9c0000e25c --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/session-share.ts @@ -0,0 +1,210 @@ +import { spawn, spawnSync } from "node:child_process"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config"; +import { type Container, type EditorComponent, hyperlink, type TUI } from "@earendil-works/pi-tui"; +import { getAuthCredential } from "../../cli/auth-command.ts"; +import { getShareViewerUrl } from "../../config.ts"; +import type { AgentSession } from "../../core/agent-session.ts"; +import { exportSessionToJsonl } from "../../core/session-export.ts"; +import { BorderedLoader } from "./components/bordered-loader.ts"; +import { theme } from "./theme/theme.ts"; + +interface SessionShareContext { + session: AgentSession; + ui: TUI; + editorContainer: Container; + editor: EditorComponent; + showStatus: (message: string) => void; + showError: (message: string) => void; +} + +/** Export the current branch with presentation metadata for Radius. */ +export function exportSessionForShare(filePath: string, session: AgentSession): void { + exportSessionToJsonl(session.sessionManager, filePath, (parentId, timestamp) => [ + { + type: "custom", + customType: "pi.share", + id: crypto.randomUUID().slice(0, 8), + parentId, + timestamp, + data: { + systemPrompt: session.state.systemPrompt, + tools: session.state.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + })), + }, + }, + ]); +} + +/** Share the current session through Radius, falling back to a private gist. */ +export async function shareSession(context: SessionShareContext): Promise { + const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); + let htmlFile: string | null = null; + + try { + try { + exportSessionForShare(jsonlFile, context.session); + } catch (error: unknown) { + context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + if (await tryShareViaRadius(jsonlFile, context)) return; + + try { + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + if (authResult.status !== 0) { + context.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); + return; + } + } catch { + context.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); + return; + } + + try { + htmlFile = path.join(os.tmpdir(), "session.html"); + await context.session.exportToHtml(htmlFile, { themeName: theme.name }); + } catch (error: unknown) { + context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + await shareViaGist(htmlFile, context); + } finally { + for (const tmpFile of [jsonlFile, htmlFile]) { + try { + if (tmpFile !== null) { + fs.unlinkSync(tmpFile); + } + } catch { + // Ignore cleanup errors + } + } + } +} + +async function tryShareViaRadius(tmpFile: string, context: SessionShareContext): Promise { + const provider = context.session.modelRuntime.getProvider("radius"); + if (!provider) return false; + + const token = getAuthCredential( + await context.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }), + ); + if (!token) return false; + + const loader = new BorderedLoader(context.ui, theme, "Uploading to Radius..."); + context.editorContainer.clear(); + context.editorContainer.addChild(loader); + context.ui.setFocus(loader); + context.ui.requestRender(); + loader.onAbort = () => { + restoreEditor(loader, context); + context.showStatus("Share cancelled"); + }; + + try { + const body = fs.readFileSync(tmpFile); + const url = new URL("/v1/artifacts", DEFAULT_RADIUS_GATEWAY); + url.searchParams.set("visibility", "organization"); + url.searchParams.set("title", "Pi session"); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-ndjson", + "Content-Length": String(body.byteLength), + }, + body, + signal: loader.signal, + }); + if (loader.signal.aborted) return true; + const json = (await response.json().catch(() => null)) as { + artifact?: { canonical_url: string }; + error?: string; + } | null; + if (loader.signal.aborted) return true; + restoreEditor(loader, context); + if (!response.ok || !json?.artifact) { + context.showError( + `Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`, + ); + return true; + } + const shareUrl = json.artifact.canonical_url; + context.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`); + return true; + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(loader, context); + context.showError( + `Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + return true; + } +} + +async function shareViaGist(tmpFile: string, context: SessionShareContext): Promise { + const loader = new BorderedLoader(context.ui, theme, "Creating gist..."); + context.editorContainer.clear(); + context.editorContainer.addChild(loader); + context.ui.setFocus(loader); + context.ui.requestRender(); + + let proc: ReturnType | null = null; + loader.onAbort = () => { + proc?.kill(); + restoreEditor(loader, context); + context.showStatus("Share cancelled"); + }; + + try { + const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + }); + + if (loader.signal.aborted) return; + restoreEditor(loader, context); + + if (result.code !== 0) { + context.showError(`Failed to create gist: ${result.stderr?.trim() || "Unknown error"}`); + return; + } + + const gistUrl = result.stdout?.trim(); + const gistId = gistUrl?.split("/").pop(); + if (!gistId) { + context.showError("Failed to parse gist ID from gh output"); + return; + } + + const previewUrl = getShareViewerUrl(gistId); + context.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`); + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(loader, context); + context.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } +} + +function restoreEditor(loader: BorderedLoader, context: SessionShareContext): void { + loader.dispose(); + context.editorContainer.clear(); + context.editorContainer.addChild(context.editor); + context.ui.setFocus(context.editor); +} diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 9db9cbd8b54..01d1e02a8b0 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -34,6 +34,8 @@ "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", + "searchMatchBg": "selectedBg", + "searchMatchText": "text", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 74ef3d1c57f..0fde42b8b73 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -33,6 +33,8 @@ "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", + "searchMatchBg": "selectedBg", + "searchMatchText": "text", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts index 6d99b0ded12..0684ecaee50 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -17,20 +17,33 @@ type ThemeResult = { success: boolean; error?: string }; export class InteractiveThemeController { private readonly ui: TUI; - private readonly settingsManager: SettingsManager; + private readonly getSettingsManager: () => SettingsManager; private readonly showError: (message: string) => void; private readonly onChanged: () => void; + private currentThemeSetting: string | undefined; private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme; private activeThemeName: string | undefined; private autoSyncEnabled = false; private terminalColorSchemeUnsubscribe: (() => void) | undefined; - constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) { + constructor( + ui: TUI, + options: { + getSettingsManager: () => SettingsManager; + showError: (message: string) => void; + onChanged: () => void; + initialThemeSetting?: string; + }, + ) { this.ui = ui; - this.settingsManager = settingsManager; - this.showError = showError; - this.onChanged = onChanged; - this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme); + this.getSettingsManager = options.getSettingsManager; + this.showError = options.showError; + this.onChanged = options.onChanged; + this.currentThemeSetting = options.initialThemeSetting; + this.activeThemeName = resolveThemeSetting( + this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting(), + this.terminalTheme, + ); initTheme(this.activeThemeName, true); this.bindTerminalColorSchemeListener(); } @@ -42,7 +55,8 @@ export class InteractiveThemeController { } async applyFromSettings(): Promise { - const themeSetting = this.settingsManager.getThemeSetting(); + const settingsManager = this.getSettingsManager(); + const themeSetting = this.currentThemeSetting ?? settingsManager.getThemeSetting(); const autoTheme = parseAutoThemeSetting(themeSetting); if (autoTheme) { this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 }); @@ -61,14 +75,27 @@ export class InteractiveThemeController { this.terminalTheme = detection.theme; if (!this.applyThemeName(detection.theme).success) return; if (detection.confidence === "high") { - this.settingsManager.setTheme(detection.theme); - await this.settingsManager.flush(); + settingsManager.setTheme(detection.theme); + await settingsManager.flush(); } } + getThemeSelection(): string | undefined { + return this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting() ?? this.activeThemeName; + } + setThemeName(themeName: string, showError = false): ThemeResult { this.setAutoSync(false); - return this.applyThemeName(themeName, showError); + const result = this.applyThemeName(themeName, showError); + if (result.success) { + this.currentThemeSetting = themeName; + } + return result; + } + + async setThemeSetting(themeSetting: string): Promise { + this.currentThemeSetting = themeSetting; + await this.applyFromSettings(); } setThemeInstance(themeInstance: Theme): ThemeResult { @@ -126,7 +153,7 @@ export class InteractiveThemeController { private applyTerminalTheme(terminalTheme: TerminalTheme): void { if (!this.autoSyncEnabled) return; this.terminalTheme = terminalTheme; - const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting()); + const autoTheme = parseAutoThemeSetting(this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting()); if (!autoTheme) { this.setAutoSync(false); return; diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 039bdc15f31..5348ab7de7a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (thinkingMax and scrollbarThumb are optional and use compatible fallbacks)", + "description": "Theme color definitions (thinkingMax, scrollbarThumb, and search highlight colors are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -141,6 +141,14 @@ "$ref": "#/$defs/colorValue", "description": "Fullscreen scrollbar thumb background (falls back to selectedBg when omitted)" }, + "searchMatchBg": { + "$ref": "#/$defs/colorValue", + "description": "Transcript search match background and current-match text (falls back to selectedBg when omitted)" + }, + "searchMatchText": { + "$ref": "#/$defs/colorValue", + "description": "Transcript search match text and current-match background (falls back to text when omitted)" + }, "userMessageBg": { "$ref": "#/$defs/colorValue", "description": "User message background" diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index b3867862965..c49bad29eb6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -16,6 +16,7 @@ import { getCustomThemesDir, getThemesDir } from "../../../config.ts"; import type { SourceInfo } from "../../../core/source-info.ts"; import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts"; import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; +import { stripBom } from "../../../utils/text.ts"; // ============================================================================ // Types & Schema @@ -45,9 +46,11 @@ const ThemeJsonSchema = Type.Object({ dim: ColorValueSchema, text: ColorValueSchema, thinkingText: ColorValueSchema, - // Backgrounds & Content Text (11 required, 1 optional) + // Backgrounds & Content Text (11 required, 3 optional) selectedBg: ColorValueSchema, scrollbarThumb: Type.Optional(ColorValueSchema), + searchMatchBg: Type.Optional(ColorValueSchema), + searchMatchText: Type.Optional(ColorValueSchema), userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, customMessageBg: ColorValueSchema, @@ -119,6 +122,7 @@ export type ThemeColor = | "dim" | "text" | "thinkingText" + | "searchMatchText" | "userMessageText" | "customMessageText" | "customMessageLabel" @@ -158,12 +162,16 @@ export type ThemeColor = export type ThemeBg = | "selectedBg" | "scrollbarThumb" + | "searchMatchBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; +type OptionalThemeColor = "thinkingMax" | "searchMatchText"; +type OptionalThemeBg = "scrollbarThumb" | "searchMatchBg"; + type ColorMode = "truecolor" | "256color"; // ============================================================================ @@ -321,13 +329,18 @@ function resolveThemeColors>( return resolved as Record; } -function withThemeColorFallbacks( - colors: ThemeJson["colors"], -): ThemeJson["colors"] & { thinkingMax: ColorValue; scrollbarThumb: ColorValue } { +function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { + thinkingMax: ColorValue; + scrollbarThumb: ColorValue; + searchMatchBg: ColorValue; + searchMatchText: ColorValue; +} { return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg, + searchMatchBg: colors.searchMatchBg ?? colors.selectedBg, + searchMatchText: colors.searchMatchText ?? colors.text, }; } @@ -344,9 +357,10 @@ export class Theme { private mode: ColorMode; constructor( - fgColors: Record, - bgColors: Record, string | number> & - Partial>, + fgColors: Record, string | number> & + Partial>, + bgColors: Record, string | number> & + Partial>, mode: ColorMode, options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, ) { @@ -355,7 +369,11 @@ export class Theme { this.sourceInfo = options.sourceInfo; this.mode = mode; this.fgColors = new Map(); - const colors = { ...fgColors, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh }; + const colors = { + ...fgColors, + thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, + searchMatchText: fgColors.searchMatchText ?? fgColors.text, + }; for (const [key, value] of Object.entries(colors) as [ThemeColor, string | number][]) { this.fgColors.set(key, fgAnsi(value, mode)); } @@ -363,6 +381,7 @@ export class Theme { const backgrounds = { ...bgColors, scrollbarThumb: bgColors.scrollbarThumb ?? bgColors.selectedBg, + searchMatchBg: bgColors.searchMatchBg ?? bgColors.selectedBg, }; for (const [key, value] of Object.entries(backgrounds) as [ThemeBg, string | number][]) { this.bgColors.set(key, bgAnsi(value, mode)); @@ -456,8 +475,8 @@ function getBuiltinThemes(): Record { const darkPath = path.join(themesDir, "dark.json"); const lightPath = path.join(themesDir, "light.json"); BUILTIN_THEMES = { - dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, - light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, + dark: JSON.parse(stripBom(fs.readFileSync(darkPath, "utf-8"))) as ThemeJson, + light: JSON.parse(stripBom(fs.readFileSync(lightPath, "utf-8"))) as ThemeJson, }; } return BUILTIN_THEMES; @@ -578,7 +597,7 @@ function parseThemeJson(label: string, json: unknown): ThemeJson { function parseThemeJsonContent(label: string, content: string): ThemeJson { let json: unknown; try { - json = JSON.parse(content); + json = JSON.parse(stripBom(content)); } catch (error) { throw new Error(`Failed to parse theme ${label}: ${error}`); } @@ -615,6 +634,7 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string const bgColorKeys: Set = new Set([ "selectedBg", "scrollbarThumb", + "searchMatchBg", "userMessageBg", "customMessageBg", "toolPendingBg", @@ -793,13 +813,21 @@ export async function detectTerminalThemeForAuto({ timeoutMs, env, }: TerminalAutoThemeDetectionOptions): Promise { + let colorSchemePromise: Promise | undefined; + try { + colorSchemePromise = ui.queryTerminalColorScheme?.({ timeoutMs }); + } catch { + // Fall back to OSC 11 / COLORFGBG detection when starting the color-scheme query fails. + } + const backgroundThemePromise = detectTerminalBackgroundTheme({ ui, timeoutMs, env }); + try { - const colorScheme = await ui.queryTerminalColorScheme?.({ timeoutMs }); + const colorScheme = await colorSchemePromise; if (colorScheme) return colorScheme; } catch { - // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported. + // Fall back to the concurrently queried OSC 11 / COLORFGBG detection. } - return (await detectTerminalBackgroundTheme({ ui, timeoutMs, env })).theme; + return (await backgroundThemePromise).theme; } export function getDefaultTheme(): string { diff --git a/packages/coding-agent/src/modes/json-event.ts b/packages/coding-agent/src/modes/json-event.ts index 6cff8ebf27f..c0c04fde0d7 100644 --- a/packages/coding-agent/src/modes/json-event.ts +++ b/packages/coding-agent/src/modes/json-event.ts @@ -1,27 +1,47 @@ +import type { Usage } from "@earendil-works/pi-ai"; import type { AgentSessionEvent } from "../core/agent-session.ts"; type WithoutPartial = T extends { partial: unknown } ? Omit : T; -type ToJsonEvent = T extends { +type ToJsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; + +type MessageUpdateEvent = Extract; +type JsonMessageUpdateEvent = { type: "message_update"; - assistantMessageEvent: infer TAssistantMessageEvent; -} - ? { - type: "message_update"; - assistantMessageEvent: WithoutPartial; - } - : T; + usage: Usage; + assistantMessageEvent: ToJsonAssistantMessageEvent; +}; /** Session event shape emitted by the JSON and RPC stdout protocols. */ -export type JsonAgentSessionEvent = ToJsonEvent; +export type JsonAgentSessionEvent = Exclude | JsonMessageUpdateEvent; -type MessageUpdateEvent = Extract; -type JsonMessageUpdateEvent = Extract; +function toJsonAssistantMessageEvent( + event: MessageUpdateEvent["assistantMessageEvent"], +): JsonMessageUpdateEvent["assistantMessageEvent"] { + if (event.type === "toolcall_start") { + const toolCall = event.partial.content[event.contentIndex]; + if (toolCall?.type !== "toolCall") { + throw new Error(`toolcall_start content at index ${event.contentIndex} is not a tool call`); + } + const { partial: _partial, ...deltaEvent } = event; + return { ...deltaEvent, id: toolCall.id, toolName: toolCall.name }; + } + + if (!("partial" in event)) { + return event; + } + + const { partial: _partial, ...deltaEvent } = event; + return deltaEvent; +} /** * Remove cumulative assistant snapshots from streaming wire events. * `message_start` provides the initial message, deltas build it, and - * `message_end` provides the final authoritative message. + * `message_end` provides the final authoritative message. Cumulative usage, + * tool-call ids, and tool names remain available because their size is constant. */ export function toJsonEvent(event: MessageUpdateEvent): JsonMessageUpdateEvent; export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent; @@ -29,12 +49,13 @@ export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent { if (event.type !== "message_update") { return event; } - - const assistantMessageEvent = event.assistantMessageEvent; - if (!("partial" in assistantMessageEvent)) { - return { type: "message_update", assistantMessageEvent }; + if (event.message.role !== "assistant") { + throw new Error("message_update message is not an assistant message"); } - const { partial: _partial, ...deltaEvent } = assistantMessageEvent; - return { type: "message_update", assistantMessageEvent: deltaEvent }; + return { + type: "message_update", + usage: event.message.usage, + assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent), + }; } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index aedfdf0b9b4..53239daf852 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,6 +1,17 @@ -import { join } from "node:path"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; +import lockfile from "proper-lockfile"; import { selectConfig } from "./cli/config-selector.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { @@ -23,7 +34,9 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; -import { spawnProcess } from "./utils/child-process.ts"; +import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts"; +import { canonicalizePath, getCwdRelativePath } from "./utils/paths.ts"; +import { getPiUserAgent } from "./utils/pi-user-agent.ts"; import { formatVersionCheckError, getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { cleanupWindowsSelfUpdateQuarantine, @@ -34,6 +47,179 @@ export type PackageCommand = "install" | "remove" | "update" | "list"; type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" }; +const DEFAULT_INSTALLER_API_BASE = "https://pi.dev/api/installer/releases"; +const MANAGED_INSTALL_MARKER = "managed-install.json"; +const MANAGED_RELEASE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function getActiveManagedInstallRoot(): string | undefined { + const configuredRoot = process.env.PI_MANAGED_INSTALL_ROOT?.trim(); + if (!configuredRoot) return undefined; + + const managedRoot = resolve(configuredRoot); + const releasesDir = canonicalizePath(join(managedRoot, "releases")); + // The launcher environment is inherited by child processes. Do not classify a + // source checkout or another Pi installation launched from managed Pi as managed. + if (getCwdRelativePath(canonicalizePath(getPackageDir()), releasesDir) === undefined) return undefined; + + const markerPath = join(managedRoot, MANAGED_INSTALL_MARKER); + try { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as { + kind?: unknown; + layout?: unknown; + schemaVersion?: unknown; + }; + if (marker.kind !== "pi-managed-install" || marker.schemaVersion !== 1 || marker.layout !== "releases-v1") { + throw new Error(); + } + } catch { + throw new Error(`Managed install marker is missing or invalid: ${markerPath}`); + } + + return managedRoot; +} + +async function fetchInstallerArtifact(url: string, label: string): Promise { + const response = await fetch(url, { headers: { "User-Agent": getPiUserAgent(VERSION) } }); + if (!response.ok) { + throw new Error(`Could not download managed installer ${label} from ${url}: HTTP ${response.status}`); + } + return await response.text(); +} + +async function runManagedNpmCi(stageDir: string): Promise { + const args = [ + "ci", + "--ignore-scripts", + "--min-release-age=0", + "--omit=dev", + "--include=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + "--progress=false", + ]; + const code = await waitForChildProcess(spawnProcess("npm", args, { cwd: stageDir, stdio: "inherit" })); + if (code !== 0) throw new Error(`npm ${args.join(" ")} exited with code ${code ?? "unknown"}`); +} + +function verifyManagedRelease(releaseDir: string, expectedVersion: string): void { + const binPath = join( + releaseDir, + "node_modules", + ".bin", + process.platform === "win32" ? `${APP_NAME}.cmd` : APP_NAME, + ); + const result = spawnProcessSync(binPath, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) { + const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; + throw new Error(`Could not verify managed Pi ${expectedVersion}: ${reason}`); + } + const installedVersion = result.stdout.trim(); + if (installedVersion !== expectedVersion) { + throw new Error(`Managed Pi smoke test returned version ${installedVersion}; expected ${expectedVersion}.`); + } +} + +function activateManagedRelease(managedRoot: string, version: string): void { + const currentPath = join(managedRoot, "current-version"); + const temporaryPath = join(managedRoot, `current-version.tmp.${process.pid}-${Date.now()}`); + try { + writeFileSync(temporaryPath, `${version}\n`); + renameSync(temporaryPath, currentPath); + } finally { + rmSync(temporaryPath, { force: true }); + } +} + +function cleanupManagedStaging(managedRoot: string): void { + const stagingRoot = join(managedRoot, "staging"); + try { + for (const entry of readdirSync(stagingRoot)) { + if (entry.startsWith("update-")) { + rmSync(join(stagingRoot, entry), { force: true, recursive: true }); + } + } + } catch { + // The staging directory does not exist yet or is not writable. + } +} + +export function cleanupManagedInstall(): void { + let managedRoot: string | undefined; + try { + managedRoot = getActiveManagedInstallRoot(); + } catch { + return; + } + if (!managedRoot) return; + + try { + const releaseLock = lockfile.lockSync(join(managedRoot, "update"), { realpath: false }); + try { + cleanupManagedStaging(managedRoot); + } finally { + releaseLock(); + } + } catch { + // A live update owns the staging directory, or cleanup is unavailable. + } +} + +async function runManagedSelfUpdate(managedRoot: string, version: string): Promise { + if (!MANAGED_RELEASE_VERSION_RE.test(version)) { + throw new Error(`Invalid managed release version: ${version}`); + } + + let releaseLock: () => Promise; + try { + releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code === "ELOCKED") { + throw new Error("Another managed Pi update is already running."); + } + throw error; + } + + let stageDir: string | undefined; + try { + cleanupManagedStaging(managedRoot); + const installerApiBase = (process.env.PI_INSTALLER_API_BASE?.trim() || DEFAULT_INSTALLER_API_BASE).replace( + /\/+$/, + "", + ); + const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`; + const stagingRoot = join(managedRoot, "staging"); + const releasesRoot = join(managedRoot, "releases"); + mkdirSync(releasesRoot, { recursive: true }); + const releaseDir = join(releasesRoot, version); + if (existsSync(releaseDir)) { + verifyManagedRelease(releaseDir, version); + activateManagedRelease(managedRoot, version); + return; + } + + mkdirSync(stagingRoot, { recursive: true }); + stageDir = mkdtempSync(join(stagingRoot, "update-")); + const [packageJsonContent, packageLockContent] = await Promise.all([ + fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"), + fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"), + ]); + writeFileSync(join(stageDir, "package.json"), packageJsonContent); + writeFileSync(join(stageDir, "package-lock.json"), packageLockContent); + + await runManagedNpmCi(stageDir); + verifyManagedRelease(stageDir, version); + renameSync(stageDir, releaseDir); + activateManagedRelease(managedRoot, version); + } finally { + if (stageDir) rmSync(stageDir, { force: true, recursive: true }); + await releaseLock(); + } +} + const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = { heading: (text) => chalk.bold(chalk.yellow(text)), link: (text) => chalk.cyan(text), @@ -432,7 +618,7 @@ function printSelfUpdateUnavailable( const entrypoint = process.argv[1]; if (entrypoint) { console.error(""); - console.error(`Location of pi executable: ${entrypoint}`); + console.error(`Location of ${APP_NAME} executable: ${entrypoint}`); } } @@ -834,10 +1020,37 @@ export async function handlePackageCommand( } } if (updateTargetIncludesSelf(target)) { + const managedInstallRoot = getActiveManagedInstallRoot(); + if (managedInstallRoot && options.force) { + console.error( + chalk.red( + `Managed ${APP_NAME} installations do not support --force; rerun the installer to repair this installation.`, + ), + ); + process.exitCode = 1; + return true; + } const selfUpdatePlan = await getSelfUpdatePlan(options.force); if (!selfUpdatePlan.shouldRun) { return true; } + if (managedInstallRoot) { + if (selfUpdatePlan.note) { + printSelfUpdateNote(selfUpdatePlan.note); + } + try { + console.log(chalk.dim(`Updating managed ${APP_NAME} installation...`)); + await runManagedSelfUpdate(managedInstallRoot, selfUpdatePlan.version); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown managed update error"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + return true; + } + console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`)); + return true; + } + const installMethod = detectInstallMethod(); if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") { console.error( diff --git a/packages/coding-agent/src/server/create-harness.ts b/packages/coding-agent/src/server/create-harness.ts new file mode 100644 index 00000000000..3ad0bad237c --- /dev/null +++ b/packages/coding-agent/src/server/create-harness.ts @@ -0,0 +1,161 @@ +import { + AgentHarness, + type AgentHarnessOptions, + type AgentHarnessTool, + createBashTool, + createEditTool, + createReadTool, + createWriteTool, + type ExecutionEnv, + type ExecutionToolContext, + type HarnessTool, +} from "@earendil-works/pi-agent-core"; +import type { Static, TSchema } from "typebox"; +import { getExperimentalToolSampling } from "../core/experimental.ts"; +import { type BuildSystemPromptOptions, buildSystemPrompt } from "../core/system-prompt.ts"; +import { bashToolSystemPromptContribution } from "../core/tools/bash.ts"; +import { editToolSystemPromptContribution } from "../core/tools/edit.ts"; +import { readToolSystemPromptContribution } from "../core/tools/read.ts"; +import { writeToolSystemPromptContribution } from "../core/tools/write.ts"; + +export interface CodingAgentHarnessTool extends HarnessTool { + promptSnippet?: string; + promptGuidelines?: readonly string[]; +} + +function createCodingAgentHarnessTool( + tool: AgentHarnessTool, + context: ExecutionToolContext, + prompt: Required>, +): CodingAgentHarnessTool { + return { + ...tool, + ...prompt, + constrainedSampling: getExperimentalToolSampling(), + execute: (toolCallId, params, signal, onUpdate) => + tool.execute(toolCallId, params as Static, signal, onUpdate, context), + }; +} + +export interface CreateCodingAgentHarnessOptions extends Omit { + env: ExecutionEnv; + bashCommandPrefix?: string; + /** Path to the JSONL session file exposed to default bash commands as PI_SESSION_FILE. */ + sessionFile?: string; + tools?: CodingAgentHarnessTool[]; + systemPromptOptions?: Omit; +} + +export interface BuildCodingAgentHarnessSystemPromptOptions { + cwd: string; + tools: readonly CodingAgentHarnessTool[]; + activeToolNames: readonly string[]; + systemPromptOptions?: CreateCodingAgentHarnessOptions["systemPromptOptions"]; +} + +export function buildCodingAgentHarnessSystemPrompt(options: BuildCodingAgentHarnessSystemPromptOptions): string { + const activeTools = options.activeToolNames.flatMap((name) => { + const tool = options.tools.find((candidate) => candidate.name === name); + return tool ? [tool] : []; + }); + const toolSnippets = Object.fromEntries( + activeTools.flatMap((tool) => { + const promptSnippet = tool.promptSnippet + ?.replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return promptSnippet ? [[tool.name, promptSnippet]] : []; + }), + ); + const promptGuidelines = activeTools.flatMap((tool) => tool.promptGuidelines ?? []); + return buildSystemPrompt({ + ...options.systemPromptOptions, + cwd: options.cwd, + selectedTools: activeTools.map((tool) => tool.name), + toolSnippets, + promptGuidelines, + }); +} + +export async function createCodingAgentHarness(options: CreateCodingAgentHarnessOptions) { + const { + env, + bashCommandPrefix, + sessionFile, + systemPromptOptions, + tools: providedTools, + activeToolNames: providedActiveToolNames, + systemPrompt: providedSystemPrompt, + ...harnessOptions + } = options; + let harness: AgentHarness | undefined; + const getHarness = (): AgentHarness => { + if (!harness) throw new Error("Coding-agent Harness callback ran before Harness initialization"); + return harness; + }; + let tools = providedTools; + if (tools === undefined) { + const metadata = await options.session.getMetadata(); + const toolContext = { env } satisfies ExecutionToolContext; + tools = [ + createCodingAgentHarnessTool(createReadTool(), toolContext, { + promptSnippet: readToolSystemPromptContribution.snippet, + promptGuidelines: readToolSystemPromptContribution.guidelines, + }), + createCodingAgentHarnessTool( + createBashTool({ + commandPrefix: bashCommandPrefix, + prepare: async (execution) => { + const currentHarness = getHarness(); + const [model, thinkingLevel] = await Promise.all([ + currentHarness.getModel(), + currentHarness.getThinkingLevel(), + ]); + execution.env.PI_SESSION_ID = metadata.id; + execution.env.PI_SESSION_FILE = sessionFile ?? ""; + execution.env.PI_PROVIDER = model.provider; + execution.env.PI_MODEL = model.id; + execution.env.PI_REASONING_LEVEL = thinkingLevel; + }, + }), + toolContext, + { + promptSnippet: bashToolSystemPromptContribution.snippet, + promptGuidelines: bashToolSystemPromptContribution.guidelines, + }, + ), + createCodingAgentHarnessTool(createEditTool(), toolContext, { + promptSnippet: editToolSystemPromptContribution.snippet, + promptGuidelines: editToolSystemPromptContribution.guidelines, + }), + createCodingAgentHarnessTool(createWriteTool(), toolContext, { + promptSnippet: writeToolSystemPromptContribution.snippet, + promptGuidelines: writeToolSystemPromptContribution.guidelines, + }), + ]; + } + const activeToolNames = [...(providedActiveToolNames ?? tools.map((tool) => tool.name))]; + const systemPrompt = + providedSystemPrompt ?? + (async () => { + const currentHarness = getHarness(); + const [currentTools, currentActiveToolNames] = await Promise.all([ + currentHarness.getTools(), + currentHarness.getActiveTools(), + ]); + return buildCodingAgentHarnessSystemPrompt({ + cwd: env.cwd, + tools: currentTools, + activeToolNames: currentActiveToolNames, + systemPromptOptions, + }); + }); + const created = await AgentHarness.create({ + ...harnessOptions, + tools, + activeToolNames, + systemPrompt, + }); + harness = created.harness; + return created; +} diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/coding-agent/src/utils/frontmatter.ts index 847e2e539ad..54481073763 100644 --- a/packages/coding-agent/src/utils/frontmatter.ts +++ b/packages/coding-agent/src/utils/frontmatter.ts @@ -1,4 +1,5 @@ import { parse } from "yaml"; +import { stripBom } from "./text.ts"; type ParsedFrontmatter> = { frontmatter: T; @@ -8,7 +9,7 @@ type ParsedFrontmatter> = { const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { - const normalized = normalizeNewlines(content); + const normalized = normalizeNewlines(stripBom(content)); if (!normalized.startsWith("---")) { return { yamlString: null, body: normalized }; diff --git a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts deleted file mode 100644 index 75e31da2873..00000000000 --- a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -declare module "highlight.js/lib/index.js" { - interface HighlightResult { - value: string; - } - - interface HighlightOptions { - language: string; - ignoreIllegals?: boolean; - } - - interface HighlightJs { - highlight(code: string, options: HighlightOptions): HighlightResult; - highlightAuto(code: string, languageSubset?: string[]): HighlightResult; - getLanguage(name: string): unknown; - } - - const hljs: HighlightJs; - export default hljs; -} diff --git a/packages/coding-agent/src/utils/highlight-js.d.ts b/packages/coding-agent/src/utils/highlight-js.d.ts new file mode 100644 index 00000000000..d4b71174e7f --- /dev/null +++ b/packages/coding-agent/src/utils/highlight-js.d.ts @@ -0,0 +1,36 @@ +interface HighlightJsResult { + value: string; +} + +interface HighlightJsOptions { + language: string; + ignoreIllegals?: boolean; +} + +interface HighlightJsLanguageDefinition { + readonly name?: string; +} + +type HighlightJsLanguageFactory = (hljs: HighlightJsApi) => HighlightJsLanguageDefinition; + +interface HighlightJsApi { + highlight(code: string, options: HighlightJsOptions): HighlightJsResult; + highlightAuto(code: string, languageSubset?: string[]): HighlightJsResult; + getLanguage(name: string): HighlightJsLanguageDefinition | undefined; + registerLanguage(name: string, language: HighlightJsLanguageFactory): void; +} + +declare module "highlight.js/lib/core.js" { + const hljs: HighlightJsApi; + export default hljs; +} + +declare module "highlight.js/lib/index.js" { + const hljs: HighlightJsApi; + export default hljs; +} + +declare module "highlight.js/lib/languages/*.js" { + const language: HighlightJsLanguageFactory; + export default language; +} diff --git a/packages/coding-agent/src/utils/management-http.ts b/packages/coding-agent/src/utils/management-http.ts index bbaab939412..9a7da8144d4 100644 --- a/packages/coding-agent/src/utils/management-http.ts +++ b/packages/coding-agent/src/utils/management-http.ts @@ -7,8 +7,10 @@ export interface FetchRetryOptions { maxRetries?: number; /** Retry transient HTTP responses as well as transport failures. Defaults to true. */ retryOnStatus?: boolean; - /** Per-attempt timeout. A new timeout is created for every attempt. */ + /** Overall time budget shared by all attempts. */ timeoutMs?: number; + /** Per-attempt timeout. A new timeout is created for every attempt. */ + attemptTimeoutMs?: number; } /** @@ -19,8 +21,8 @@ export interface FetchRetryOptions { * agent/model operations: those can fail after the HTTP request starts and are * retried by their semantic caller instead. * - * Caller cancellation is terminal. When timeoutMs is supplied, it is the - * overall time budget shared by all attempts. + * Caller cancellation and timeoutMs are terminal. attemptTimeoutMs aborts + * only the current attempt so a hung connection can be retried. */ export async function fetchWithRetry( input: FetchInput, @@ -32,17 +34,20 @@ export async function fetchWithRetry( ? 2 : Math.max(0, Math.floor(options.maxRetries)); const retryOnStatus = options.retryOnStatus ?? true; - const parentSignal = init?.signal; + const parentSignal = init?.signal ?? undefined; const timeoutSignal = options.timeoutMs !== undefined && options.timeoutMs > 0 ? AbortSignal.timeout(options.timeoutMs) : undefined; - const signal = timeoutSignal - ? parentSignal - ? AbortSignal.any([parentSignal, timeoutSignal]) - : timeoutSignal - : parentSignal; + const attemptTimeoutMs = + options.attemptTimeoutMs !== undefined && options.attemptTimeoutMs > 0 ? options.attemptTimeoutMs : undefined; for (let attempt = 0; ; attempt++) { - signal?.throwIfAborted(); + parentSignal?.throwIfAborted(); + timeoutSignal?.throwIfAborted(); + const attemptTimeoutSignal = attemptTimeoutMs ? AbortSignal.timeout(attemptTimeoutMs) : undefined; + const signals = [parentSignal, timeoutSignal, attemptTimeoutSignal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0]; try { const response = await fetch(input, signal ? { ...init, signal } : init); @@ -55,10 +60,15 @@ export async function fetchWithRetry( // do if cancelling its body also fails. } } catch (error) { + const attemptTimedOut = + attemptTimeoutSignal?.aborted === true && !parentSignal?.aborted && !timeoutSignal?.aborted; if ( parentSignal?.aborted || timeoutSignal?.aborted || - (error instanceof Error && error.name === "AbortError" && timeoutSignal === undefined) || + (error instanceof Error && + error.name === "AbortError" && + !attemptTimedOut && + timeoutSignal === undefined) || attempt >= maxRetries ) { throw error; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 2cafa595eab..fae75267ec0 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -21,11 +21,11 @@ function getBashShellConfig(shell: string): ShellConfig { return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; } -function findBashOnPath(): string | null { +function findExecutableOnPath(executable: string): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { + const result = spawnSync("where", [executable], { encoding: "utf-8", timeout: 5000, windowsHide: true, @@ -44,7 +44,7 @@ function findBashOnPath(): string | null { // Unix: Use 'which' and trust its output (handles Termux and special filesystems) try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("which", [executable], { encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch) { @@ -92,7 +92,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash.exe"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -111,7 +111,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return getBashShellConfig("/bin/bash"); } - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -119,6 +119,22 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return { shell: "sh", args: ["-c"] }; } +export const POWERSHELL_ARGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"] as const; + +/** Resolve PowerShell on Windows, preferring PowerShell 7 when available. */ +export function getPowerShellConfig(): ShellConfig { + if (process.platform !== "win32") { + throw new Error("The powershell tool is only available on Windows."); + } + + const shell = findExecutableOnPath("pwsh.exe") ?? findExecutableOnPath("powershell.exe"); + if (!shell) { + throw new Error("No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH."); + } + + return { shell, args: [...POWERSHELL_ARGS] }; +} + export function getShellEnv(): NodeJS.ProcessEnv { const binDir = getBinDir(); const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; diff --git a/packages/coding-agent/src/utils/syntax-highlight.ts b/packages/coding-agent/src/utils/syntax-highlight.ts index bcc4add1c37..080c128c207 100644 --- a/packages/coding-agent/src/utils/syntax-highlight.ts +++ b/packages/coding-agent/src/utils/syntax-highlight.ts @@ -1,6 +1,72 @@ -import hljs from "highlight.js/lib/index.js"; +import hljs from "highlight.js/lib/core.js"; +import bash from "highlight.js/lib/languages/bash.js"; +import c from "highlight.js/lib/languages/c.js"; +import cpp from "highlight.js/lib/languages/cpp.js"; +import csharp from "highlight.js/lib/languages/csharp.js"; +import dart from "highlight.js/lib/languages/dart.js"; +import go from "highlight.js/lib/languages/go.js"; +import groovy from "highlight.js/lib/languages/groovy.js"; +import java from "highlight.js/lib/languages/java.js"; +import javascript from "highlight.js/lib/languages/javascript.js"; +import kotlin from "highlight.js/lib/languages/kotlin.js"; +import lua from "highlight.js/lib/languages/lua.js"; +import nix from "highlight.js/lib/languages/nix.js"; +import perl from "highlight.js/lib/languages/perl.js"; +import php from "highlight.js/lib/languages/php.js"; +import python from "highlight.js/lib/languages/python.js"; +import ruby from "highlight.js/lib/languages/ruby.js"; +import rust from "highlight.js/lib/languages/rust.js"; +import scala from "highlight.js/lib/languages/scala.js"; +import swift from "highlight.js/lib/languages/swift.js"; +import typescript from "highlight.js/lib/languages/typescript.js"; import { decodeHtmlEntityAt } from "./html.ts"; +const eagerLanguages = { + python, + java, + go, + javascript, + cpp, + typescript, + php, + ruby, + c, + csharp, + nix, + bash, + rust, + scala, + kotlin, + swift, + dart, + groovy, + perl, + lua, +}; + +for (const [name, language] of Object.entries(eagerLanguages)) { + hljs.registerLanguage(name, language); +} + +let allLanguagesPromise: Promise | undefined; + +export function loadAllHighlightLanguages(): Promise { + if (!allLanguagesPromise) { + allLanguagesPromise = new Promise((resolve) => { + setImmediate(() => { + void import("highlight.js/lib/index.js").then( + () => resolve(), + () => { + // Eager languages and plaintext fallback remain available. + resolve(); + }, + ); + }); + }); + } + return allLanguagesPromise; +} + export type HighlightFormatter = (text: string) => string; export type HighlightTheme = Partial>; diff --git a/packages/coding-agent/src/utils/text.ts b/packages/coding-agent/src/utils/text.ts new file mode 100644 index 00000000000..737466e851b --- /dev/null +++ b/packages/coding-agent/src/utils/text.ts @@ -0,0 +1,9 @@ +/** Split a leading UTF-8 byte order mark from decoded text. */ +export function splitBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} + +/** Remove a leading UTF-8 byte order mark from decoded text. */ +export function stripBom(content: string): string { + return splitBom(content).text; +} diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index a177b4f64ce..23a71b5a171 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,4 +1,3 @@ -import chalk from "chalk"; import { type SpawnSyncReturns, spawnSync } from "child_process"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; @@ -323,9 +322,20 @@ const TERMUX_PACKAGES: Record = { rg: "ripgrep", }; -// Ensure a tool is available, downloading if necessary -// Returns the path to the tool, or null if unavailable -export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { +export interface ToolStatus { + type: "info" | "warning"; + message: string; +} + +/** + * Ensure a tool is available, downloading if necessary. + * Reports progress through `onStatus`; status messages are otherwise silent. + * Returns the tool path, or undefined if unavailable. + */ +export async function ensureTool( + tool: "fd" | "rg", + onStatus?: (status: ToolStatus) => void, +): Promise { const existingPath = getToolPath(tool); if (existingPath) { return existingPath; @@ -335,9 +345,7 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr if (!config) return undefined; if (isOfflineModeEnabled()) { - if (!silent) { - console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`)); - } + onStatus?.({ type: "warning", message: `${config.name} not found. Offline mode enabled, skipping download.` }); return undefined; } @@ -345,27 +353,22 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr // Users must install via pkg. if (platform() === "android") { const pkgName = TERMUX_PACKAGES[tool] ?? tool; - if (!silent) { - console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`)); - } + onStatus?.({ type: "warning", message: `${config.name} not found. Install with: pkg install ${pkgName}` }); return undefined; } // Tool not found - download it - if (!silent) { - console.log(chalk.dim(`${config.name} not found. Downloading...`)); - } + onStatus?.({ type: "info", message: `${config.name} not found. Downloading...` }); try { const path = await downloadTool(tool); - if (!silent) { - console.log(chalk.dim(`${config.name} installed to ${path}`)); - } + onStatus?.({ type: "info", message: `${config.name} installed to ${path}` }); return path; } catch (e) { - if (!silent) { - console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`)); - } + onStatus?.({ + type: "warning", + message: `Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`, + }); return undefined; } } diff --git a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts index 719550fedb5..5edc1f6c097 100644 --- a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts @@ -74,7 +74,7 @@ describe("AgentSession dynamic tool registration", () => { const bashTool = session.agent.state.tools.find((tool) => tool.name === "bash")!; expect(session.systemPrompt).toContain( - "Inspect PI_* environment variables for current model and session details.", + "You can inspect PI_* environment variables for current model and session details.", ); await bashTool.execute("bash-env", { command: "printf ok" }); expect(sessionEnv).toMatchObject({ diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 575d3666e34..710ed7b09d0 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseArgs } from "../src/cli/args.ts"; +import { normalizeSessionName, parseArgs } from "../src/cli/args.ts"; describe("parseArgs", () => { describe("--version flag", () => { @@ -173,6 +173,11 @@ describe("parseArgs", () => { expect(result.name).toBe(""); }); + test("normalizes display names and rejects whitespace-only values", () => { + expect(normalizeSessionName(" named session ")).toBe("named session"); + expect(normalizeSessionName(" ")).toBeUndefined(); + }); + test("reports missing value", () => { const result = parseArgs(["--name"]); expect(result.diagnostics).toEqual([{ type: "error", message: "--name requires a value" }]); @@ -192,6 +197,21 @@ describe("parseArgs", () => { const result = parseArgs(["--no-session"]); expect(result.noSession).toBe(true); }); + + test("preserves custom session IDs for non-persisting commands", () => { + expect(parseArgs(["--session-id", "ephemeral-id", "--help"])).toMatchObject({ + sessionId: "ephemeral-id", + help: true, + }); + expect(parseArgs(["--session-id", "ephemeral-id", "--list-models"])).toMatchObject({ + sessionId: "ephemeral-id", + listModels: true, + }); + expect(parseArgs(["--session-id", "ephemeral-id", "--no-session"])).toMatchObject({ + sessionId: "ephemeral-id", + noSession: true, + }); + }); }); describe("--extension flag", () => { @@ -260,6 +280,20 @@ describe("parseArgs", () => { }); }); + describe("--use-theme flag", () => { + test("parses --use-theme", () => { + const result = parseArgs(["--use-theme", "light"]); + expect(result.useTheme).toBe("light"); + }); + + test("reports when the theme name value is missing", () => { + const result = parseArgs(["--use-theme", "--print"]); + expect(result.useTheme).toBeUndefined(); + expect(result.print).toBe(true); + expect(result.diagnostics).toEqual([{ type: "error", message: "--use-theme requires a theme name" }]); + }); + }); + describe("--no-skills flag", () => { test("parses --no-skills flag", () => { const result = parseArgs(["--no-skills"]); diff --git a/packages/coding-agent/test/auth-check.test.ts b/packages/coding-agent/test/auth-check.test.ts new file mode 100644 index 00000000000..5f1ce632797 --- /dev/null +++ b/packages/coding-agent/test/auth-check.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryModelsStore } from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { parseArgs } from "../src/cli/args.ts"; +import { checkProviderAuth, createAuthCheckModelRuntime, getProviderCredential } from "../src/cli/auth-check.ts"; +import { parseAuthCommand } from "../src/cli/auth-command.ts"; +import { AuthStorage, ReadOnlyAuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRuntime } from "../src/core/model-runtime.ts"; + +const tempDir = join(tmpdir(), `pi-test-auth-check-${Date.now()}-${Math.random().toString(36).slice(2)}`); + +async function createRuntime(credentials: AuthStorage | ReadOnlyAuthStorage): Promise { + return ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + refreshOnCreate: false, + }); +} + +describe("auth check command", () => { + beforeEach(() => { + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + }); + + test("reports a configured provider as ready", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } })); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "ready", + provider: "openai", + authType: "api_key", + }); + }); + + test("resolves the provider from --model", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } })); + + await expect(checkProviderAuth(parseArgs(["--model", "openai/gpt-5.5"]), runtime)).resolves.toEqual({ + status: "ready", + provider: "openai", + authType: "api_key", + }); + await expect( + checkProviderAuth(parseArgs(["--provider", "openai", "--model", "gpt-5.5"]), runtime), + ).resolves.toMatchObject({ status: "ready", provider: "openai" }); + }); + + test("reads credentials without refreshing OAuth when requested", async () => { + const apiCredentials = AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } }); + const apiRuntime = await createRuntime(apiCredentials); + await expect(getProviderCredential("openai", apiRuntime, apiCredentials, { refresh: false })).resolves.toBe( + "test-key", + ); + + const credentials = AuthStorage.inMemory({ + "openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 }, + }); + const oauthRuntime = await createRuntime(credentials); + const oauth = oauthRuntime.getProvider("openai-codex")?.auth.oauth; + if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); + const refresh = vi.fn(oauth.refresh); + oauth.refresh = refresh; + + await expect(getProviderCredential("openai-codex", oauthRuntime, credentials, { refresh: false })).resolves.toBe( + "old-token", + ); + expect(refresh).not.toHaveBeenCalled(); + }); + + test("refreshes OAuth by default", async () => { + const credentials = AuthStorage.inMemory({ + "openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 }, + }); + const runtime = await createRuntime(credentials); + const oauth = runtime.getProvider("openai-codex")?.auth.oauth; + if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); + const refresh = vi.fn(async () => ({ + type: "oauth" as const, + access: "fresh-token", + refresh: "refresh-token", + expires: Date.now() + 60 * 60 * 1000, + })); + oauth.refresh = refresh; + + await expect( + checkProviderAuth(parseArgs(["--provider", "openai-codex"]), runtime, { refresh: true }), + ).resolves.toMatchObject({ + status: "ready", + }); + expect(refresh).toHaveBeenCalledOnce(); + }); + + test("reports an unknown provider as not ready", async () => { + const runtime = await createRuntime(AuthStorage.inMemory()); + + await expect(checkProviderAuth(parseArgs(["--provider", "not-installed"]), runtime)).resolves.toEqual({ + status: "not_ready", + provider: "not-installed", + reason: "provider_not_found", + }); + }); + + test("does not treat an unresolved stored environment reference as configured", async () => { + const authPath = join(tempDir, "auth.json"); + writeFileSync(authPath, JSON.stringify({ openai: { type: "api_key", key: "$MISSING_AUTH_CHECK_KEY" } }), "utf-8"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "not_ready", + provider: "openai", + reason: "credentials_not_configured", + }); + }); + + test("reports malformed auth state as invalid", async () => { + const authPath = join(tempDir, "auth.json"); + writeFileSync(authPath, "{invalid-json", "utf-8"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "invalid", + provider: "openai", + reason: "invalid_state", + }); + }); + + test("does not create an auth file or its parent directory", async () => { + const authPath = join(tempDir, "agent", "auth.json"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toMatchObject({ + status: "not_ready", + reason: "credentials_not_configured", + }); + expect(existsSync(authPath)).toBe(false); + expect(existsSync(join(tempDir, "agent"))).toBe(false); + }); + + test("accepts optional JSON output, credential output, and --no-refresh", () => { + expect(parseAuthCommand(["auth", "check", "--provider", "openai"])).toEqual({ + kind: "check", + args: ["--provider", "openai"], + json: false, + credentials: false, + noRefresh: false, + }); + expect( + parseAuthCommand(["auth", "check", "--json", "--credentials", "--no-refresh", "--provider", "openai"]), + ).toEqual({ + kind: "check", + args: ["--provider", "openai"], + json: true, + credentials: true, + noRefresh: true, + }); + }); + + test("creates an auth-check runtime without catalog storage", async () => { + const runtime = await createAuthCheckModelRuntime(AuthStorage.inMemory()); + expect(runtime.getProvider("openai")).toBeDefined(); + }); +}); diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 801697ee280..61cdbefba0f 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type CredentialStore, createModels, type Provider } from "@earendil-works/pi-ai"; @@ -139,6 +139,22 @@ describe("AuthStorage", () => { expect(release).toHaveBeenCalledTimes(1); }); + test.skipIf(process.platform === "win32")("creates new auth files with owner-only permissions", () => { + AuthStorage.create(authJsonPath); + + expect(statSync(authJsonPath).mode & 0o777).toBe(0o600); + }); + + test.skipIf(process.platform === "win32")("preserves the mode of an existing auth file", async () => { + writeAuthJson({ anthropic: { type: "api_key", key: "old" } }); + chmodSync(authJsonPath, 0o660); + const storage = AuthStorage.create(authJsonPath); + + await storage.modify("anthropic", async () => ({ type: "api_key", key: "new" })); + + expect(statSync(authJsonPath).mode & 0o777).toBe(0o660); + }); + test("modify persists a credential while preserving unrelated external edits", async () => { writeAuthJson({ anthropic: { type: "api_key", key: "old" } }); const storage = AuthStorage.create(authJsonPath); diff --git a/packages/coding-agent/test/branch-summarization.test.ts b/packages/coding-agent/test/branch-summarization.test.ts new file mode 100644 index 00000000000..e54da181fea --- /dev/null +++ b/packages/coding-agent/test/branch-summarization.test.ts @@ -0,0 +1,114 @@ +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { + type AssistantMessage, + createAssistantMessageEventStream, + fauxAssistantMessage, + type Model, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { generateBranchSummary } from "../src/core/compaction/index.ts"; +import type { SessionEntry } from "../src/core/session-manager.ts"; + +const model: Model<"anthropic-messages"> = { + id: "test-model", + name: "Test Model", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, +}; + +const entries: SessionEntry[] = [ + { + type: "message", + id: "branch-user", + parentId: null, + timestamp: new Date(1).toISOString(), + message: { role: "user", content: "Abandoned request", timestamp: 1 }, + }, +]; + +function response(content: AssistantMessage["content"]): AssistantMessage { + return { + ...fauxAssistantMessage(""), + content, + api: model.api, + provider: model.provider, + model: model.id, + }; +} + +describe("branch summarization", () => { + it("disables tools for branch summaries", async () => { + let requestOptions: SimpleStreamOptions | undefined; + const streamFn: StreamFn = (_model, _context, options) => { + requestOptions = options; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => + stream.push({ type: "done", reason: "stop", message: response([{ type: "text", text: "summary" }]) }), + ); + return stream; + }; + + await generateBranchSummary(entries, { + model, + signal: new AbortController().signal, + streamFn, + }); + + expect(requestOptions?.toolChoice).toBe("none"); + }); + + it("rejects tool calls from branch summaries", async () => { + const streamFn: StreamFn = () => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => + stream.push({ + type: "done", + reason: "toolUse", + message: response([ + { type: "toolCall", id: "tool-call-1", name: "read", arguments: { path: "README.md" } }, + ]), + }), + ); + return stream; + }; + + const result = await generateBranchSummary(entries, { + model, + signal: new AbortController().signal, + streamFn, + }); + + expect(result.error).toBe("Branch summarization attempted to call a tool"); + }); + + it("rejects length-limited branch summaries", async () => { + const streamFn: StreamFn = () => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => + stream.push({ + type: "done", + reason: "length", + message: { ...response([{ type: "text", text: "partial" }]), stopReason: "length" }, + }), + ); + return stream; + }; + + const result = await generateBranchSummary(entries, { + model, + signal: new AbortController().signal, + streamFn, + }); + + expect(result.error).toBe( + "Branch summarization failed: generation hit the token cap and the summary is incomplete", + ); + }); +}); diff --git a/packages/coding-agent/test/branch-summary-extensions.test.ts b/packages/coding-agent/test/branch-summary-extensions.test.ts index 9c47cb81aac..ecdc0179785 100644 --- a/packages/coding-agent/test/branch-summary-extensions.test.ts +++ b/packages/coding-agent/test/branch-summary-extensions.test.ts @@ -38,12 +38,14 @@ describe("Branch summary extensions", () => { const targetId = harness.sessionManager.appendMessage(userMsg("first branch")); harness.sessionManager.appendMessage(assistantMsg("first reply")); harness.sessionManager.appendMessage(userMsg("abandoned branch work")); - harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); + const sourceId = harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); const result = await harness.session.navigateTree(targetId, { summarize: true }); const summaryEntry = result.summaryEntry; expect(summaryEntry?.type).toBe("branch_summary"); + expect(summaryEntry?.parentId).toBeNull(); + expect(summaryEntry?.fromId).toBe(sourceId); expect(summaryEntry?.fromHook).toBe(true); expect(summaryEntry?.summary).toBe("Summary provided by extension"); expect(summaryEntry?.usage).toEqual(usage); diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index e79a55047e6..5a4d8e86b53 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -1,9 +1,10 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; +import type { AssistantMessage, Context, Model } from "@earendil-works/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { type CompactionPreparation, compact, + completeSummarization, generateSummary, generateSummaryWithUsage, } from "../src/core/compaction/index.ts"; @@ -20,7 +21,11 @@ vi.mock("@earendil-works/pi-ai/compat", async (importOriginal) => { }; }); -function createModel(reasoning: boolean, maxTokens = 8192): Model<"anthropic-messages"> { +function createModel( + reasoning: boolean, + maxTokens = 8192, + compat?: Model<"anthropic-messages">["compat"], +): Model<"anthropic-messages"> { return { id: reasoning ? "reasoning-model" : "non-reasoning-model", name: reasoning ? "Reasoning Model" : "Non-reasoning Model", @@ -32,6 +37,7 @@ function createModel(reasoning: boolean, maxTokens = 8192): Model<"anthropic-mes cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens, + ...(compat ? { compat } : {}), }; } @@ -53,6 +59,12 @@ const mockSummaryResponse: AssistantMessage = { timestamp: Date.now(), }; +const mockToolCallResponse: AssistantMessage = { + ...mockSummaryResponse, + content: [{ type: "toolCall", id: "tool-call-1", name: "read", arguments: { path: "README.md" } }], + stopReason: "toolUse", +}; + const messages: AgentMessage[] = [{ role: "user", content: "Summarize this.", timestamp: Date.now() }]; describe("generateSummary reasoning options", () => { @@ -97,11 +109,103 @@ describe("generateSummary reasoning options", () => { const requestOptions = completeSimpleMock.mock.calls.map((call) => call[2]); expect(requestOptions).toHaveLength(2); expect(requestOptions.every((options) => options?.cacheRetention === "none")).toBe(true); + expect(requestOptions.every((options) => options?.toolChoice === "none")).toBe(true); const sessionIds = requestOptions.map((options) => options?.sessionId); expect(sessionIds[0]).not.toBe(sessionIds[1]); }); + it("honors a caller-supplied routing session without prompt caching", async () => { + await completeSummarization( + createModel(false), + { systemPrompt: "Summarize", messages: [] }, + { sessionId: "current-routing-session", cacheRetention: "long", toolChoice: "auto" }, + ); + + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ + sessionId: "current-routing-session", + cacheRetention: "none", + toolChoice: "none", + }); + }); + + it("preserves the standalone split-turn summary prompt", async () => { + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await compact(preparation, createModel(false), "test-key"); + + const requestContext = completeSimpleMock.mock.calls[0][1] as Context; + const prompt = JSON.stringify(requestContext.messages); + expect(prompt).toContain("This is the PREFIX of a turn that was too large to keep"); + expect(prompt).toContain(""); + }); + + it("rejects tool calls from conversation summaries", async () => { + completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); + + await expect(generateSummaryWithUsage(messages, createModel(false), 2000, "test-key")).rejects.toThrow( + "Summarization attempted to call a tool", + ); + }); + + it("rejects tool calls from split-turn summaries", async () => { + completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await expect(compact(preparation, createModel(false), "test-key")).rejects.toThrow( + "Turn prefix summarization attempted to call a tool", + ); + }); + + it("rejects a length-limited history summary", async () => { + completeSimpleMock.mockResolvedValueOnce({ + ...mockSummaryResponse, + stopReason: "length", + content: [{ type: "text", text: "partial" }], + }); + + await expect(generateSummaryWithUsage(messages, createModel(false), 2000, "test-key")).rejects.toThrow( + "generation hit the token cap", + ); + }); + + it("rejects a length-limited split-turn summary", async () => { + completeSimpleMock.mockResolvedValueOnce({ + ...mockSummaryResponse, + stopReason: "length", + content: [{ type: "text", text: "partial" }], + }); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await expect(compact(preparation, createModel(false), "test-key")).rejects.toThrow( + "generation hit the token cap", + ); + }); + it("does not set reasoning when thinking is off", async () => { await generateSummary( messages, @@ -142,6 +246,33 @@ describe("generateSummary reasoning options", () => { expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning"); }); + it("leaves Anthropic refusal fallback handling to pi-ai model metadata", async () => { + await generateSummary( + messages, + createModel(true, 8192, { + allowedFallbackModels: [ + { + provider: "anthropic", + model: "claude-opus-4-8", + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ], + }), + 2000, + "test-key", + ); + + expect(completeSimpleMock).toHaveBeenCalledTimes(1); + expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks"); + }); + + it("does not set Anthropic refusal fallback for models without allowed fallback targets", async () => { + await generateSummary(messages, createModel(true), 2000, "test-key"); + + expect(completeSimpleMock).toHaveBeenCalledTimes(1); + expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks"); + }); + it("clamps compaction summary maxTokens to the model output cap", async () => { const preparation: CompactionPreparation = { firstKeptEntryId: "entry-keep", diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 49448cf119e..300f9e06ab1 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -4,6 +4,7 @@ import { delimiter, join } from "path"; import { afterEach, describe, expect, test } from "vitest"; import { detectInstallMethod, + findNodePackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, getUpdateInstruction, @@ -145,6 +146,19 @@ function createFakeBunScript(bunBin: string): string { return `#!/bin/sh\nif [ "$1" = "pm" ] && [ "$2" = "bin" ] && [ "$3" = "-g" ]; then\n\tprintf '%s\\n' '${escapedBunBin}'\n\texit 0\nfi\nexit 1\n`; } +describe("findNodePackageDir", () => { + test("skips binary metadata copied into dist", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-package-dir-")); + const distDir = join(tempDir, "dist"); + const bundleDir = join(distDir, "bundle"); + mkdirSync(bundleDir, { recursive: true }); + writeFileSync(join(tempDir, "package.json"), "{}"); + writeFileSync(join(distDir, "package.json"), "{}"); + + expect(findNodePackageDir(bundleDir)).toBe(tempDir); + }); +}); + describe("detectInstallMethod", () => { test("detects pnpm from Windows .pnpm install paths", () => { setExecPath( diff --git a/packages/coding-agent/test/credential-print.test.ts b/packages/coding-agent/test/credential-print.test.ts index be17a1b3e26..a1ca56d9c7e 100644 --- a/packages/coding-agent/test/credential-print.test.ts +++ b/packages/coding-agent/test/credential-print.test.ts @@ -1,14 +1,11 @@ import { InMemoryModelsStore } from "@earendil-works/pi-ai"; import { describe, expect, test, vi } from "vitest"; import { parseArgs } from "../src/cli/args.ts"; -import { - CredentialPrintError, - isCredentialPrintHelp, - parseCredentialPrintCommand, - resolveCredentialForPrint, -} from "../src/cli/credential-print.ts"; +import { AuthCommandError, isAuthCommandHelp, parseAuthCommand } from "../src/cli/auth-command.ts"; +import { resolveCredentialForPrint } from "../src/cli/credential-print.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; +import { main } from "../src/main.ts"; async function createRuntime(credentials: AuthStorage): Promise { return ModelRuntime.create({ @@ -22,7 +19,7 @@ async function createRuntime(credentials: AuthStorage): Promise { describe("credential print commands", () => { test("prints a resolved API key", async () => { const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-api-key" } })); - const args = parseArgs(["--model", "gpt-5.5"]); + const args = parseArgs(["--provider", "openai"]); await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key"); }); @@ -38,7 +35,7 @@ describe("credential print commands", () => { }, }), ); - const args = parseArgs(["--provider", "kimi-coding", "--model", "kimi-for-coding"]); + const args = parseArgs(["--provider", "kimi-coding"]); await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("header-test-token"); }); @@ -62,13 +59,31 @@ describe("credential print commands", () => { const oauth = runtime.getProvider("openai-codex")?.auth.oauth; if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); oauth.refresh = refresh; - const args = parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]); + const args = parseArgs(["--provider", "openai-codex"]); await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("fresh-test-token"); expect(refresh).toHaveBeenCalledOnce(); expect(await storage.read("openai-codex")).toMatchObject({ access: "fresh-test-token" }); }); + test("reports unknown auth options like package commands", async () => { + const originalExitCode = process.exitCode; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + process.exitCode = undefined; + await main(["auth", "check", "--provider", "openai-codex", "--credentails"]); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain('Unknown option --credentails for "auth check".'); + expect(stderr).toContain( + 'Use "pi --help" or "pi auth check --provider [--json] [--credentials] [--no-refresh]".', + ); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + } + }); + test("parses credential commands and rejects invalid arguments or credential types", async () => { const runtime = await createRuntime( AuthStorage.inMemory({ @@ -81,24 +96,35 @@ describe("credential print commands", () => { }), ); - expect(parseCredentialPrintCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({ + expect(parseAuthCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({ kind: "api_key", args: ["--provider", "openai"], + json: false, + credentials: false, + noRefresh: false, }); - expect(parseCredentialPrintCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" }); - expect(parseCredentialPrintCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({ + expect(parseAuthCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" }); + expect(parseAuthCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({ kind: "bearer_token", args: [], + json: false, + credentials: false, + noRefresh: false, minExpiryMs: 30 * 60_000, }); - expect(() => parseCredentialPrintCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow( + expect(() => parseAuthCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow( "only supported by print-bearer-token", ); - expect(isCredentialPrintHelp(["auth", "--help"])).toBe(true); - expect(() => parseCredentialPrintCommand(["auth", "unknown"])).toThrow(CredentialPrintError); - await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow("requires --model"); + expect(isAuthCommandHelp(["auth", "--help"])).toBe(true); + expect(isAuthCommandHelp(["auth", "print-api-key", "--help"])).toBe(true); + expect(isAuthCommandHelp(["auth", "print-bearer-token", "-h"])).toBe(true); + expect(isAuthCommandHelp(["auth", "check", "--help"])).toBe(true); + expect(() => parseAuthCommand(["auth", "unknown"])).toThrow(AuthCommandError); + await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow( + "requires --provider or --model ", + ); await expect( - resolveCredentialForPrint(parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]), runtime, "api_key"), + resolveCredentialForPrint(parseArgs(["--provider", "openai-codex"]), runtime, "api_key"), ).rejects.toThrow("configured with OAuth"); }); }); diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts new file mode 100644 index 00000000000..5b9331400a0 --- /dev/null +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -0,0 +1,159 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.ts"; +import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; +import { type CreateAgentSessionOptions, createAgentSession, type InlineExtension } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +type ToolOptions = Pick; + +describe("defaultTools setting", () => { + let tempDir: string; + let agentDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-default-tools-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + mkdirSync(agentDir, { recursive: true }); + }); + + afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + async function createSession( + defaultTools: string[], + options: ToolOptions = {}, + extensionFactories: InlineExtension[] = [], + ) { + const settingsManager = SettingsManager.inMemory({ defaultTools }); + const resourceLoader = new DefaultResourceLoader({ + cwd: tempDir, + agentDir, + settingsManager, + extensionFactories, + }); + await resourceLoader.reload(); + + return ( + await createAgentSession({ + cwd: tempDir, + agentDir, + model: getModel("anthropic", "claude-sonnet-4-5")!, + settingsManager, + sessionManager: SessionManager.inMemory(tempDir), + resourceLoader, + ...options, + }) + ).session; + } + + it("uses the configured list as the initial built-in selection", async () => { + const session = await createSession(["grep", "find"]); + + expect( + session + .getAllTools() + .map((tool) => tool.name) + .sort(), + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); + expect(session.getActiveToolNames()).toEqual(["grep", "find"]); + expect(session.systemPrompt).toContain("- grep:"); + expect(session.systemPrompt).not.toContain("- read:"); + session.dispose(); + }); + + it("can select powershell instead of bash", async () => { + const session = await createSession(["read", "powershell", "edit", "write"]); + + expect(session.getActiveToolNames()).toEqual(["read", "powershell", "edit", "write"]); + expect(session.systemPrompt).toContain("- powershell: Execute PowerShell commands"); + expect(session.systemPrompt).not.toContain("- bash:"); + session.dispose(); + }); + + it("keeps extension and SDK custom tools enabled", async () => { + const session = await createSession( + ["grep"], + { + customTools: [ + { + name: "sdk_tool", + label: "SDK Tool", + description: "SDK custom tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }, + ], + }, + [ + (pi) => { + pi.registerTool({ + name: "static_tool", + label: "Static Tool", + description: "Statically registered extension tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + pi.on("session_start", () => { + pi.registerTool({ + name: "dynamic_tool", + label: "Dynamic Tool", + description: "Dynamically registered extension tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + }); + }, + ], + ); + await session.bindExtensions({}); + + expect(session.getActiveToolNames().sort()).toEqual(["dynamic_tool", "grep", "sdk_tool", "static_tool"]); + expect(session.getAllTools().map((tool) => tool.name)).toEqual( + expect.arrayContaining(["read", "dynamic_tool", "sdk_tool", "static_tool"]), + ); + session.dispose(); + }); + + it("preserves explicit tool option precedence", async () => { + const allowlistedSession = await createSession(["grep"], { tools: ["read"] }); + expect(allowlistedSession.getActiveToolNames()).toEqual(["read"]); + allowlistedSession.dispose(); + + const excludedSession = await createSession(["read", "grep"], { excludeTools: ["read"] }); + expect(excludedSession.getActiveToolNames()).toEqual(["grep"]); + excludedSession.dispose(); + + const toolLessSession = await createSession(["read"], { noTools: "all" }); + expect(toolLessSession.getAllTools()).toEqual([]); + expect(toolLessSession.getActiveToolNames()).toEqual([]); + toolLessSession.dispose(); + }); + + it("applies through service-based session creation", async () => { + const settingsManager = SettingsManager.inMemory({ defaultTools: ["ls"] }); + const services = await createAgentSessionServices({ cwd: tempDir, agentDir, settingsManager }); + const { session } = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(tempDir), + model: getModel("anthropic", "claude-sonnet-4-5")!, + }); + + expect( + session + .getAllTools() + .map((tool) => tool.name) + .sort(), + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); + expect(session.getActiveToolNames()).toEqual(["ls"]); + session.dispose(); + }); +}); diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index 6219e24d1df..70d1b9d92b4 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -92,11 +92,13 @@ describe("experimental CLI commands", () => { const result = experimentalCli.parse(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"]); expect(result).toMatchObject({ ok: true, - command: { command: "pi", options: { fileArgs: ["prompt.md"] } }, + command: { + command: "pi", + options: { fileArgs: ["prompt.md"], messages: ["--listen", "unix:///tmp/pi.sock"] }, + }, }); if (!result.ok || result.command.command !== "pi") return; - expect(result.command.options.unknownFlags.get("unknown")).toBe(true); - expect(result.command.options.unknownFlags.get("listen")).toBe("unix:///tmp/pi.sock"); + expect(result.command.options.unknownFlags).toEqual(new Map([["unknown", true]])); }); test.each([ diff --git a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts new file mode 100644 index 00000000000..2e8b0334dad --- /dev/null +++ b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createBashToolDefinition, + createEditToolDefinition, + createPowerShellToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, +} from "../src/core/tools/index.ts"; + +function createBuiltInTools() { + return [ + createReadToolDefinition(process.cwd()), + createBashToolDefinition(process.cwd()), + createPowerShellToolDefinition(process.cwd()), + createEditToolDefinition(process.cwd()), + createWriteToolDefinition(process.cwd()), + ]; +} + +describe("experimental strict built-in tools", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + + afterEach(() => { + if (originalPiExperimental === undefined) delete process.env.PI_EXPERIMENTAL; + else process.env.PI_EXPERIMENTAL = originalPiExperimental; + }); + + it("only enables strict-prefer sampling in experimental mode", () => { + delete process.env.PI_EXPERIMENTAL; + const normalTools = createBuiltInTools(); + process.env.PI_EXPERIMENTAL = "1"; + const experimentalTools = createBuiltInTools(); + + for (const [index, tool] of experimentalTools.entries()) { + expect(tool.constrainedSampling).toEqual({ type: "json_schema", strict: "prefer" }); + expect(tool.parameters).toEqual(normalTools[index]?.parameters); + expect(normalTools[index]?.constrainedSampling).toBeUndefined(); + } + }); +}); diff --git a/packages/coding-agent/test/export-jsonl-share.test.ts b/packages/coding-agent/test/export-jsonl-share.test.ts new file mode 100644 index 00000000000..5e668593144 --- /dev/null +++ b/packages/coding-agent/test/export-jsonl-share.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai/compat"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { defineTool } from "../src/core/extensions/types.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { exportSessionForShare } from "../src/modes/interactive/session-share.ts"; +import { assistantMsg, userMsg } from "./utilities.ts"; + +describe("JSONL share export", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("adds presentation data without changing conversation IDs or links", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-jsonl-share-")); + tempDirs.push(tempDir); + const sessionManager = SessionManager.inMemory(tempDir); + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + model: getModel("anthropic", "claude-sonnet-4-5")!, + settingsManager: SettingsManager.inMemory(), + sessionManager, + tools: ["share_tool"], + customTools: [ + defineTool({ + name: "share_tool", + label: "Share Tool", + description: "Render a value for sharing", + parameters: Type.Object({ value: Type.String({ description: "Value to render" }) }), + execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }), + }), + ], + }); + + try { + const userId = sessionManager.appendMessage(userMsg("hello")); + const assistant: AssistantMessage = { + ...assistantMsg(""), + content: [{ type: "toolCall", id: "call-1", name: "share_tool", arguments: { value: "example" } }], + stopReason: "toolUse", + }; + const assistantId = sessionManager.appendMessage(assistant); + const result: ToolResultMessage = { + role: "toolResult", + toolCallId: "call-1", + toolName: "share_tool", + content: [{ type: "text", text: "done" }], + details: {}, + isError: false, + timestamp: Date.now(), + }; + const resultId = sessionManager.appendMessage(result); + const originalEntryIds = sessionManager.getBranch().map((entry) => entry.id); + + const normalPath = join(tempDir, "normal.jsonl"); + session.exportToJsonl(normalPath); + const normalRecords = readFileSync(normalPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(normalRecords.some((record) => record.type === "custom" && record.customType === "pi.share")).toBe( + false, + ); + + const sharePath = join(tempDir, "share.jsonl"); + exportSessionForShare(sharePath, session); + const records = readFileSync(sharePath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const conversationRecords = records.slice(1, -1); + expect(conversationRecords.map((record) => record.id)).toEqual(originalEntryIds); + expect(conversationRecords.map((record) => record.parentId)).toEqual([null, ...originalEntryIds.slice(0, -1)]); + expect(conversationRecords.slice(-3).map((record) => record.id)).toEqual([userId, assistantId, resultId]); + + const shareEntry = records.at(-1) as { + id: string; + data?: { + systemPrompt?: string; + tools?: Array>; + }; + }; + expect(shareEntry).toMatchObject({ + type: "custom", + customType: "pi.share", + parentId: resultId, + timestamp: expect.any(String), + }); + expect(shareEntry.data?.systemPrompt).toBe(session.state.systemPrompt); + expect(shareEntry.data?.tools).toEqual([ + expect.objectContaining({ + name: "share_tool", + description: "Render a value for sharing", + }), + ]); + expect(shareEntry.data).not.toHaveProperty("renderedTools"); + expect(shareEntry.data).not.toHaveProperty("theme"); + expect(shareEntry.data).not.toHaveProperty("version"); + + const imported = SessionManager.open(sharePath); + expect(imported.getLeafId()).toBe(shareEntry.id); + expect(imported.buildSessionContext().messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "toolResult", + ]); + } finally { + session.dispose(); + } + }); +}); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index f58a4da6279..70f06a3cf64 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -691,6 +691,26 @@ describe("ExtensionRunner", () => { expect(result.runtime.flagValues.get("shared-flag")).toBe(true); }); + it("rejects default values that do not match the flag type", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("safe-mode", { + type: "boolean", + default: "false", + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "bad-flag-default.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.extensions).toHaveLength(0); + expect(result.errors[0]?.error).toContain( + 'Invalid default for flag "safe-mode": expected boolean, got string', + ); + expect(result.runtime.flagValues.has("safe-mode")).toBe(false); + }); + it("can set flag values", async () => { const extCode = ` export default function(pi) { diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 7b2a8f7e4a9..80308277cb3 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -3,7 +3,7 @@ * * These tests verify that DefaultPackageManager.update() handles: * - Normal git updates (no force-push) - * - Force-pushed remotes gracefully (currently fails, fix needed) + * - Force-pushed remotes after a complete history rewrite */ import { spawnSync } from "node:child_process"; @@ -104,12 +104,8 @@ describe("DefaultPackageManager git update", () => { } }); - /** - * Sets up a "remote" repository and clones it to the installed directory. - * This simulates what packageManager.install() would do. - * @param sourceOverride Optional source string to use instead of gitSource (e.g., with @ref for pinned tests) - */ - function setupRemoteAndInstall(sourceOverride?: string): void { + /** Sets up a "remote" repository and clones it to the installed directory. */ + function setupRemoteAndInstall(): void { // Create "remote" repository mkdirSync(remoteDir, { recursive: true }); initGitRepo(remoteDir); @@ -122,7 +118,7 @@ describe("DefaultPackageManager git update", () => { git(["config", "--local", "user.name", "Test"], installedDir); // Add to global packages so update() processes this source - settingsManager.setPackages([sourceOverride ?? gitSource]); + settingsManager.setPackages([gitSource]); } describe("normal updates (no force-push)", () => { @@ -180,99 +176,9 @@ describe("DefaultPackageManager git update", () => { expect(getCurrentCommit(installedDir)).toBe(newCommit); expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); }); - - it("should handle multiple commits ahead", async () => { - setupRemoteAndInstall(); - - // Add multiple commits to remote - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - createCommit(remoteDir, "extension.ts", "// v3", "Third commit"); - const latestCommit = createCommit(remoteDir, "extension.ts", "// v4", "Fourth commit"); - - await packageManager.update(); - - expect(getCurrentCommit(installedDir)).toBe(latestCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v4"); - }); - - it("should update even when local checkout has no upstream", async () => { - setupRemoteAndInstall(); - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - const latestCommit = createCommit(remoteDir, "extension.ts", "// v3", "Third commit"); - - const detachedCommit = getCurrentCommit(installedDir); - git(["checkout", detachedCommit], installedDir); - - const executedCommands: string[] = []; - const managerWithInternals = packageManager as unknown as { - runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; - }; - managerWithInternals.runCommand = async (command, args, options) => { - executedCommands.push(`${command} ${args.join(" ")}`); - const result = spawnSync(command, args, { - cwd: options?.cwd, - encoding: "utf-8", - }); - if (result.status !== 0) { - throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`); - } - }; - - await packageManager.update(); - - expect(executedCommands).toContain( - "git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main", - ); - expect(getCurrentCommit(installedDir)).toBe(latestCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v3"); - }); }); describe("force-push scenarios", () => { - it("should recover when remote history is rewritten", async () => { - setupRemoteAndInstall(); - const initialCommit = getCurrentCommit(remoteDir); - - // Add commit to remote - createCommit(remoteDir, "extension.ts", "// v2", "Commit to keep"); - - // Update to get the new commit - await packageManager.update(); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); - - // Now force-push to rewrite history on remote - git(["reset", "--hard", initialCommit], remoteDir); - const rewrittenCommit = createCommit(remoteDir, "extension.ts", "// v2-rewritten", "Rewritten commit"); - - // Update should succeed despite force-push - await packageManager.update(); - - expect(getCurrentCommit(installedDir)).toBe(rewrittenCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-rewritten"); - }); - - it("should recover when local commit no longer exists in remote", async () => { - setupRemoteAndInstall(); - - // Add commits to remote - createCommit(remoteDir, "extension.ts", "// v2", "Commit A"); - createCommit(remoteDir, "extension.ts", "// v3", "Commit B"); - - // Update to get all commits - await packageManager.update(); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v3"); - - // Force-push remote to remove commits A and B - git(["reset", "--hard", "HEAD~2"], remoteDir); - const newCommit = createCommit(remoteDir, "extension.ts", "// v2-new", "New commit replacing A and B"); - - // Update should succeed - the commits we had locally no longer exist - await packageManager.update(); - - expect(getCurrentCommit(installedDir)).toBe(newCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-new"); - }); - it("should handle complete history rewrite", async () => { setupRemoteAndInstall(); @@ -297,32 +203,6 @@ describe("DefaultPackageManager git update", () => { }); describe("pinned sources", () => { - it("should not move pinned git sources past their configured ref", async () => { - // Create remote repo first to get the initial commit - mkdirSync(remoteDir, { recursive: true }); - initGitRepo(remoteDir); - const initialCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); - - // Install with pinned ref from the start - full clone to ensure commit is available - mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); - git(["clone", remoteDir, installedDir], tempDir); - git(["checkout", initialCommit], installedDir); - git(["config", "--local", "user.email", "test@test.com"], installedDir); - git(["config", "--local", "user.name", "Test"], installedDir); - - // Add to global packages with pinned ref - settingsManager.setPackages([`${gitSource}@${initialCommit}`]); - - // Add new commit to remote - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - - await packageManager.update(); - - // Should still be on initial commit - expect(getCurrentCommit(installedDir)).toBe(initialCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); - }); - it("should checkout the configured pinned git ref during full and targeted updates", async () => { mkdirSync(remoteDir, { recursive: true }); initGitRepo(remoteDir); @@ -351,42 +231,6 @@ describe("DefaultPackageManager git update", () => { expect(getCurrentCommit(installedDir)).toBe(v2Commit); expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); }); - - it("should not reset an annotated tag checkout that already matches the configured ref", async () => { - mkdirSync(remoteDir, { recursive: true }); - initGitRepo(remoteDir); - const taggedCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); - git(["tag", "-a", "v1", "-m", "v1"], remoteDir); - - mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); - git(["clone", remoteDir, installedDir], tempDir); - git(["checkout", "v1"], installedDir); - expect(getCurrentCommit(installedDir)).toBe(taggedCommit); - - settingsManager.setPackages([`${gitSource}@v1`]); - - const executedCommands: string[] = []; - const managerWithInternals = packageManager as unknown as { - runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; - }; - managerWithInternals.runCommand = async (command, args, options) => { - executedCommands.push(`${command} ${args.join(" ")}`); - const result = spawnSync(command, args, { - cwd: options?.cwd, - encoding: "utf-8", - }); - if (result.status !== 0) { - throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`); - } - }; - - await packageManager.update(); - - expect(executedCommands).toContain("git fetch origin v1"); - expect(executedCommands.some((command) => command.startsWith("git reset --hard"))).toBe(false); - expect(executedCommands).not.toContain("git clean -fdx"); - expect(getCurrentCommit(installedDir)).toBe(taggedCommit); - }); }); describe("temporary git sources", () => { @@ -434,53 +278,5 @@ describe("DefaultPackageManager git update", () => { ); expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// fresh"); }); - - it("should not refresh pinned temporary git sources", async () => { - const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; - const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); - const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); - - rmSync(cachedDir, { recursive: true, force: true }); - mkdirSync(join(cachedDir, "pi-extensions"), { recursive: true }); - writeFileSync( - join(cachedDir, "package.json"), - JSON.stringify({ pi: { extensions: ["./pi-extensions"] } }, null, 2), - ); - writeFileSync(extensionFile, "// pinned"); - - const executedCommands: string[] = []; - const managerWithInternals = packageManager as unknown as { - runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; - }; - managerWithInternals.runCommand = async (command, args) => { - executedCommands.push(`${command} ${args.join(" ")}`); - }; - - await packageManager.resolveExtensionSources([`${gitSource}@main`], { temporary: true }); - - expect(executedCommands).toEqual([]); - expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// pinned"); - }); - }); - - describe("scope-aware update", () => { - it("should not install locally when source is only registered globally", async () => { - setupRemoteAndInstall(); - - // Add a new commit to remote - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - - // The project-scope install path should not exist before or after update - const projectGitDir = join(tempDir, ".pi", "git", "github.com", "test", "extension"); - expect(existsSync(projectGitDir)).toBe(false); - - await packageManager.update(gitSource); - - // Global install should be updated - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); - - // Project-scope directory should NOT have been created - expect(existsSync(projectGitDir)).toBe(false); - }); }); }); diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index dd019f8f4dd..ece2d43cac7 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -1,8 +1,140 @@ +import type { Usage } from "@earendil-works/pi-ai"; +import { Container } from "@earendil-works/pi-tui"; import { describe, expect, test, vi } from "vitest"; +import type { SessionEntry } from "../src/core/session-manager.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; describe("InteractiveMode compaction events", () => { - test("rebuilds chat and appends a synthetic compaction summary at the bottom", async () => { + test("uses the cache miss notice setting for compaction and branch summary costs", () => { + const usage: Usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 }, + }; + const addCompactionCostNotice = Reflect.get(InteractiveMode.prototype, "addCompactionCostNotice") as ( + this: { chatContainer: Container; settingsManager: { getShowCacheMissNotices(): boolean } }, + notice: { + type: "compaction_cost"; + kind: "compaction" | "branch_summary"; + usage: Usage; + }, + ) => void; + + initTheme("dark"); + const enabled = { + chatContainer: new Container(), + settingsManager: { getShowCacheMissNotices: () => true }, + }; + addCompactionCostNotice.call(enabled, { type: "compaction_cost", kind: "compaction", usage }); + addCompactionCostNotice.call(enabled, { + type: "compaction_cost", + kind: "branch_summary", + usage, + }); + const output = stripAnsi(enabled.chatContainer.render(120).join("\n")); + expect(output).toContain("Compaction: 100 tokens billed (~$0.13)"); + expect(output).toContain("Branch summary: 100 tokens billed (~$0.13)"); + + const disabled = { + chatContainer: new Container(), + settingsManager: { getShowCacheMissNotices: () => false }, + }; + addCompactionCostNotice.call(disabled, { type: "compaction_cost", kind: "compaction", usage }); + expect(disabled.chatContainer.children).toHaveLength(0); + }); + + test("renders each compaction cost after its summary", () => { + const currentUsage: Usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, + }; + const previousUsage: Usage = { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.001, output: 0.002, cacheRead: 0.003, cacheWrite: 0.004, total: 0.01 }, + }; + const entries: SessionEntry[] = [ + { + type: "compaction", + id: "current", + parentId: "previous", + timestamp: "2025-01-02T00:00:00Z", + summary: "current summary", + firstKeptEntryId: "kept", + tokensBefore: 200, + usage: currentUsage, + }, + { + type: "compaction", + id: "previous", + parentId: null, + timestamp: "2025-01-01T00:00:00Z", + summary: "previous summary", + firstKeptEntryId: "kept", + tokensBefore: 100, + usage: previousUsage, + }, + ]; + const fakeThis = { renderSessionItems: vi.fn() }; + const renderSessionEntries = Reflect.get(InteractiveMode.prototype, "renderSessionEntries") as ( + this: typeof fakeThis, + entries: SessionEntry[], + ) => void; + + renderSessionEntries.call(fakeThis, entries); + + expect(fakeThis.renderSessionItems).toHaveBeenCalledWith( + [ + expect.objectContaining({ role: "compactionSummary", summary: "current summary" }), + { type: "compaction_cost", kind: "compaction", usage: currentUsage }, + expect.objectContaining({ role: "compactionSummary", summary: "previous summary" }), + { type: "compaction_cost", kind: "compaction", usage: previousUsage }, + ], + {}, + ); + }); + + test("renders retained entries and appends the latest summary cost at the bottom", async () => { + const usage: Usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 }, + }; + const latestCompaction: SessionEntry = { + type: "compaction", + id: "latest", + parentId: "previous", + timestamp: "2025-01-02T00:00:00Z", + summary: "summary", + firstKeptEntryId: "kept", + tokensBefore: 123, + usage, + }; + const previousCompaction: SessionEntry = { + type: "compaction", + id: "previous", + parentId: null, + timestamp: "2025-01-01T00:00:00Z", + summary: "previous summary", + firstKeptEntryId: "kept", + tokensBefore: 100, + usage, + }; const fakeThis = { isInitialized: true, footer: { invalidate: vi.fn() }, @@ -11,8 +143,10 @@ describe("InteractiveMode compaction events", () => { defaultEditor: {}, statusContainer: { clear: vi.fn() }, chatContainer: { clear: vi.fn() }, - rebuildChatFromMessages: vi.fn(), + sessionManager: { buildContextEntries: vi.fn().mockReturnValue([latestCompaction, previousCompaction]) }, + renderSessionEntries: vi.fn(), addMessageToChat: vi.fn(), + addCompactionCostNotice: vi.fn(), showError: vi.fn(), showStatus: vi.fn(), clearStatusIndicator: vi.fn(), @@ -26,7 +160,7 @@ describe("InteractiveMode compaction events", () => { event: { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; - result: { tokensBefore: number; summary: string } | undefined; + result: { tokensBefore: number; summary: string; usage?: Usage } | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string; @@ -39,13 +173,14 @@ describe("InteractiveMode compaction events", () => { result: { tokensBefore: 123, summary: "summary", + usage, }, aborted: false, willRetry: false, }); expect(fakeThis.chatContainer.clear).toHaveBeenCalledTimes(1); - expect(fakeThis.rebuildChatFromMessages).toHaveBeenCalledTimes(1); + expect(fakeThis.renderSessionEntries).toHaveBeenCalledWith([previousCompaction]); expect(fakeThis.addMessageToChat).toHaveBeenCalledTimes(1); expect(fakeThis.addMessageToChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -54,6 +189,11 @@ describe("InteractiveMode compaction events", () => { summary: "summary", }), ); + expect(fakeThis.addCompactionCostNotice).toHaveBeenCalledWith({ + type: "compaction_cost", + kind: "compaction", + usage, + }); expect(fakeThis.flushCompactionQueue).toHaveBeenCalledWith({ willRetry: false }); }); diff --git a/packages/coding-agent/test/interactive-mode-startup-input.test.ts b/packages/coding-agent/test/interactive-mode-startup-input.test.ts index b784f3769ec..28a0f48b80c 100644 --- a/packages/coding-agent/test/interactive-mode-startup-input.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup-input.test.ts @@ -23,7 +23,13 @@ type InputContext = { pendingUserInputs: string[]; }; +type StartupSubmitContext = { + editor: { setText: (text: string) => void }; + showStatus: (message: string) => void; +}; + type InteractiveModePrivate = { + handleStartupSubmit(this: StartupSubmitContext, text: string): void; setupEditorSubmitHandler(this: SubmitContext): void; getUserInput(this: InputContext): Promise; }; @@ -49,6 +55,18 @@ function createSubmitContext(): SubmitContext { } describe("InteractiveMode startup input", () => { + it("restores a prompt submitted while managed-tool setup is running", () => { + const context: StartupSubmitContext = { + editor: { setText: vi.fn() }, + showStatus: vi.fn(), + }; + + interactiveModePrototype.handleStartupSubmit.call(context, "early prompt"); + + expect(context.editor.setText).toHaveBeenCalledWith("early prompt"); + expect(context.showStatus).toHaveBeenCalledWith("Startup is still in progress"); + }); + it("queues a normal prompt submitted before the input callback is installed", async () => { const context = createSubmitContext(); interactiveModePrototype.setupEditorSubmitHandler.call(context); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d422d8f3480..a4aaa48c02a 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -118,6 +118,30 @@ describe("InteractiveMode.showStatus", () => { }); }); +describe("InteractiveMode.showManagedToolStatus", () => { + beforeAll(() => initTheme("dark")); + + test("renders tool updates as one contiguous group", () => { + const fakeThis: any = { + chatContainer: new Container(), + ui: { requestRender: vi.fn() }, + managedToolStatusStarted: false, + lastStatusSpacer: undefined, + lastStatusText: undefined, + }; + const showManagedToolStatus = (InteractiveMode as any).prototype.showManagedToolStatus; + + showManagedToolStatus.call(fakeThis, { type: "info", message: "fd downloading" }); + showManagedToolStatus.call(fakeThis, { type: "info", message: "rg downloading" }); + showManagedToolStatus.call(fakeThis, { type: "warning", message: "rg failed" }); + + expect(fakeThis.chatContainer.children).toHaveLength(4); + expect(normalizeRenderedOutput(fakeThis.chatContainer)).toBe( + "fd downloading\n rg downloading\n Warning: rg failed", + ); + }); +}); + describe("InteractiveMode.setToolsExpanded", () => { test("applies expansion state to the active header and chat entries", () => { const header = { setExpanded: vi.fn() }; diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 996e5e56c75..a4a0408d8c8 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -2,7 +2,7 @@ import type { Component, Terminal, TUI } from "@earendil-works/pi-tui"; import { Container, isViewportTUI, Text } from "@earendil-works/pi-tui"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; -import type { TuiMode } from "../src/core/settings-manager.ts"; +import type { FullscreenExitOutput, TuiMode } from "../src/core/settings-manager.ts"; import { createInteractiveTui, createInteractiveTuiReference, @@ -68,7 +68,7 @@ describe("createInteractiveTui", () => { altTui.stop(); }); - it("replaces the renderer while preserving components and focus", async () => { + it("replaces the renderer and restores the previous screen for resume-hint exits", async () => { const terminal = new RecordingTerminal(40, 8); const renderer = createInteractiveTui({ tuiMode: "regular", @@ -105,7 +105,7 @@ describe("createInteractiveTui", () => { stableUi = createInteractiveTuiReference(() => context.renderer); context.ui = stableUi; const { stopInteractiveTui, switchTuiMode } = InteractiveMode.prototype as unknown as { - stopInteractiveTui(this: SwitchContext): void; + stopInteractiveTui(this: SwitchContext, fullscreenExitOutput: FullscreenExitOutput): void; switchTuiMode(this: SwitchContext, mode: TuiMode, restoreProgress?: boolean): boolean; }; @@ -121,10 +121,31 @@ describe("createInteractiveTui", () => { expect(invalidatedModes).toEqual(["fullscreen"]); expect([terminal.startCount, terminal.stopCount]).toEqual([2, 1]); - stopInteractiveTui.call(context); + stopInteractiveTui.call(context, "resume-hint"); - expect(stableUi.mode).toBe("regular"); - expect([terminal.startCount, terminal.stopCount]).toEqual([2, 3]); + expect(stableUi.mode).toBe("fullscreen"); + expect([terminal.startCount, terminal.stopCount]).toEqual([2, 2]); + }); +}); + +describe("InteractiveMode right-click paste", () => { + it("feeds clipboard text to the focused component as a bracketed paste", async () => { + clipboardMocks.readClipboardText.mockResolvedValue("clipboard text"); + const handleInput = vi.fn<(data: string) => void>(); + const target = { render: () => [], invalidate: () => {}, handleInput } satisfies Component; + const requestRender = vi.fn(); + const context = { + renderer: { getFocusedComponent: () => target }, + ui: { requestRender }, + }; + const prototype = InteractiveMode.prototype as unknown as { + handleRightClickPaste(this: typeof context): Promise; + }; + + await prototype.handleRightClickPaste.call(context); + + expect(handleInput).toHaveBeenCalledWith("\x1b[200~clipboard text\x1b[201~"); + expect(requestRender).toHaveBeenCalledOnce(); }); }); diff --git a/packages/coding-agent/test/keybindings.test.ts b/packages/coding-agent/test/keybindings.test.ts new file mode 100644 index 00000000000..cb505eede7e --- /dev/null +++ b/packages/coding-agent/test/keybindings.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { KEYBINDINGS, useWindowsKeybindings } from "../src/core/keybindings.ts"; + +describe("Windows keybinding defaults", () => { + it("uses Windows keybindings on native Windows", () => { + expect(useWindowsKeybindings("win32", {})).toBe(true); + }); + + it("uses Windows keybindings in WSL without relying on Windows Terminal detection", () => { + expect(useWindowsKeybindings("linux", { WSL_DISTRO_NAME: "Ubuntu" })).toBe(true); + expect(useWindowsKeybindings("linux", { WSL_INTEROP: "/run/WSL/123_interop" })).toBe(true); + }); + + it("does not use Windows keybindings from WT_SESSION alone", () => { + expect(useWindowsKeybindings("linux", { WT_SESSION: "session" })).toBe(false); + }); + + it("keeps non-Windows defaults on other platforms", () => { + expect(useWindowsKeybindings("linux", {})).toBe(false); + expect(useWindowsKeybindings("darwin", {})).toBe(false); + }); + + it("applies the detected defaults consistently", () => { + const windowsKeybindings = useWindowsKeybindings(); + const nativeWindows = process.platform === "win32"; + + expect(KEYBINDINGS["app.clipboard.pasteImage"].defaultKeys).toBe(windowsKeybindings ? "alt+v" : "ctrl+v"); + expect(KEYBINDINGS["tui.altScreen.search"].defaultKeys).toBe(windowsKeybindings ? "ctrl+f" : "ctrl+shift+f"); + expect(KEYBINDINGS["app.message.followUp"].defaultKeys).toBe(windowsKeybindings ? "ctrl+q" : "alt+enter"); + expect(KEYBINDINGS["app.model.cycleBackward"].defaultKeys).toBe(windowsKeybindings ? "alt+p" : "shift+ctrl+p"); + expect(KEYBINDINGS["tui.editor.undo"].defaultKeys).toBe( + nativeWindows ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-", + ); + expect(KEYBINDINGS["tui.altScreen.previousPrompt"].defaultKeys).toEqual( + windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"], + ); + expect(KEYBINDINGS["tui.altScreen.nextPrompt"].defaultKeys).toEqual( + windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"], + ); + expect(KEYBINDINGS["app.message.dequeue"].defaultKeys).toBe(windowsKeybindings ? "alt+q" : "alt+up"); + }); +}); diff --git a/packages/coding-agent/test/llama-extension.test.ts b/packages/coding-agent/test/llama-extension.test.ts index a89a0940e4d..7c506aafc91 100644 --- a/packages/coding-agent/test/llama-extension.test.ts +++ b/packages/coding-agent/test/llama-extension.test.ts @@ -59,7 +59,7 @@ describe("llama.cpp extension", () => { expect(() => normalizeLlamaServerUrl("file:///tmp/llama")).toThrow("http or https"); }); - it("exposes only loaded models with router metadata", () => { + it("exposes loaded and sleeping models with router metadata", () => { const controller = createLlamaProvider(); controller.setCatalog( [ @@ -69,6 +69,7 @@ describe("llama.cpp extension", () => { architecture: { input_modalities: ["text", "image"] }, meta: { n_ctx: 65536, n_ctx_train: 131072 }, }, + { id: "sleeping", status: { value: "sleeping" } }, { id: "unloaded", status: { value: "unloaded" } }, { id: "loading", status: { value: "loading" } }, ], @@ -83,16 +84,21 @@ describe("llama.cpp extension", () => { maxTokens: 65536, input: ["text", "image"], }), + expect.objectContaining({ + id: "sleeping", + baseUrl: "http://localhost:8080/v1", + }), ]); }); - it("persists and restores loaded models for cache-only startup refreshes", async () => { + it("persists and restores selectable models for cache-only startup refreshes", async () => { let cachedEntry: ModelsStoreEntry | undefined; const { url } = await listen((request, response) => { if (request.url === "/models") { json(response, { data: [ { id: "loaded", status: { value: "loaded" }, meta: { n_ctx: 32768 } }, + { id: "sleeping", status: { value: "sleeping" }, meta: { n_ctx: 32768 } }, { id: "unloaded", status: { value: "unloaded" } }, ], }); @@ -115,8 +121,8 @@ describe("llama.cpp extension", () => { allowNetwork: true, signal: new AbortController().signal, }); - expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded"]); - expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded"]); + expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded", "sleeping"]); + expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded", "sleeping"]); const second = createLlamaProvider(); await second.provider.refreshModels?.({ @@ -128,9 +134,82 @@ describe("llama.cpp extension", () => { }); expect(second.provider.getModels()).toEqual([ expect.objectContaining({ id: "loaded", baseUrl: `${url}/v1`, contextWindow: 32768 }), + expect.objectContaining({ id: "sleeping", baseUrl: `${url}/v1`, contextWindow: 32768 }), ]); }); + it("exposes unloaded presets only when router autoload is enabled", async () => { + let propsRequests = 0; + const { url } = await listen((request, response) => { + expect(request.headers.authorization).toBe("Bearer local"); + if (request.url === "/models") { + json(response, { + data: [ + { id: "preset", status: { value: "unloaded" }, source: "preset", meta: { n_ctx: 65536 } }, + { id: "failed-preset", status: { value: "unloaded", failed: true }, source: "preset" }, + { id: "cache", status: { value: "unloaded" }, source: "cache" }, + { id: "models-dir", status: { value: "unloaded" }, source: "models_dir" }, + ], + }); + return; + } + if (request.url === "/props") { + propsRequests++; + json(response, { role: "router", models_autoload: true }); + return; + } + response.writeHead(404).end(); + }); + + let cachedEntry: ModelsStoreEntry | undefined; + const controller = createLlamaProvider(); + await controller.provider.refreshModels?.({ + credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } }, + stored: undefined, + publish: async (publication) => { + if (publication.persist !== undefined && publication.persist !== null) { + cachedEntry = structuredClone(publication.persist); + } + publication.update?.(); + return true; + }, + allowNetwork: true, + signal: new AbortController().signal, + }); + + expect(propsRequests).toBe(1); + expect(controller.provider.getModels().map((model) => model.id)).toEqual(["preset"]); + expect(cachedEntry?.models.map((model) => model.id)).toEqual(["preset"]); + }); + + it("hides unloaded presets when router autoload is disabled", async () => { + const { url } = await listen((request, response) => { + if (request.url === "/models") { + json(response, { data: [{ id: "preset", status: { value: "unloaded" }, source: "preset" }] }); + return; + } + if (request.url === "/props") { + json(response, { role: "router", models_autoload: false }); + return; + } + response.writeHead(404).end(); + }); + + const controller = createLlamaProvider(); + await controller.provider.refreshModels?.({ + credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } }, + stored: undefined, + publish: async (publication) => { + publication.update?.(); + return true; + }, + allowNetwork: true, + signal: new AbortController().signal, + }); + + expect(controller.provider.getModels()).toEqual([]); + }); + it("stays dormant until configured and stores URL plus optional key", async () => { const { provider } = createLlamaProvider(); const auth = provider.auth.apiKey!; diff --git a/packages/coding-agent/test/management-http.test.ts b/packages/coding-agent/test/management-http.test.ts index b615bb2dc74..cb0809cd4a0 100644 --- a/packages/coding-agent/test/management-http.test.ts +++ b/packages/coding-agent/test/management-http.test.ts @@ -30,6 +30,27 @@ describe("fetchWithRetry", () => { expect(signals[0]).toBe(signals[1]); }); + it("retries an attempt timeout", async () => { + const controllers: AbortController[] = []; + vi.spyOn(AbortSignal, "timeout").mockImplementation(() => { + const controller = new AbortController(); + controllers.push(controller); + return controller.signal; + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { + if (fetchMock.mock.calls.length === 1) { + controllers[0].abort(); + init?.signal?.throwIfAborted(); + } + return Response.json({ ok: true }); + }); + + await fetchWithRetry("https://example.test", undefined, { attemptTimeoutMs: 4000 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(controllers).toHaveLength(2); + }); + it("retries transient HTTP responses and returns the successful response", async () => { const fetchMock = vi .spyOn(globalThis, "fetch") diff --git a/packages/coding-agent/test/model-catalog-refresh.test.ts b/packages/coding-agent/test/model-catalog-refresh.test.ts new file mode 100644 index 00000000000..0250026f934 --- /dev/null +++ b/packages/coding-agent/test/model-catalog-refresh.test.ts @@ -0,0 +1,81 @@ +import type { ModelsRefreshOptions, ModelsRefreshResult } from "@earendil-works/pi-ai"; +import { describe, expect, it, vi } from "vitest"; +import { refreshModelCatalogs } from "../src/modes/interactive/model-catalog-refresh.ts"; + +interface Deferred { + promise: Promise; + resolve(value: T): void; +} + +function createDeferred(): Deferred { + let resolvePromise!: (value: T) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} + +function successfulRefresh(): ModelsRefreshResult { + return { aborted: false, errors: new Map() }; +} + +describe("interactive model catalog refresh", () => { + it("shares one runtime refresh between concurrent callers", async () => { + const deferred = createDeferred(); + const runtime = { refresh: vi.fn((_options?: ModelsRefreshOptions) => deferred.promise) }; + const firstController = new AbortController(); + const secondController = new AbortController(); + + const first = refreshModelCatalogs(runtime, firstController.signal); + const second = refreshModelCatalogs(runtime, secondController.signal); + + expect(runtime.refresh).toHaveBeenCalledOnce(); + deferred.resolve(successfulRefresh()); + await expect(first).resolves.toEqual(successfulRefresh()); + await expect(second).resolves.toEqual(successfulRefresh()); + }); + + it("keeps the shared refresh alive when one caller stops waiting", async () => { + const deferred = createDeferred(); + let refreshSignal: AbortSignal | undefined; + const runtime = { + refresh: vi.fn((options?: ModelsRefreshOptions) => { + refreshSignal = options?.signal; + return deferred.promise; + }), + }; + const firstController = new AbortController(); + const secondController = new AbortController(); + const first = refreshModelCatalogs(runtime, firstController.signal); + const second = refreshModelCatalogs(runtime, secondController.signal); + + firstController.abort(); + await expect(first).rejects.toMatchObject({ name: "AbortError" }); + expect(refreshSignal?.aborted).toBe(false); + + deferred.resolve(successfulRefresh()); + await expect(second).resolves.toEqual(successfulRefresh()); + }); + + it("aborts an abandoned refresh and allows a later refresh to start", async () => { + const refreshSignals: AbortSignal[] = []; + const runtime = { + refresh: vi.fn((options?: ModelsRefreshOptions) => { + if (options?.signal) refreshSignals.push(options.signal); + return new Promise(() => {}); + }), + }; + const firstController = new AbortController(); + const first = refreshModelCatalogs(runtime, firstController.signal); + + firstController.abort(); + await expect(first).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(refreshSignals[0]?.aborted).toBe(true)); + + const secondController = new AbortController(); + const second = refreshModelCatalogs(runtime, secondController.signal); + expect(runtime.refresh).toHaveBeenCalledTimes(2); + secondController.abort(); + await expect(second).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index dae695c337c..18bf921b337 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -11,6 +11,7 @@ import type { import { getApiProvider, getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; +import type { ModelsJsonProvider } from "../src/core/model-config.ts"; import { clearApiKeyCache, type ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts"; import { createModelRegistry } from "./model-runtime-test-utils.ts"; @@ -767,6 +768,26 @@ describe("ModelRegistry", () => { expect(compat?.openRouterRouting).toEqual({ only: ["amazon-bedrock"] }); }); + test("supportsFinishReason can be configured at provider and model levels", async () => { + const provider: ModelsJsonProvider = { + compat: { supportsFinishReason: true }, + modelOverrides: { + "anthropic/claude-sonnet-4": { + compat: { supportsFinishReason: false }, + }, + }, + }; + writeRawModelsJson({ openrouter: provider }); + + const registry = await createModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + const sonnet = models.find((model) => model.id === "anthropic/claude-sonnet-4"); + const opus = models.find((model) => model.id === "anthropic/claude-opus-4"); + + expect((sonnet?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(false); + expect((opus?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(true); + }); + test("model override deep merges compat settings", async () => { writeRawModelsJson({ openrouter: { diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 508c949ba08..6488b7ae8c5 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,4 +1,5 @@ import type { Model } from "@earendil-works/pi-ai"; +import { getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"; import { describe, expect, test, vi } from "vitest"; import { defaultModelPerProvider, @@ -700,17 +701,36 @@ describe("default model selection", () => { }); test("zai, minimax, cerebras, and ant-ling defaults track current models", () => { - expect(defaultModelPerProvider.zai).toBe("glm-5.1"); + expect(defaultModelPerProvider.zai).toBe("glm-5.3"); + expect(defaultModelPerProvider["zai-coding-cn"]).toBe("glm-5.3"); expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7"); - expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7"); + expect(defaultModelPerProvider.cerebras).toBe("gpt-oss-120b"); expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T"); }); + test("built-in defaults exist in generated provider catalogs", () => { + for (const provider of getBuiltinProviders()) { + const defaultId = defaultModelPerProvider[provider]; + expect( + getBuiltinModels(provider).some((model) => model.id === defaultId), + `${provider} default ${defaultId} should exist in its generated catalog`, + ).toBe(true); + } + }); + test("ai-gateway default tracks current model", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); + test("xai default tracks current model", () => { + expect(defaultModelPerProvider.xai).toBe("grok-4.6"); + }); + + test("qwen token plan individual default tracks current model", () => { + expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max"); + }); + test("findInitialModel accepts explicit provider custom model ids", async () => { const registry = { getModels: () => allModels, diff --git a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts index 8a0afcabf7a..6e9c4518511 100644 --- a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts @@ -59,7 +59,7 @@ async function createCloudflareRuntime(): Promise<{ modelRuntime: ModelRuntime; describe("ModelRegistry Cloudflare compat streaming", () => { it("materializes the Cloudflare endpoint through ModelRuntime streaming", async () => { const { modelRuntime } = await createCloudflareRuntime(); - const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5"); + const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); expect(model).toBeDefined(); resetApiProviders(); @@ -75,7 +75,7 @@ describe("ModelRegistry Cloudflare compat streaming", () => { it("materializes the Cloudflare endpoint after extension-style auth resolution", async () => { const { modelRegistry } = await createCloudflareRuntime(); - const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5"); + const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); expect(model).toBeDefined(); resetApiProviders(); diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index 060386ee708..fd5550ac17b 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -34,7 +34,6 @@ describe("model selector", () => { const selector = new ModelSelectorComponent( createFakeTui(), harness.getModel(), - harness.settingsManager, harness.session.modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/models-store.test.ts b/packages/coding-agent/test/models-store.test.ts index 30e592f7c00..bf645042c8e 100644 --- a/packages/coding-agent/test/models-store.test.ts +++ b/packages/coding-agent/test/models-store.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Model } from "@earendil-works/pi-ai"; @@ -53,6 +53,17 @@ describe("FileModelsStore", () => { expect((await reloaded.read("two"))?.models.map((entry) => entry.id)).toEqual(["m2"]); }); + it.skipIf(process.platform === "win32")("preserves the mode of an existing models file", async () => { + const managedModelsPath = join(sharedTempDir, "managed-mode.json"); + writeFileSync(managedModelsPath, "{}"); + chmodSync(managedModelsPath, 0o660); + const store = new FileModelsStore(managedModelsPath); + + await store.write("one", { models: [model("one", "m1")], checkedAt: 100 }); + + expect(statSync(managedModelsPath).mode & 0o777).toBe(0o660); + }); + it("coalesces file reloads across concurrent readers and interleaved storage instances", async () => { writeFileSync( sharedModelsPath, diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index a804b40e41e..1d534349298 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,6 +1,17 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; +import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; @@ -29,6 +40,77 @@ describe("package commands", () => { return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; } + function prepareManagedInstall( + targetVersion: string, + npmExitCode = 0, + ): { managedRoot: string; npmRecordPath: string } { + const managedRoot = join(agentDir, "install"); + const activeRelease = join(managedRoot, "releases", VERSION); + const selfPackageDir = join(activeRelease, "node_modules", ...PACKAGE_NAME.split("/")); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync(join(activeRelease, "active.txt"), "active"); + writeFileSync(join(managedRoot, "current-version"), `${VERSION}\n`); + writeFileSync( + join(managedRoot, "managed-install.json"), + `${JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" })}\n`, + ); + + const binDir = join(tempDir, "managed-bin"); + const fakeNpmPath = join(tempDir, "managed-npm.cjs"); + const npmRecordPath = join(tempDir, "managed-npm-record.json"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(npmRecordPath)}, JSON.stringify(args)); +if (${npmExitCode} !== 0) process.exit(${npmExitCode}); +const binDir = path.join(process.cwd(), "node_modules", ".bin"); +fs.mkdirSync(binDir, { recursive: true }); +const piPath = path.join(binDir, process.platform === "win32" ? "pi.cmd" : "pi"); +fs.writeFileSync( + piPath, + process.platform === "win32" + ? "@echo off\\r\\necho ${targetVersion}\\r\\n" + : "#!/bin/sh\\nprintf '%s\\n' ${targetVersion}\\n", +); +if (process.platform !== "win32") fs.chmodSync(piPath, 0o755); +`, + ); + const npmPath = join(binDir, process.platform === "win32" ? "npm.cmd" : "npm"); + writeFileSync( + npmPath, + process.platform === "win32" + ? `@echo off\r\n"${originalExecPath}" "${fakeNpmPath}" %*\r\n` + : `#!/bin/sh\nexec "${originalExecPath}" "${fakeNpmPath}" "$@"\n`, + ); + chmodSync(npmPath, 0o755); + + vi.stubEnv("PI_INSTALLER_API_BASE", "https://example.test/api/installer/releases"); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", managedRoot); + process.env.PI_PACKAGE_DIR = selfPackageDir; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + return { managedRoot, npmRecordPath }; + } + + function mockManagedUpdate(targetVersion: string): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === "https://pi.dev/api/latest-version") { + return Response.json({ packageName: PACKAGE_NAME, version: targetVersion }); + } + const releaseUrl = `https://example.test/api/installer/releases/${targetVersion}`; + if (url === `${releaseUrl}/package.json` || url === `${releaseUrl}/package-lock.json`) { + return Response.json({}); + } + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + } + async function runPackageCommandDirectly(args: string[]): Promise { expect(await handlePackageCommand(args)).toBe(true); } @@ -82,6 +164,7 @@ describe("package commands", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); process.chdir(originalCwd); process.exitCode = originalExitCode; @@ -523,10 +606,103 @@ describe("package commands", () => { } }); - it("uses the update check version for forced self updates even when current", async () => { + it("updates installer-managed Pi through a staged immutable release", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const abandonedStage = join(managedRoot, "staging", "update-abandoned"); + mkdirSync(abandonedStage, { recursive: true }); + writeFileSync(join(abandonedStage, "partial"), "partial"); + const abandonedLock = join(managedRoot, "update.lock"); + mkdirSync(abandonedLock); + utimesSync(abandonedLock, new Date(0), new Date(0)); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${targetVersion}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(true); + expect(existsSync(join(managedRoot, "releases", VERSION, "active.txt"))).toBe(true); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(JSON.parse(readFileSync(npmRecordPath, "utf8")) as string[]).toEqual( + expect.arrayContaining(["ci", "--ignore-scripts"]), + ); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + `Updated pi from ${VERSION} to ${targetVersion}`, + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("rejects a concurrent managed update", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + } finally { + await releaseLock(); + } + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(npmRecordPath)).toBe(false); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Another managed Pi update is already running.", + ); + expect(process.exitCode).toBe(1); + }); + + it("rejects forced managed reinstalls", async () => { + const targetVersion = getNewerPatchVersion(); + const { npmRecordPath } = prepareManagedInstall(targetVersion); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(existsSync(npmRecordPath)).toBe(false); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Managed pi installations do not support --force", + ); + expect(process.exitCode).toBe(1); + }); + + it("keeps the managed release active when its update fails", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot } = prepareManagedInstall(targetVersion, 23); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(false); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("exited with code 23"); + expect(process.exitCode).toBe(1); + }); + + it("keeps npm self-updates non-managed when the managed environment is inherited", async () => { const globalPrefix = join(tempDir, "global-prefix"); const projectPrefix = join(tempDir, "project-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const inheritedManagedRoot = join(tempDir, "inherited-managed-install"); + mkdirSync(join(inheritedManagedRoot, "releases"), { recursive: true }); + writeFileSync( + join(inheritedManagedRoot, "managed-install.json"), + JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" }), + ); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", inheritedManagedRoot); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); const recordPath = join(tempDir, "self-update.json"); mkdirSync(selfPackageDir, { recursive: true }); diff --git a/packages/coding-agent/test/package-distribution.test.ts b/packages/coding-agent/test/package-distribution.test.ts new file mode 100644 index 00000000000..2fe5380ec8f --- /dev/null +++ b/packages/coding-agent/test/package-distribution.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +interface CodingAgentPackageJson { + bin: { pi: string }; + main: string; + exports: { + ".": { import: string; types: string }; + "./client": { import: string; types: string }; + "./rpc-entry": { import: string }; + }; +} + +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as CodingAgentPackageJson; + +describe("package distribution entrypoints", () => { + test("uses the bundle for executables and modular output for libraries", () => { + expect(packageJson.bin.pi).toBe("dist/bundle/cli.js"); + expect(packageJson.main).toBe("./dist/index.js"); + expect(packageJson.exports["."].import).toBe("./dist/index.js"); + expect(packageJson.exports["./client"].import).toBe("./dist/client/index.js"); + expect(packageJson.exports["./rpc-entry"].import).toBe("./dist/bundle/rpc-entry.js"); + }); +}); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 31c89faffce..6f4f6b4fab3 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -440,13 +440,19 @@ Content`, expect(result.skills.some((r) => r.path === middleSkill && r.enabled)).toBe(true); }); - it("should ignore root markdown files in .agents/skills", async () => { + it("should ignore root markdown files in .agents/skills but discover nested markdown skills", async () => { const agentsSkillsDir = join(tempDir, ".agents", "skills"); mkdirSync(join(agentsSkillsDir, "nested-skill"), { recursive: true }); + mkdirSync(join(agentsSkillsDir, "third-party"), { recursive: true }); + mkdirSync(join(agentsSkillsDir, "third-party", "vendor", "pack"), { recursive: true }); const rootSkill = join(agentsSkillsDir, "root-file.md"); const nestedSkill = join(agentsSkillsDir, "nested-skill", "SKILL.md"); + const nestedMarkdownSkill = join(agentsSkillsDir, "third-party", "child-skill.md"); + const deeplyNestedMarkdownSkill = join(agentsSkillsDir, "third-party", "vendor", "pack", "deep-skill.md"); writeFileSync(rootSkill, "---\nname: root-file\ndescription: Root markdown file\n---\n"); writeFileSync(nestedSkill, "---\nname: nested-skill\ndescription: Nested skill\n---\n"); + writeFileSync(nestedMarkdownSkill, "---\nname: child-skill\ndescription: Nested markdown skill\n---\n"); + writeFileSync(deeplyNestedMarkdownSkill, "---\nname: deep-skill\ndescription: Deep markdown skill\n---\n"); const pm = new DefaultPackageManager({ cwd: join(tempDir, "work"), @@ -458,6 +464,8 @@ Content`, const result = await pm.resolve(); expect(result.skills.some((r) => r.path === rootSkill)).toBe(false); expect(result.skills.some((r) => r.path === nestedSkill && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === nestedMarkdownSkill && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === deeplyNestedMarkdownSkill && r.enabled)).toBe(true); }); it("should keep ~/.agents/skills user-scoped when cwd is under home in a non-git directory", async () => { @@ -1639,6 +1647,60 @@ Content`, expect(result.skills.some((r) => isEnabled(r, "pdf-to-markdown", "includes"))).toBe(true); expect(result.skills.some((r) => isEnabled(r, "document-processor-api", "includes"))).toBe(true); }); + + it("should sort manifest glob matches and use exact entries for dot paths and symlink traversal", async () => { + const pkgDir = join(tempDir, "manifest-glob-semantics-pkg"); + const extensionFilesDir = join(pkgDir, "extension-files"); + const extensionGroupDir = join(pkgDir, "extension-groups", "group"); + const linkedPluginSource = join(pkgDir, "linked-plugin-source"); + mkdirSync(join(extensionFilesDir, "nested"), { recursive: true }); + mkdirSync(extensionGroupDir, { recursive: true }); + mkdirSync(join(pkgDir, "plugins", "local", "skills", "local-skill"), { recursive: true }); + mkdirSync(join(linkedPluginSource, "skills", "linked-skill"), { recursive: true }); + writeFileSync(join(extensionFilesDir, "z.ts"), "export default function() {}"); + writeFileSync(join(extensionFilesDir, "a.ts"), "export default function() {}"); + writeFileSync(join(extensionFilesDir, ".ignored.ts"), "export default function() {}"); + writeFileSync(join(extensionFilesDir, "nested", ".hidden.ts"), "export default function() {}"); + writeFileSync(join(extensionGroupDir, "index.ts"), "export default function() {}"); + writeFileSync( + join(pkgDir, "plugins", "local", "skills", "local-skill", "SKILL.md"), + "---\nname: local-skill\ndescription: Local\n---\n", + ); + writeFileSync( + join(linkedPluginSource, "skills", "linked-skill", "SKILL.md"), + "---\nname: linked-skill\ndescription: Linked\n---\n", + ); + symlinkSync( + linkedPluginSource, + join(pkgDir, "plugins", "linked"), + process.platform === "win32" ? "junction" : "dir", + ); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "manifest-glob-semantics-pkg", + pi: { + extensions: [ + "./extension-files/*.ts", + "./extension-files/**/.ignored.ts", + "./extension-files/nested/.hidden.ts", + "./extension-groups/*/", + ], + skills: ["./plugins/*/skills", "./plugins/linked/skills"], + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + expect(result.extensions.map((resource) => relative(pkgDir, resource.path))).toEqual([ + join("extension-files", "a.ts"), + join("extension-files", "z.ts"), + join("extension-files", "nested", ".hidden.ts"), + join("extension-groups", "group", "index.ts"), + ]); + expect(result.skills.some((resource) => pathEndsWith(resource.path, "local-skill/SKILL.md"))).toBe(true); + expect(result.skills.some((resource) => pathEndsWith(resource.path, "linked-skill/SKILL.md"))).toBe(true); + }); }); describe("pattern filtering in package filters", () => { @@ -2244,6 +2306,25 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(runCommandSpy).not.toHaveBeenCalled(); }); + it("should skip npm updates when the installed version is newer than the registry version", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "2.0.0" })); + settingsManager.setProjectPackages(["npm:example"]); + + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.9.0"'); + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.update("npm:example"); + + expect(runCommandCaptureSpy).toHaveBeenCalledWith( + "npm", + ["view", "example", "version", "--json"], + expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), + ); + expect(runCommandSpy).not.toHaveBeenCalled(); + }); + it("should migrate legacy user npm installs into the managed npm root during update", async () => { const legacyRoot = join(tempDir, "legacy-global", "node_modules"); const legacyPath = join(legacyRoot, "legacy-pkg"); @@ -2504,6 +2585,18 @@ export default function(api) { api.registerTool({ name: "test", description: "te ]); }); + it("should not report npm updates when the installed version is newer than the registry version", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "2.0.0" })); + settingsManager.setProjectPackages(["npm:example"]); + + vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.9.0"'); + + const updates = await packageManager.checkForAvailableUpdates(); + expect(updates).toEqual([]); + }); + it("should skip pinned packages when checking for updates", async () => { const installedNpmPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedNpmPath, { recursive: true }); diff --git a/packages/coding-agent/test/powershell-tool.test.ts b/packages/coding-agent/test/powershell-tool.test.ts new file mode 100644 index 00000000000..22bc193c895 --- /dev/null +++ b/packages/coding-agent/test/powershell-tool.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createPowerShellTool } from "../src/core/tools/powershell.ts"; +import { getPowerShellConfig, POWERSHELL_ARGS } from "../src/utils/shell.ts"; + +function getTextOutput(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((content) => content.type === "text") + .map((content) => content.text ?? "") + .join("\n"); +} + +describe("powershell tool", () => { + it("uses process-local execution policy bypass", () => { + expect(POWERSHELL_ARGS).toEqual(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"]); + }); + + it.skipIf(process.platform !== "win32")("executes PowerShell commands with UTF-8 output", async () => { + const config = getPowerShellConfig(); + expect(config.args).toEqual(POWERSHELL_ARGS); + + const tool = createPowerShellTool(process.cwd()); + const result = await tool.execute("powershell-test", { + command: "Write-Output 'héllo €'; Get-ExecutionPolicy -Scope Process", + }); + const output = getTextOutput(result); + + expect(output).toContain("héllo €"); + expect(output).toContain("Bypass"); + }); +}); diff --git a/packages/coding-agent/test/scrollbar-theme.test.ts b/packages/coding-agent/test/scrollbar-theme.test.ts index 01dbdbcca73..f07bb9c8ae5 100644 --- a/packages/coding-agent/test/scrollbar-theme.test.ts +++ b/packages/coding-agent/test/scrollbar-theme.test.ts @@ -27,7 +27,7 @@ afterEach(() => { } }); -describe("scrollbar theme color", () => { +describe("optional fullscreen theme colors", () => { it("falls back to selectedBg when scrollbarThumb is omitted", () => { const themeJson = loadDarkTheme(); themeJson.name = "legacy-scrollbar-theme"; @@ -45,4 +45,26 @@ describe("scrollbar theme color", () => { const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe("\x1b[48;2;18;52;86m"); }); + + it("falls back to existing selection and text colors for search highlights", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "legacy-search-theme"; + delete themeJson.colors.searchMatchBg; + delete themeJson.colors.searchMatchText; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("searchMatchBg")).toBe(loadedTheme.getBgAnsi("selectedBg")); + expect(loadedTheme.getFgAnsi("searchMatchText")).toBe(loadedTheme.getFgAnsi("text")); + }); + + it("uses explicitly configured search highlight colors", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "custom-search-theme"; + themeJson.colors.searchMatchBg = "#112233"; + themeJson.colors.searchMatchText = "#223344"; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("searchMatchBg")).toBe("\x1b[48;2;17;34;51m"); + expect(loadedTheme.getFgAnsi("searchMatchText")).toBe("\x1b[38;2;34;51;68m"); + }); }); diff --git a/packages/coding-agent/test/sdk-session-manager.test.ts b/packages/coding-agent/test/sdk-session-manager.test.ts index d60ea9d2eee..8ab4c431dfe 100644 --- a/packages/coding-agent/test/sdk-session-manager.test.ts +++ b/packages/coding-agent/test/sdk-session-manager.test.ts @@ -105,7 +105,7 @@ describe("createAgentSession session manager defaults", () => { }); expect(session.sessionFile).toBeTruthy(); expect(session.systemPrompt).toContain( - "Inspect PI_* environment variables for current model and session details.", + "You can inspect PI_* environment variables for current model and session details.", ); const bashTool = session.agent.state.tools.find((tool) => tool.name === "bash"); diff --git a/packages/coding-agent/test/server/create-harness.test.ts b/packages/coding-agent/test/server/create-harness.test.ts new file mode 100644 index 00000000000..c16f2d37cb3 --- /dev/null +++ b/packages/coding-agent/test/server/create-harness.test.ts @@ -0,0 +1,352 @@ +import { + AgentHarness, + type AgentHarnessOptions, + type ExecutionError, + type HarnessTool, + InMemorySessionStorage, + type Result, + Session, + type ShellExecOptions, +} from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { createModels } from "@earendil-works/pi-ai"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; +import { describe, expect, test, vi } from "vitest"; +import { + buildCodingAgentHarnessSystemPrompt, + type CodingAgentHarnessTool, + createCodingAgentHarness, +} from "../../src/server/create-harness.ts"; + +class CapturingExecutionEnv extends NodeExecutionEnv { + executionOverrides: Record | undefined; + + override async exec( + command: string, + options?: ShellExecOptions, + ): Promise> { + this.executionOverrides = options?.env; + return super.exec(command, options); + } +} + +async function resolveSystemPrompt(systemPrompt: AgentHarnessOptions["systemPrompt"]): Promise { + if (typeof systemPrompt === "string") return systemPrompt; + if (systemPrompt === undefined) throw new Error("Expected a system prompt callback"); + return systemPrompt(); +} + +function createPromptTool(name: string, promptSnippet?: string, promptGuidelines?: string[]): CodingAgentHarnessTool { + return { + name, + label: name, + description: `${name} description`, + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), + promptSnippet, + promptGuidelines, + }; +} + +const defaultPromptTools = [ + createPromptTool("read", "Read file contents", ["Use read to examine files instead of cat or sed."]), + createPromptTool("bash", "Execute bash commands (ls, grep, find, etc.)", [ + "You can inspect PI_* environment variables for current model and session details.", + ]), + createPromptTool("edit", "Edit files", ["Edit carefully."]), + createPromptTool("write", "Create or overwrite files", ["Use write only for new files or complete rewrites."]), +]; + +describe("coding-agent Harness construction", () => { + test("adds coding-agent policy to explicit Harness options", async () => { + const session = new Session(new InMemorySessionStorage({ id: "harness-session", createdAt: 1 })); + const env = new NodeExecutionEnv({ cwd: "/workspace" }); + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + thinkingLevel: "high", + env, + streamOptions: { maxTokens: 123 }, + retry: { enabled: true, maxRetries: 2, baseDelayMs: 10 }, + steeringMode: "all", + followUpMode: "all", + }); + try { + expect(created.suspended).toEqual([]); + expect(await created.harness.getActiveTools()).toEqual(["read", "bash", "edit", "write"]); + expect((await created.harness.getTools()).map((tool) => tool.name)).toEqual(["read", "bash", "edit", "write"]); + expect(await created.harness.getStreamOptions()).toEqual({ maxTokens: 123 }); + expect(await created.harness.getRetryPolicy()).toEqual({ enabled: true, maxRetries: 2, baseDelayMs: 10 }); + expect(await created.harness.getSteeringMode()).toBe("all"); + expect(await created.harness.getFollowUpMode()).toBe("all"); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("preserves coding-agent prompt snippets and guideline order", () => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: defaultPromptTools, + activeToolNames: ["read", "bash", "edit", "write"], + }); + expect(prompt).toContain("- read: Read file contents"); + expect(prompt).toContain("- bash: Execute bash commands (ls, grep, find, etc.)"); + expect(prompt).toContain("Use read to examine files instead of cat or sed."); + expect(prompt).toContain("You can inspect PI_* environment variables for current model and session details."); + expect(prompt.indexOf("Use read to examine files")).toBeLessThan( + prompt.indexOf("You can inspect PI_* environment variables"), + ); + }); + + test("preserves caller-supplied tools and activation", async () => { + const session = new Session(new InMemorySessionStorage({ id: "custom-harness-session", createdAt: 1 })); + const env = new NodeExecutionEnv({ cwd: "/workspace" }); + const customTool: HarnessTool = { + name: "inspect", + label: "inspect", + description: "Inspect the configured service", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), + }; + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + env, + tools: [customTool], + activeToolNames: [], + systemPrompt: "Server-owned prompt", + }); + try { + expect((await created.harness.getTools()).map((tool) => tool.name)).toEqual(["inspect"]); + expect(await created.harness.getActiveTools()).toEqual([]); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("sets the optional session file in the default bash tool environment", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session-file-harness", createdAt: 1 })); + const env = new CapturingExecutionEnv({ + cwd: process.cwd(), + shellEnv: { PI_SESSION_FILE: "/stale/parent.jsonl", PI_CODING_AGENT: "true" }, + }); + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + thinkingLevel: "high", + env, + sessionFile: "/sessions/current.jsonl", + }); + try { + const bash = (await created.harness.getTools()).find((tool) => tool.name === "bash"); + if (!bash) throw new Error("Expected the default bash tool"); + + const result = await bash.execute("bash-call", { + command: `printf '%s' "$PI_SESSION_ID|$PI_SESSION_FILE|$PI_PROVIDER|$PI_MODEL|$PI_REASONING_LEVEL|$PI_CODING_AGENT"`, + }); + + expect(env.executionOverrides).toEqual({ + PI_SESSION_ID: "session-file-harness", + PI_SESSION_FILE: "/sessions/current.jsonl", + PI_PROVIDER: "google", + PI_MODEL: "gemini-2.5-flash", + PI_REASONING_LEVEL: "high", + }); + expect(result.content).toEqual([ + { + type: "text", + text: "session-file-harness|/sessions/current.jsonl|google|gemini-2.5-flash|high|true", + }, + ]); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("keeps bash PI model variables synchronized with Harness state", async () => { + const session = new Session(new InMemorySessionStorage({ id: "dynamic-bash-session", createdAt: 1 })); + const env = new CapturingExecutionEnv({ + cwd: process.cwd(), + shellEnv: { PI_SESSION_FILE: "/stale/parent.jsonl", PI_CODING_AGENT: "true" }, + }); + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + thinkingLevel: "high", + env, + }); + try { + await created.harness.setModel(getModel("anthropic", "claude-sonnet-4-5")); + await created.harness.setThinkingLevel("low"); + const bash = (await created.harness.getTools()).find((tool) => tool.name === "bash"); + if (!bash) throw new Error("Expected the default bash tool"); + + const result = await bash.execute("bash-call", { + command: `printf '%s:%s' "\${PI_SESSION_FILE+x}" "$PI_SESSION_ID|$PI_PROVIDER|$PI_MODEL|$PI_REASONING_LEVEL|$PI_CODING_AGENT"`, + }); + + expect(env.executionOverrides).toEqual({ + PI_SESSION_ID: "dynamic-bash-session", + PI_SESSION_FILE: "", + PI_PROVIDER: "anthropic", + PI_MODEL: "claude-sonnet-4-5", + PI_REASONING_LEVEL: "low", + }); + expect(Object.hasOwn(env.executionOverrides ?? {}, "PI_SESSION_FILE")).toBe(true); + expect(env.executionOverrides?.PI_SESSION_FILE).toBe(""); + expect(result.content).toEqual([ + { + type: "text", + text: "x:dynamic-bash-session|anthropic|claude-sonnet-4-5|low|true", + }, + ]); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("builds each default system prompt from current Harness tool metadata", async () => { + const originalCreate = AgentHarness.create.bind(AgentHarness); + let configuredSystemPrompt: AgentHarnessOptions["systemPrompt"]; + const createSpy = vi.spyOn(AgentHarness, "create").mockImplementation(async (options) => { + configuredSystemPrompt = options.systemPrompt; + return originalCreate(options); + }); + const session = new Session(new InMemorySessionStorage({ id: "dynamic-prompt-session", createdAt: 1 })); + const env = new NodeExecutionEnv({ cwd: "/workspace" }); + try { + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + env, + }); + createSpy.mockRestore(); + try { + const initialPrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(initialPrompt).toContain("- read: Read file contents"); + expect(initialPrompt).toContain("- bash: Execute bash commands (ls, grep, find, etc.)"); + expect(initialPrompt).toContain("- edit: Make precise file edits with exact text replacement"); + expect(initialPrompt).toContain("- write: Create or overwrite files"); + + await created.harness.setActiveTools(["write"]); + const writePrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(writePrompt).toContain("- write: Create or overwrite files"); + expect(writePrompt).not.toContain("- read:"); + expect(writePrompt).not.toContain("- bash:"); + + const read = (await created.harness.getTools()).find((tool) => tool.name === "read"); + if (!read) throw new Error("Expected the default read tool"); + await created.harness.setTools([read]); + const readPrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(readPrompt).toContain("- read: Read file contents"); + expect(readPrompt).not.toContain("- write:"); + + const inspectTool: CodingAgentHarnessTool = { + name: "inspect", + label: "inspect", + description: "Inspect the configured service", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), + promptSnippet: " Inspect\nthe configured service ", + promptGuidelines: ["Use inspect for service diagnostics."], + }; + await created.harness.setTools([inspectTool]); + const inspectPrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(inspectPrompt).toContain("- inspect: Inspect the configured service"); + expect(inspectPrompt).toContain("Use inspect for service diagnostics."); + } finally { + await created.harness.close(); + await env.cleanup(); + } + } finally { + createSpy.mockRestore(); + } + }); + + test("omits active custom tools without prompt metadata from the textual tools section", () => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: [createPromptTool("hidden")], + activeToolNames: ["hidden"], + }); + + expect(prompt).toContain("Available tools:\n(none)"); + expect(prompt).not.toContain("- hidden:"); + expect(prompt).not.toContain("hidden description"); + }); + + test.each([ + [ + "bash", + "Execute bash commands (ls, grep, find, etc.)", + "You can inspect PI_* environment variables for current model and session details.", + ], + ["read", "Read file contents", "Use read to examine files instead of cat or sed."], + [ + "edit", + "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", + "Use edit for precise changes (edits[].oldText must match exactly)", + ], + ["write", "Create or overwrite files", "Use write only for new files or complete rewrites."], + ] as const)( + "does not infer prompt metadata for a caller-supplied %s replacement", + (name, builtInSnippet, builtInGuideline) => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: [createPromptTool(name)], + activeToolNames: [name], + }); + + expect(prompt).toContain("Available tools:\n(none)"); + expect(prompt).not.toContain(builtInSnippet); + expect(prompt).not.toContain(builtInGuideline); + }, + ); + + test("builds the default prompt from active tools and resolved prompt resources", () => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: defaultPromptTools, + activeToolNames: ["write", "read"], + systemPromptOptions: { + contextFiles: [{ path: "/workspace/AGENTS.md", content: "Follow project policy." }], + skills: [ + { + name: "review", + description: "Review server changes", + filePath: "/skills/review/SKILL.md", + baseDir: "/skills/review", + sourceInfo: { + path: "/skills/review/SKILL.md", + source: "test", + scope: "temporary", + origin: "top-level", + }, + disableModelInvocation: false, + }, + ], + }, + }); + + expect(prompt).toContain("- write: Create or overwrite files"); + expect(prompt).toContain("- read: Read file contents"); + expect(prompt).not.toContain("- bash:"); + expect(prompt).not.toContain("You can inspect PI_* environment variables"); + expect(prompt).toContain(''); + expect(prompt).toContain("review"); + expect(prompt.indexOf("Use write only for new files or complete rewrites.")).toBeLessThan( + prompt.indexOf("Use read to examine files instead of cat or sed."), + ); + }); +}); diff --git a/packages/coding-agent/test/session-id-readonly.test.ts b/packages/coding-agent/test/session-id-readonly.test.ts index 726a7577f38..5527193ec2e 100644 --- a/packages/coding-agent/test/session-id-readonly.test.ts +++ b/packages/coding-agent/test/session-id-readonly.test.ts @@ -1,23 +1,19 @@ import { spawn } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Args } from "../src/cli/args.ts"; import { ENV_AGENT_DIR } from "../src/config.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createSessionManager } from "../src/main.ts"; const cliPath = resolve(__dirname, "../src/cli.ts"); const tempDirs: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -50,141 +46,128 @@ function hasSessionWithId(root: string, sessionId: string): boolean { return false; } -interface CliDirs { - agentDir: string; - projectDir: string; - sessionDir: string; -} - -async function runCli( - args: string[] | ((dirs: CliDirs) => string[]), - setup?: (dirs: CliDirs) => void, -): Promise<{ code: number | null; agentDir: string; stderr: string }> { +async function runCli(args: string[]): Promise<{ code: number | null; agentDir: string }> { const tempRoot = createTempDir(); - const dirs: CliDirs = { - agentDir: join(tempRoot, "agent"), - projectDir: join(tempRoot, "project"), - sessionDir: join(tempRoot, "sessions"), - }; - mkdirSync(dirs.agentDir, { recursive: true }); - mkdirSync(dirs.projectDir, { recursive: true }); - setup?.(dirs); - const resolvedArgs = typeof args === "function" ? args(dirs) : args; + const agentDir = join(tempRoot, "agent"); + const projectDir = join(tempRoot, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); - let stderr = ""; const code = await new Promise((resolvePromise, reject) => { - const child = spawn(process.execPath, [cliPath, ...resolvedArgs], { - cwd: dirs.projectDir, + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: projectDir, env: { ...process.env, - [ENV_AGENT_DIR]: dirs.agentDir, + [ENV_AGENT_DIR]: agentDir, PI_OFFLINE: "1", TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), }, - stdio: ["ignore", "ignore", "pipe"], - }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); + stdio: ["ignore", "ignore", "ignore"], }); child.on("error", reject); child.on("close", resolvePromise); }); - return { code, agentDir: dirs.agentDir, stderr }; + return { code, agentDir }; } -function writeSession(sessionDir: string, cwd: string, id: string): void { - writeFileSync( - join(sessionDir, `${id}.jsonl`), - `${JSON.stringify({ type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd })}\n`, - ); +function args(overrides: Partial): Args { + return { + messages: [], + fileArgs: [], + unknownFlags: new Map(), + diagnostics: [], + ...overrides, + }; } -describe("--session-id read-only commands", () => { - it("does not reserve a session for --help", async () => { - const result = await runCli(["--session-id", "read-only-help", "--help"]); - - expect(result.code).toBe(0); - expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false); - }); - - it("allows --no-session with --session-id", async () => { - const result = await runCli(["--no-session", "--session-id", "ephemeral-id", "--help"]); - - expect(result.code).toBe(0); - expect(hasSessionWithId(join(result.agentDir, "sessions"), "ephemeral-id")).toBe(false); +function persistSession(session: SessionManager, content: string): void { + session.appendMessage({ role: "user", content, timestamp: Date.now() }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "persisted" }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), }); +} - it("does not reserve a session for --list-models", async () => { - const result = await runCli(["--session-id", "read-only-models", "--list-models"]); +describe("--session-id", () => { + it("does not persist a custom ID for metadata commands", async () => { + const result = await runCli(["--session-id", "read-only-help", "--help"]); expect(result.code).toBe(0); - expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-models")).toBe(false); + expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false); }); - it("warns when a missing --session-id creates a new session", async () => { - const result = await runCli((dirs) => [ - "--session-dir", - dirs.sessionDir, - "--session-id", - "missing-session-id", - "--model", - "missing-model", - "-p", - "hi", - ]); - - expect(result.code).toBe(1); - expect(result.stderr).toContain( - "Warning: No project session found with id 'missing-session-id'; creating a new session with that id.", + it("creates missing IDs and reopens existing IDs in process", async () => { + const tempRoot = createTempDir(); + const projectDir = join(tempRoot, "project"); + const sessionDir = join(tempRoot, "sessions"); + mkdirSync(projectDir, { recursive: true }); + const settingsManager = SettingsManager.inMemory(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const readOnly = await createSessionManager( + args({ sessionId: "read-only", help: true }), + projectDir, + sessionDir, + settingsManager, ); - }); - - it("does not warn when --session-id opens an existing session", async () => { - const result = await runCli( - (dirs) => [ - "--session-dir", - dirs.sessionDir, - "--session-id", - "existing-session-id", - "--model", - "missing-model", - "-p", - "hi", - ], - (dirs) => { - mkdirSync(dirs.sessionDir, { recursive: true }); - writeSession(dirs.sessionDir, dirs.projectDir, "existing-session-id"); - }, + expect(readOnly.getSessionId()).toBe("read-only"); + expect(readOnly.getSessionFile()).toBeUndefined(); + + const created = await createSessionManager( + args({ sessionId: "persisted-id" }), + projectDir, + sessionDir, + settingsManager, ); - - expect(result.code).toBe(1); - expect(result.stderr).not.toContain("No project session found with id 'existing-session-id'"); - }); - - it("rejects an existing fork target session id", async () => { - const result = await runCli( - (dirs) => ["--session-dir", dirs.sessionDir, "--fork", "source-id", "--session-id", "existing-id", "-p", "hi"], - (dirs) => { - mkdirSync(dirs.sessionDir, { recursive: true }); - writeSession(dirs.sessionDir, dirs.projectDir, "source-id"); - writeSession(dirs.sessionDir, dirs.projectDir, "existing-id"); - }, + persistSession(created, "persist me"); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("creating a new session")); + + consoleError.mockClear(); + const reopened = await createSessionManager( + args({ sessionId: "persisted-id" }), + projectDir, + sessionDir, + settingsManager, ); - - expect(result.code).toBe(1); - expect(result.stderr).toContain("Session already exists with id 'existing-id'"); + expect(reopened.getSessionFile()).toBe(created.getSessionFile()); + expect(consoleError).not.toHaveBeenCalled(); }); -}); -describe("--session-id validation", () => { - it("rejects ids invalid under SessionManager rules without stack traces", async () => { - for (const id of ["-bad", "bad id"]) { - const result = await runCli(["--session-id", id, "-p", "hi"]); + it("rejects an existing fork target in process", async () => { + const tempRoot = createTempDir(); + const projectDir = join(tempRoot, "project"); + const sessionDir = join(tempRoot, "sessions"); + mkdirSync(projectDir, { recursive: true }); + const source = SessionManager.create(projectDir, sessionDir, { id: "source-id" }); + persistSession(source, "source"); + const target = SessionManager.create(projectDir, sessionDir, { id: "existing-id" }); + persistSession(target, "target"); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit:${code}`); + }); - expect(result.code).toBe(1); - expect(result.stderr).toContain("Session id must be non-empty"); - expect(result.stderr).not.toContain("SessionManager.create"); - } + await expect( + createSessionManager( + args({ fork: "source-id", sessionId: "existing-id" }), + projectDir, + sessionDir, + SettingsManager.inMemory(), + ), + ).rejects.toThrow("exit:1"); }); }); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index d6e20f0430f..123dc65cd6e 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -321,12 +321,12 @@ describe("SessionManager append and tree traversal", () => { }); describe("branchWithSummary", () => { - it("inserts branch summary and advances leaf", () => { + it("inserts branch summary with the source and destination and advances leaf", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); const _id2 = session.appendMessage(assistantMsg("2")); - const _id3 = session.appendMessage(userMsg("3")); + const id3 = session.appendMessage(userMsg("3")); const usage = { input: 10, @@ -345,6 +345,7 @@ describe("SessionManager append and tree traversal", () => { expect(summaryEntry).toBeDefined(); expect(summaryEntry?.parentId).toBe(id1); if (summaryEntry?.type === "branch_summary") { + expect(summaryEntry.fromId).toBe(id3); expect(summaryEntry.summary).toBe("Summary of abandoned work"); expect(summaryEntry.usage).toEqual(usage); } diff --git a/packages/coding-agent/test/settings-diagnostics.test.ts b/packages/coding-agent/test/settings-diagnostics.test.ts new file mode 100644 index 00000000000..9bda0fd6a4c --- /dev/null +++ b/packages/coding-agent/test/settings-diagnostics.test.ts @@ -0,0 +1,47 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { collectSettingsDiagnostics, deduplicateDiagnostics } from "../src/core/settings-diagnostics.ts"; +import { SettingsManager, type SettingsStorage } from "../src/core/settings-manager.ts"; + +describe("settings diagnostics", () => { + it("includes the settings file path for file-backed storage", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-settings-diagnostics-")); + const agentDir = join(tempDir, "agent"); + const settingsPath = join(agentDir, "settings.json"); + mkdirSync(agentDir); + writeFileSync(settingsPath, "{"); + + try { + const diagnostics = collectSettingsDiagnostics(SettingsManager.create(tempDir, agentDir)); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.type).toBe("warning"); + expect(diagnostics[0]?.message).toContain(`Invalid settings file ${settingsPath}:`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("falls back to the settings scope for storage without file paths", () => { + const storage: SettingsStorage = { + withLock(scope, fn) { + if (scope === "global") throw new Error("backend failed"); + fn(undefined); + }, + }; + const diagnostics = collectSettingsDiagnostics(SettingsManager.fromStorage(storage)); + + expect(diagnostics).toEqual([{ type: "warning", message: "Invalid global settings: backend failed" }]); + }); + + it("deduplicates diagnostics by type and message", () => { + const warning = { type: "warning" as const, message: "Invalid settings file /tmp/settings.json" }; + + expect(deduplicateDiagnostics([warning, warning, { ...warning, type: "error" }])).toEqual([ + warning, + { ...warning, type: "error" }, + ]); + }); +}); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 799da20283b..3a2dbd0f79f 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -185,7 +185,7 @@ describe("SettingsManager", () => { expect(manager.getDefaultModel()).toBe("claude-sonnet"); }); - it("should keep previous settings when file is invalid", async () => { + it("should keep previous settings and report the file path when the file is invalid", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); @@ -195,6 +195,7 @@ describe("SettingsManager", () => { await manager.reload(); expect(manager.getTheme()).toBe("dark"); + expect(manager.drainErrors()).toMatchObject([{ scope: "global", path: settingsPath }]); }); }); @@ -227,7 +228,10 @@ describe("SettingsManager", () => { const errors = manager.drainErrors(); expect(errors).toHaveLength(2); - expect(errors.map((e) => e.scope).sort()).toEqual(["global", "project"]); + expect(errors).toMatchObject([ + { scope: "global", path: globalSettingsPath }, + { scope: "project", path: projectSettingsPath }, + ]); expect(manager.drainErrors()).toEqual([]); }); }); @@ -428,16 +432,25 @@ describe("SettingsManager", () => { }); }); - it("validates and persists the fullscreen scrollbar mode", async () => { + it("validates and persists fullscreen settings", async () => { const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getFullscreenExitOutput()).toBe("transcript"); expect(manager.getFullscreenScrollbar()).toBe("auto"); + manager.setFullscreenExitOutput("resume-hint"); manager.setFullscreenScrollbar("hidden"); await manager.flush(); - expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).fullscreenScrollbar).toBe("hidden"); - - writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ fullscreenScrollbar: "sometimes" })); - expect(SettingsManager.create(projectDir, agentDir).getFullscreenScrollbar()).toBe("auto"); + const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + expect(savedSettings.fullscreenExitOutput).toBe("resume-hint"); + expect(savedSettings.fullscreenScrollbar).toBe("hidden"); + + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ fullscreenExitOutput: "nothing", fullscreenScrollbar: "sometimes" }), + ); + const reloadedManager = SettingsManager.create(projectDir, agentDir); + expect(reloadedManager.getFullscreenExitOutput()).toBe("transcript"); + expect(reloadedManager.getFullscreenScrollbar()).toBe("auto"); }); describe("outputPad", () => { @@ -517,6 +530,23 @@ describe("SettingsManager", () => { }); }); + describe("defaultTools", () => { + it("loads global defaults and lets project settings replace them", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultTools: ["read", "bash"] })); + + expect(SettingsManager.create(projectDir, agentDir).getDefaultTools()).toEqual(["read", "bash"]); + + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultTools: ["grep"] })); + + expect(SettingsManager.create(projectDir, agentDir).getDefaultTools()).toEqual(["grep"]); + }); + + it("preserves an empty tool list", () => { + expect(SettingsManager.inMemory({ defaultTools: [] }).getDefaultTools()).toEqual([]); + expect(SettingsManager.inMemory().getDefaultTools()).toBeUndefined(); + }); + }); + describe("getSessionDir", () => { it("should return undefined when not set", () => { writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark" })); diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index d8f3c1e22c0..9c88fd2820e 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -14,24 +14,33 @@ describe("SettingsSelectorComponent", () => { setKeybindings(new KeybindingsManager()); }); - it("cycles through fullscreen scrollbar modes", () => { - const onChange = vi.fn(); - const selector = new SettingsSelectorComponent( - { - fullscreenScrollbar: "auto", - warnings: {}, - availableThinkingLevels: [], - availableThemes: [], - } as unknown as SettingsConfig, - { onFullscreenScrollbarChange: onChange } as unknown as SettingsCallbacks, - ); - const settingsList = selector.getSettingsList(); + it("cycles through fullscreen settings", () => { + const onExitOutputChange = vi.fn(); + const onScrollbarChange = vi.fn(); + const config = { + fullscreenExitOutput: "transcript", + fullscreenScrollbar: "auto", + warnings: {}, + defaultModel: "not set", + availableDefaultModels: [], + availableThinkingLevels: [], + modelThinkingLevels: {}, + availableThemes: [], + } as unknown as SettingsConfig; + const callbacks = { + onFullscreenExitOutputChange: onExitOutputChange, + onFullscreenScrollbarChange: onScrollbarChange, + } as unknown as SettingsCallbacks; - for (const character of "Fullscreen scrollbar") settingsList.handleInput(character); - settingsList.handleInput("\r"); - settingsList.handleInput("\r"); - settingsList.handleInput("\r"); + const cycle = (label: string, count: number) => { + const list = new SettingsSelectorComponent(config, callbacks).getSettingsList(); + for (const character of label) list.handleInput(character); + for (let i = 0; i < count; i++) list.handleInput("\r"); + }; - expect(onChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); + cycle("Fullscreen exit output", 2); + expect(onExitOutputChange.mock.calls.flat()).toEqual(["resume-hint", "transcript"]); + cycle("Fullscreen scrollbar", 3); + expect(onScrollbarChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); }); }); diff --git a/packages/coding-agent/test/startup-session-name.test.ts b/packages/coding-agent/test/startup-session-name.test.ts index 2d6f94ce553..3571ce66618 100644 --- a/packages/coding-agent/test/startup-session-name.test.ts +++ b/packages/coding-agent/test/startup-session-name.test.ts @@ -119,17 +119,4 @@ describe("startup session name", () => { expect(result.signal).toBeNull(); expect(readSessionInfoNames(dirs.sessionFile)).toEqual(["CLI Named Session"]); }); - - it("rejects empty --name values without appending session metadata", async () => { - const dirs = setup(); - const result = await runCli( - ["--session", dirs.sessionFile, "--name", " ", "--model", "missing-model", "-p", "hi"], - dirs, - ); - - expect(result.code).toBe(1); - expect(result.signal).toBeNull(); - expect(result.stderr).toContain("--name requires a non-empty value"); - expect(readSessionInfoNames(dirs.sessionFile)).toEqual([]); - }); }); diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index 83abde7c959..a5ffa5e68ef 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -85,20 +85,14 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe("stdout cleanliness in non-interactive modes", () => { - it("prints --version to stdout when stdout is redirected", async () => { - const result = await runCli(["--version"]); - - expect(result.code).toBe(0); - expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); - expect(result.stderr).toBe(""); - }); - it("prints plain --help to stdout when stdout is redirected", async () => { const result = await runCli(["--help"]); expect(result.code).toBe(0); expect(result.stdout).toContain("Usage:"); expect(result.stderr).not.toContain("Usage:"); + expect(result.stderr).not.toContain("changed 1 package in 471ms"); + expect(result.stderr).not.toContain("found 0 vulnerabilities"); }); it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => { @@ -110,24 +104,4 @@ describe("stdout cleanliness in non-interactive modes", () => { expect(result.stderr).toContain("found 0 vulnerabilities"); expect(result.stderr).toContain("Usage:"); }); - - it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => { - const result = await runCli(["-p", "--help", "--approve"]); - - expect(result.code).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("changed 1 package in 471ms"); - expect(result.stderr).toContain("found 0 vulnerabilities"); - expect(result.stderr).toContain("Usage:"); - }); - - it("ignores untrusted project package installs for help", async () => { - const result = await runCli(["-p", "--help"]); - - expect(result.code).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).not.toContain("changed 1 package in 471ms"); - expect(result.stderr).not.toContain("found 0 vulnerabilities"); - expect(result.stderr).toContain("Usage:"); - }); }); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index dbb512f3723..7d81358c553 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1,8 +1,11 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { type AssistantMessage, + type Context, createAssistantMessageEventStream, fauxAssistantMessage, type Model, + type SimpleStreamOptions, } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { estimateTokens } from "../../src/core/compaction/index.ts"; @@ -47,10 +50,15 @@ function createAssistant( }; } -function useSummaryStreamFn(harness: Harness, summary: string): () => number { +function useSummaryStreamFn( + harness: Harness, + summary: string, + onRequest?: (context: Context, options: SimpleStreamOptions | undefined) => void, +): () => number { let callCount = 0; - harness.session.agent.streamFunction = (model) => { + harness.session.agent.streamFunction = (model, context, options) => { callCount++; + onRequest?.(context, options); const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message: AssistantMessage = { @@ -246,6 +254,34 @@ describe("AgentSession compaction characterization", () => { expect(harness.faux.state.callCount).toBe(1); }); + it("uses the standalone compaction request context", async () => { + const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } } }); + harnesses.push(harness); + seedCompactableSession(harness); + + const transformContext = vi.fn(async (messages: AgentMessage[]) => messages); + harness.session.agent.transformContext = transformContext; + harness.session.agent.sessionId = "active-routing-session"; + harness.session.agent.transport = "websocket"; + + let requestContext: Context | undefined; + let requestOptions: SimpleStreamOptions | undefined; + useSummaryStreamFn(harness, "standalone summary", (context, options) => { + requestContext = context; + requestOptions = options; + }); + + await harness.session.compact(); + + expect(transformContext).not.toHaveBeenCalled(); + expect(requestContext?.systemPrompt).not.toBe(harness.session.agent.state.systemPrompt); + expect(requestContext?.tools).toBeUndefined(); + expect(JSON.stringify(requestContext?.messages)).toContain(""); + expect(requestOptions).toMatchObject({ cacheRetention: "none" }); + expect(requestOptions?.sessionId).not.toBe("active-routing-session"); + expect(requestOptions?.transport).toBeUndefined(); + }); + it("persists usage from pi-generated manual compaction", async () => { const harness = await createHarness({ withConfiguredAuth: false }); harnesses.push(harness); @@ -278,6 +314,50 @@ describe("AgentSession compaction characterization", () => { expect(getStreamCallCount()).toBe(1); }); + it("notifies extensions when auto-compaction fails", async () => { + const failedEvents: Array<{ + reason: "manual" | "threshold" | "overflow"; + errorMessage?: string; + aborted: boolean; + willRetry: boolean; + fromExtension: boolean; + }> = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("session_compact_failed", async (event) => { + failedEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + seedCompactableSession(harness); + harness.session.agent.streamFunction = () => { + throw new Error("summary generator blew up"); + }; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + + await expect(sessionInternals._runAutoCompaction("threshold", false)).resolves.toBe(false); + + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "threshold", + aborted: false, + willRetry: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }); + expect(failedEvents).toEqual([ + expect.objectContaining({ + type: "session_compact_failed", + reason: "threshold", + aborted: false, + willRetry: false, + fromExtension: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }), + ]); + }); + it("compacts and resumes after a length stop below the desired output limit", async () => { const harness = await createHarness({ models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }], @@ -353,6 +433,34 @@ describe("AgentSession compaction characterization", () => { expect(harness.faux.state.callCount).toBe(2); expect(harness.eventsOfType("compaction_start").filter((event) => event.reason === "overflow")).toHaveLength(1); expect(harness.eventsOfType("compaction_end").at(-1)?.errorMessage).toBe( + "Truncated response recovery failed after one compact-and-retry attempt.", + ); + }); + + it("keeps overflow wording when a repeated length stop fills the context window", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 100 }], + }); + harnesses.push(harness); + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const lengthOverflowMessage = createAssistant(harness, { + stopReason: "length", + totalTokens: 100, + timestamp: Date.now(), + }); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + const compactionErrors: string[] = []; + harness.session.subscribe((event) => { + if (event.type === "compaction_end" && event.errorMessage) { + compactionErrors.push(event.errorMessage); + } + }); + + await sessionInternals._checkCompaction(lengthOverflowMessage); + await sessionInternals._checkCompaction({ ...lengthOverflowMessage, timestamp: Date.now() + 1 }); + + expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1); + expect(compactionErrors).toContain( "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", ); }); diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index b334b1232a0..f024a67d151 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -14,7 +14,7 @@ describe("AgentSession model and extension characterization", () => { } }); - it("setModel saves the model and emits model_select", async () => { + it("setModel saves the model to the session and emits model_select", async () => { const modelEvents: string[] = []; const harness = await createHarness({ models: [ @@ -42,6 +42,127 @@ describe("AgentSession model and extension characterization", () => { .filter((entry) => entry.type === "model_change") .map((entry) => `${entry.provider}/${entry.modelId}`), ).toEqual([`${nextModel.provider}/${nextModel.id}`]); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + }); + + it("only persists model and thinking defaults when requested", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + const nextModel = harness.getModel("faux-2")!; + + await harness.session.setModel(nextModel); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + + harness.session.setThinkingLevel("low"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBeUndefined(); + + await harness.session.setModel(nextModel, { persist: true }); + expect(harness.settingsManager.getDefaultProvider()).toBe(nextModel.provider); + expect(harness.settingsManager.getDefaultModel()).toBe(nextModel.id); + + harness.session.setThinkingLevel("high", { persist: true }); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("high"); + }); + + it("persists the requested default thinking level even when the current model clamps it", async () => { + const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] }); + harnesses.push(harness); + + harness.session.setThinkingLevel("max", { persist: true }); + + expect(harness.session.thinkingLevel).toBe("high"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("max"); + }); + + it("cycleModel and cycleThinkingLevel are session-only by default", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultProvider: "faux", + defaultModel: "faux-1", + defaultThinkingLevel: "low", + }, + }); + harnesses.push(harness); + + await harness.session.cycleModel(); + expect(harness.session.model?.id).toBe("faux-2"); + expect(harness.settingsManager.getDefaultModel()).toBe("faux-1"); + + harness.session.setThinkingLevel("off"); + expect(harness.session.cycleThinkingLevel()).toBe("minimal"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low"); + }); + + it("applies per-model thinking level override on model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { defaultThinkingLevel: "medium" }, + }); + harnesses.push(harness); + + // Set a per-model override for faux-2 + harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low"); + + // Session starts on faux-1 with default thinking + harness.session.setThinkingLevel("high"); + expect(harness.session.thinkingLevel).toBe("high"); + + // Switch to faux-2 → per-model override should apply + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("low"); + + // Switch back to faux-1 → no per-model override, uses global default + const model1 = harness.getModel("faux-1")!; + await harness.session.setModel(model1); + expect(harness.session.thinkingLevel).toBe("medium"); + }); + + it("falls back to current session thinking level when no per-model or global default is configured", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + + harness.session.setThinkingLevel("high"); + await harness.session.setModel(harness.getModel("faux-2")!); + expect(harness.session.thinkingLevel).toBe("high"); + }); + + it("per-model override takes priority over global default during model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultThinkingLevel: "high", + modelThinkingLevels: { "faux/faux-2": "minimal" }, + }, + }); + harnesses.push(harness); + + // Start on a non-thinking model, then switch to faux-2 + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("minimal"); }); it("cycles through scoped models and preserves the scoped thinking preference", async () => { diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index 0e9c587fd05..1fa23fbe29c 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -5,7 +5,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import type { InputEvent } from "../../src/core/extensions/index.ts"; +import type { ExtensionAPI, InputEvent } from "../../src/core/extensions/index.ts"; import type { PromptTemplate } from "../../src/core/prompt-templates.ts"; import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; import { createTestResourceLoader } from "../utilities.ts"; @@ -224,6 +224,39 @@ describe("AgentSession prompt characterization", () => { expect(expandedPrompt).toBe("Review this code: src/index.ts"); }); + it("sendUserMessage can opt into prompt template expansion", async () => { + const template: PromptTemplate = { + name: "review", + description: "Review template", + content: "Review this code: $1", + filePath: "/virtual/review.md", + sourceInfo: createSyntheticSourceInfo("/virtual/review.md", { + source: "local", + scope: "temporary", + origin: "top-level", + }), + }; + const resourceLoader = { + ...createTestResourceLoader(), + getPrompts: () => ({ prompts: [template], diagnostics: [] }), + }; + const harness = await createHarness({ resourceLoader }); + harnesses.push(harness); + let expandedPrompt = ""; + + harness.setResponses([ + (context) => { + const user = context.messages.find((message) => message.role === "user"); + expandedPrompt = user ? getMessageText(user) : ""; + return fauxAssistantMessage("ok"); + }, + ]); + + await harness.session.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true }); + + expect(expandedPrompt).toBe("Review this code: src/index.ts"); + }); + it("dispatches extension commands without consuming a provider response", async () => { const commandRuns: string[] = []; const harness = await createHarness({ @@ -248,6 +281,35 @@ describe("AgentSession prompt characterization", () => { expect(harness.getPendingResponseCount()).toBe(1); }); + it("extension sendUserMessage can opt into extension command dispatch", async () => { + let extensionApi: ExtensionAPI | undefined; + let resolveCommandRun: (args: string) => void = () => {}; + const commandRun = new Promise((resolve) => { + resolveCommandRun = resolve; + }); + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + extensionApi = pi; + pi.registerCommand("testcmd", { + description: "Test command", + handler: async (args) => { + resolveCommandRun(args); + }, + }); + }, + ], + }); + harnesses.push(harness); + expect(extensionApi).toBeDefined(); + + extensionApi?.sendUserMessage("/testcmd hello world", { expandPromptTemplates: true }); + + await expect(commandRun).resolves.toBe("hello world"); + expect(harness.session.messages).toEqual([]); + expect(harness.getPendingResponseCount()).toBe(0); + }); + it("sendUserMessage while idle triggers a turn", async () => { const harness = await createHarness(); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts b/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts index 6a37b184130..884ef2f264b 100644 --- a/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts +++ b/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts @@ -1,95 +1,40 @@ -import { crc32, deflateSync } from "node:zlib"; import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ImageContent } from "@earendil-works/pi-ai"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createHarness, type Harness } from "./harness.ts"; -function pngChunk(type: string, body: Buffer): Buffer { - const header = Buffer.alloc(8); - header.writeUInt32BE(body.length, 0); - header.write(type, 4, "ascii"); - const checksum = Buffer.alloc(4); - checksum.writeUInt32BE(crc32(Buffer.concat([header.subarray(4), body])), 0); - return Buffer.concat([header, body, checksum]); -} +const normalizeToolResultImages = vi.hoisted(() => vi.fn(async (content: unknown[]) => content)); +vi.mock("../../src/utils/tool-result-images.ts", () => ({ normalizeToolResultImages })); -/** Build an 8-bit grayscale PNG of arbitrary dimensions without pulling in an encoder. */ -function createPng(width: number, height: number): Buffer { - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; // bit depth - ihdr[9] = 0; // color type: grayscale - const raw = Buffer.alloc((width + 1) * height); - for (let row = 0; row < height; row++) { - raw.fill(row % 256, row * (width + 1) + 1, (row + 1) * (width + 1)); - } - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - pngChunk("IHDR", ihdr), - pngChunk("IDAT", deflateSync(raw)), - pngChunk("IEND", Buffer.alloc(0)), - ]); -} +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; -function readPngDimensions(base64Data: string): { width: number; height: number } { - const buffer = Buffer.from(base64Data, "base64"); - return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; -} - -const OVERSIZED_PNG_BASE64 = createPng(2400, 4800).toString("base64"); - -/** Stands in for extension, MCP bridge, or screenshot tools that return images they produced. */ const screenshotTool: AgentTool = { name: "screenshot", label: "Screenshot", - description: "Return an oversized screenshot", + description: "Return a screenshot", parameters: Type.Object({}), execute: async () => ({ content: [ { type: "text", text: "captured" }, - { type: "image", data: OVERSIZED_PNG_BASE64, mimeType: "image/png" }, + { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }, ], details: {}, }), }; -function getToolResultImages(harness: Harness): ImageContent[] { - return harness.session.messages - .filter((message) => message.role === "toolResult") - .flatMap((message) => message.content) - .filter((block): block is ImageContent => block.type === "image"); -} - describe("AgentSession tool result images", () => { const harnesses: Harness[] = []; afterEach(() => { + normalizeToolResultImages.mockClear(); while (harnesses.length > 0) { harnesses.pop()?.cleanup(); } }); - it("resizes oversized tool result images before they enter history", async () => { - const harness = await createHarness({ tools: [screenshotTool] }); - harnesses.push(harness); - harness.setResponses([ - fauxAssistantMessage([fauxToolCall("screenshot", {})], { stopReason: "toolUse" }), - fauxAssistantMessage("done"), - ]); - - await harness.session.prompt("take a screenshot"); - - const images = getToolResultImages(harness); - expect(images).toHaveLength(1); - const { width, height } = readPngDimensions(images[0].data); - expect(width).toBeLessThanOrEqual(2000); - expect(height).toBeLessThanOrEqual(2000); - }); - - it("honors images.autoResize being disabled", async () => { + it("passes images.autoResize to tool result normalization", async () => { const harness = await createHarness({ tools: [screenshotTool], settings: { images: { autoResize: false } }, @@ -102,8 +47,6 @@ describe("AgentSession tool result images", () => { await harness.session.prompt("take a screenshot"); - const images = getToolResultImages(harness); - expect(images).toHaveLength(1); - expect(images[0].data).toBe(OVERSIZED_PNG_BASE64); + expect(normalizeToolResultImages).toHaveBeenCalledWith(expect.any(Array), { autoResizeImages: false }); }); }); diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index c44e1b36f3e..e9f33f90ccb 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -78,7 +78,6 @@ describe("issue #3217 scoped model ordering", () => { const selector = new ModelSelectorComponent( createFakeTui(), modelOne, - harness.settingsManager, harness.session.modelRuntime, [{ model: modelTwo }, { model: modelOne }, { model: modelThree }], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts index 3d900ee361c..86ad26a08c9 100644 --- a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts +++ b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts @@ -78,7 +78,7 @@ describe("regression #3592: no-builtin-tools keeps extension tools enabled", () .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["dynamic_tool"]); expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); expect(session.systemPrompt).not.toContain("- read:"); diff --git a/packages/coding-agent/test/suite/regressions/5998-blocked-tool-terminate.test.ts b/packages/coding-agent/test/suite/regressions/5998-blocked-tool-terminate.test.ts new file mode 100644 index 00000000000..93413be83da --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5998-blocked-tool-terminate.test.ts @@ -0,0 +1,53 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, getAssistantTexts, type Harness } from "../harness.ts"; + +describe("#5998 blocked tool termination", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("lets a tool_call handler terminate the run after blocking execution", async () => { + const echoTool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo text back", + parameters: Type.Object({ text: Type.String() }), + execute: async () => { + throw new Error("tool should have been blocked"); + }, + }; + const harness = await createHarness({ + tools: [echoTool], + extensionFactories: [ + (pi) => { + pi.on("tool_call", async () => ({ + block: true, + reason: "Blocked by terminating policy", + terminate: true, + })); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }), + fauxAssistantMessage("should not run"), + ]); + + await harness.session.prompt("hi"); + + expect(harness.getPendingResponseCount()).toBe(1); + expect(getAssistantTexts(harness)).not.toContain("should not run"); + expect(harness.eventsOfType("tool_execution_end")[0]?.result).toHaveProperty("terminate", true); + expect( + harness.session.messages.find((message) => message.role === "toolResult" && message.isError), + ).toBeDefined(); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts index 8a4c144bc28..2f64c52a7c2 100644 --- a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts +++ b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts @@ -5,7 +5,6 @@ import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../../../src/core/auth-storage.ts"; import { KeybindingsManager } from "../../../src/core/keybindings.ts"; -import { SettingsManager } from "../../../src/core/settings-manager.ts"; import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../../src/utils/ansi.ts"; @@ -69,7 +68,6 @@ describe("issue #6999 models.json hot reload", () => { const selector = new ModelSelectorComponent( tui, undefined, - SettingsManager.inMemory(), modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts b/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts new file mode 100644 index 00000000000..7935951ddbb --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts @@ -0,0 +1,48 @@ +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +function seedCompactableSession(harness: Harness): void { + harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const now = Date.now(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "message to compact" }], + timestamp: now - 1000, + }); + const model = harness.getModel(); + const assistant: AssistantMessage = { + ...fauxAssistantMessage("assistant response to compact", { timestamp: now - 500 }), + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 100, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + harness.sessionManager.appendMessage(assistant); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; +} + +describe("#7048 truncated compaction summaries", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + harness = undefined; + }); + + it("does not persist a length-limited summary", async () => { + harness = await createHarness(); + seedCompactableSession(harness); + harness.setResponses([fauxAssistantMessage("partial summar", { stopReason: "length" })]); + + await expect(harness.session.compact()).rejects.toThrow("generation hit the token cap"); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(0); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts b/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts index 0fb55a30627..dccdc9d86b5 100644 --- a/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts +++ b/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts @@ -101,7 +101,7 @@ describe("issue #7153 scoped models refresh", () => { expect(refresh.refreshSignal).toBeDefined(); refresh.selector.handleInput("\x1b"); - expect(refresh.refreshSignal?.aborted).toBe(true); + await vi.waitFor(() => expect(refresh.refreshSignal?.aborted).toBe(true)); expect(refresh.done).toHaveBeenCalledOnce(); }); }); diff --git a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts index 84551ddfbf9..e853ac35a02 100644 --- a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts @@ -51,7 +51,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), current, - harness.settingsManager, harness.session.modelRuntime, [], () => {}, @@ -102,7 +101,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), alpha1, - harness.settingsManager, harness.session.modelRuntime, [{ model: alpha2 }, { model: alpha3 }, { model: alpha1 }], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/7269-cli-end-of-options.test.ts b/packages/coding-agent/test/suite/regressions/7269-cli-end-of-options.test.ts new file mode 100644 index 00000000000..cd012c28056 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7269-cli-end-of-options.test.ts @@ -0,0 +1,39 @@ +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseArgs } from "../../../src/cli/args.ts"; +import { createHarness, getUserTexts, type Harness } from "../harness.ts"; + +describe("issue #7269 CLI end-of-options delimiter", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + harness = undefined; + }); + + it.each(["- summarize the following points for me", "--answer my question briefly"])( + "passes %j as a prompt after --", + async (prompt) => { + const parsed = parseArgs(["-ne", "--no-session", "-p", "--", prompt]); + expect(parsed.messages).toEqual([prompt]); + expect(parsed.unknownFlags.size).toBe(0); + expect(parsed.diagnostics).toEqual([]); + + harness = await createHarness(); + harness.setResponses([fauxAssistantMessage("ok")]); + await harness.session.prompt(parsed.messages[0]); + expect(getUserTexts(harness)).toEqual([prompt]); + }, + ); + + it("stops parsing options while retaining @file handling", () => { + const parsed = parseArgs(["--unknown-flag", "value", "--", "--provider", "openai", "-c", "@prompt.md"]); + + expect(parsed.unknownFlags.get("unknown-flag")).toBe("value"); + expect(parsed.provider).toBeUndefined(); + expect(parsed.continue).toBeUndefined(); + expect(parsed.messages).toEqual(["--provider", "openai", "-c"]); + expect(parsed.fileArgs).toEqual(["prompt.md"]); + expect(parsed.diagnostics).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/7731-tui-method-wrapping.test.ts b/packages/coding-agent/test/suite/regressions/7731-tui-method-wrapping.test.ts new file mode 100644 index 00000000000..ae91246ab50 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7731-tui-method-wrapping.test.ts @@ -0,0 +1,31 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { createInteractiveTuiReference } from "../../../src/modes/interactive/interactive-mode.ts"; + +describe("TUI method wrapping", () => { + it("calls the method captured before a replacement", () => { + const renderer = { + render: (width: number) => [`width: ${width}`], + } as unknown as TUI; + const tui = createInteractiveTuiReference(() => renderer); + const originalRender = tui.render; + tui.render = (width: number) => originalRender(width); + + expect(tui.render(80)).toEqual(["width: 80"]); + }); + + it("routes a captured method to a replacement renderer", () => { + const regularRequestRender = vi.fn(); + const fullscreenRequestRender = vi.fn(); + let renderer = { requestRender: regularRequestRender } as unknown as TUI; + const tui = createInteractiveTuiReference(() => renderer); + const requestRender = tui.requestRender; + + requestRender(); + renderer = { requestRender: fullscreenRequestRender } as unknown as TUI; + requestRender(); + + expect(regularRequestRender).toHaveBeenCalledOnce(); + expect(fullscreenRequestRender).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts b/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts new file mode 100644 index 00000000000..cb73e360264 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts @@ -0,0 +1,56 @@ +import { Container } from "@earendil-works/pi-tui"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { AgentSessionRuntimeDiagnostic } from "../../../src/core/agent-session-services.ts"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { createHarness } from "../harness.ts"; + +function render(container: Container): string { + return container.children.flatMap((child) => child.render(120)).join("\n"); +} + +describe("issue #7829 invalid settings warning", () => { + beforeAll(() => initTheme("dark")); + + it("renders startup diagnostics inside the transcript", async () => { + const harness = await createHarness(); + const previousOffline = process.env.PI_OFFLINE; + process.env.PI_OFFLINE = "1"; + try { + const chatContainer = new Container(); + const startupDiagnostics: AgentSessionRuntimeDiagnostic[] = [ + { + type: "warning", + message: "Invalid settings file /tmp/settings.json: malformed JSON", + }, + ]; + const context = { + init: vi.fn(async () => {}), + options: { startupDiagnostics }, + chatContainer, + outputPad: 1, + ui: { requestRender: vi.fn() }, + version: "test", + showWarning: (InteractiveMode.prototype as unknown as { showWarning(message: string): void }).showWarning, + session: harness.session, + checkForPackageUpdates: vi.fn().mockResolvedValue([]), + checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), + getUserInput: vi.fn(() => new Promise(() => {})), + }; + const run = (InteractiveMode.prototype as unknown as { run(this: typeof context): Promise }).run; + + void run.call(context); + + await vi.waitFor(() => { + expect(render(chatContainer)).toContain( + "Warning: Invalid settings file /tmp/settings.json: malformed JSON", + ); + }); + } finally { + if (previousOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = previousOffline; + harness.cleanup(); + } + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/7911-json-stream-usage.test.ts b/packages/coding-agent/test/suite/regressions/7911-json-stream-usage.test.ts new file mode 100644 index 00000000000..b014a91a4fc --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7911-json-stream-usage.test.ts @@ -0,0 +1,32 @@ +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { toJsonEvent } from "../../../src/modes/json-event.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("regression #7911: JSON message updates retain usage", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + }); + + it("includes cumulative usage without cumulative message snapshots", async () => { + harness = await createHarness(); + harness.setResponses([fauxAssistantMessage("hello")]); + + await harness.session.prompt("respond"); + + // #7290's delta-only wire projection dropped this fixed-size metadata with the snapshots. + const update = harness + .eventsOfType("message_update") + .find((event) => event.message.role === "assistant" && event.message.usage.totalTokens > 0); + if (!update || update.message.role !== "assistant") { + throw new Error("Expected an assistant update with populated usage"); + } + + const wireUpdate = toJsonEvent(update); + expect(wireUpdate.usage).toEqual(update.message.usage); + expect(wireUpdate).not.toHaveProperty("message"); + expect(wireUpdate.assistantMessageEvent).not.toHaveProperty("partial"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts new file mode 100644 index 00000000000..e58de9e9756 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts @@ -0,0 +1,43 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { toJsonEvent } from "../../../src/modes/json-event.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("regression #7925: tool-call metadata is available when streaming starts", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + }); + + it("includes the tool call id and name without cumulative snapshots", async () => { + harness = await createHarness(); + harness.setResponses([ + fauxAssistantMessage( + fauxToolCall("write", { path: "output.txt", content: "x".repeat(100) }, { id: "call_7925" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("write a file"); + + const update = harness + .eventsOfType("message_update") + .find((event) => event.assistantMessageEvent.type === "toolcall_start"); + if (!update || update.message.role !== "assistant") { + throw new Error("Expected toolcall_start assistant update"); + } + + expect(toJsonEvent(update)).toEqual({ + type: "message_update", + usage: update.message.usage, + assistantMessageEvent: { + type: "toolcall_start", + contentIndex: 0, + id: "call_7925", + toolName: "write", + }, + }); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/8237-node-sea-extension-loading.test.ts b/packages/coding-agent/test/suite/regressions/8237-node-sea-extension-loading.test.ts new file mode 100644 index 00000000000..160bf7dc3d5 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8237-node-sea-extension-loading.test.ts @@ -0,0 +1,50 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => { + const originalGetBuiltinModule = Object.getOwnPropertyDescriptor(process, "getBuiltinModule"); + const getBuiltinModule = process.getBuiltinModule.bind(process); + Object.defineProperty(process, "getBuiltinModule", { + configurable: true, + value: (id: string) => (id === "node:sea" ? { isSea: () => true } : getBuiltinModule(id)), + }); + return { + originalGetBuiltinModule, + createJiti: vi.fn((_id: unknown, _options: unknown) => ({ + import: vi.fn(async () => () => {}), + })), + }; +}); + +vi.mock("jiti/static", () => ({ createJiti: state.createJiti })); + +import { loadExtensions } from "../../../src/core/extensions/loader.ts"; + +interface JitiOptionsProbe { + alias?: unknown; + tryNative?: boolean; + virtualModules?: Record; +} + +afterAll(() => { + if (state.originalGetBuiltinModule) { + Object.defineProperty(process, "getBuiltinModule", state.originalGetBuiltinModule); + } +}); + +describe("Node SEA extension loading", () => { + it("uses bundled virtual modules instead of filesystem aliases", async () => { + const result = await loadExtensions(["/extension.ts"], "/"); + + expect(result.errors).toEqual([]); + expect(result.extensions).toHaveLength(1); + expect(state.createJiti).toHaveBeenCalledOnce(); + + const options = state.createJiti.mock.calls[0][1] as JitiOptionsProbe; + // Source TypeScript also uses virtual modules, so tryNative: false is what + // proves the compiled-binary branch took precedence over the source branch. + expect(options.tryNative).toBe(false); + expect(options.alias).toBeUndefined(); + expect(options.virtualModules?.typebox).toBeDefined(); + expect(options.virtualModules?.["@earendil-works/pi-coding-agent"]).toBeDefined(); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/8261-subagent-project-trust.test.ts b/packages/coding-agent/test/suite/regressions/8261-subagent-project-trust.test.ts new file mode 100644 index 00000000000..211e1bfa51e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8261-subagent-project-trust.test.ts @@ -0,0 +1,82 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { describe, expect, it, vi } from "vitest"; +import subagentExtension from "../../../examples/extensions/subagent/index.ts"; +import type { ExtensionUIContext } from "../../../src/core/extensions/index.ts"; +import { createHarness, getMessageText } from "../harness.ts"; + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + CONFIG_DIR_NAME: ".pi", + getAgentDir: () => "/missing-user-agent-dir", + getMarkdownTheme: () => ({}), + parseFrontmatter: (content: string) => ({ + frontmatter: { name: "project-agent", description: "Project test agent" }, + body: content, + }), + withFileMutationQueue: async (_path: string, fn: () => Promise) => fn(), +})); + +interface RunOptions { + trusted: boolean; + confirmResult?: boolean; +} + +async function runProjectAgent(options: RunOptions): Promise<{ confirmCalls: number; toolResult: string }> { + const harness = await createHarness({ extensionFactories: [subagentExtension] }); + const confirm = vi.fn(async () => options.confirmResult ?? false); + + try { + const agentsDir = join(harness.tempDir, ".pi", "agents"); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "project-agent.md"), + "---\nname: project-agent\ndescription: Project test agent\n---\n\nHandle the delegated task.\n", + ); + harness.settingsManager.setProjectTrusted(options.trusted); + + await harness.session.bindExtensions({ + uiContext: { confirm } as unknown as ExtensionUIContext, + mode: "tui", + }); + + harness.setResponses([ + fauxAssistantMessage( + fauxToolCall("subagent", { + agent: "project-agent", + task: "Test project trust", + agentScope: "project", + cwd: join(harness.tempDir, "missing-cwd"), + }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("Delegate this task"); + + const toolResult = harness.session.messages.find((message) => message.role === "toolResult"); + return { + confirmCalls: confirm.mock.calls.length, + toolResult: getMessageText(toolResult), + }; + } finally { + harness.cleanup(); + } +} + +describe("regression #8261: subagent project trust", () => { + it("skips per-call confirmation for trusted projects", async () => { + const result = await runProjectAgent({ trusted: true }); + + expect(result.confirmCalls).toBe(0); + expect(result.toolResult).not.toContain("Canceled:"); + }); + + it("keeps confirmation for untrusted interactive projects", async () => { + const result = await runProjectAgent({ trusted: false, confirmResult: false }); + + expect(result.confirmCalls).toBe(1); + expect(result.toolResult).toContain("Canceled: project-local agents not approved."); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts b/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts new file mode 100644 index 00000000000..910690bee28 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts @@ -0,0 +1,80 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +type SessionWithCompactionInternals = { + _checkCompaction: (assistantMessage: AssistantMessage) => Promise; + _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; +}; + +function createZeroUsageAssistant(harness: Harness): AssistantMessage { + const model = harness.getModel(); + return { + role: "assistant", + content: [{ type: "text", text: "response" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +describe("issue #8328 zero-usage auto-compaction", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + async function createCompactionHarness(): Promise { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 20 }], + settings: { compaction: { enabled: true, reserveTokens: 10 } }, + }); + harnesses.push(harness); + return harness; + } + + it("uses the message estimate when no assistant has reported usage", async () => { + const harness = await createCompactionHarness(); + const assistant = createZeroUsageAssistant(harness); + harness.session.agent.state.messages = [ + { role: "user", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() - 1 }, + assistant, + ]; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + + await sessionInternals._checkCompaction(assistant); + + expect(runAutoCompactionSpy).toHaveBeenCalledOnce(); + expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false); + }); + + it("does not compact when the zero-usage message estimate is below the threshold", async () => { + const harness = await createCompactionHarness(); + const assistant = createZeroUsageAssistant(harness); + harness.session.agent.state.messages = [ + { role: "user", content: [{ type: "text", text: "short" }], timestamp: Date.now() - 1 }, + assistant, + ]; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + + await sessionInternals._checkCompaction(assistant); + + expect(runAutoCompactionSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts b/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts new file mode 100644 index 00000000000..eef0d710e39 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts @@ -0,0 +1,49 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; +import { parseFrontmatter } from "../../../src/utils/frontmatter.ts"; +import { splitBom } from "../../../src/utils/text.ts"; + +describe("issue #8337 UTF-8 BOM parsing", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "pi-8337-")); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it("loads frontmatter and settings with a leading BOM", async () => { + expect(splitBom("\uFEFFcontent")).toEqual({ bom: "\uFEFF", text: "content" }); + const document = "---\nname: demo\ndescription: Test\n---\nBody"; + expect(parseFrontmatter(`\uFEFF${document}`)).toEqual({ + frontmatter: { name: "demo", description: "Test" }, + body: "Body", + }); + + const agentDir = join(testDir, "agent"); + const projectDir = join(testDir, "project"); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + const globalSettingsPath = join(agentDir, "settings.json"); + writeFileSync(globalSettingsPath, `\uFEFF${JSON.stringify({ defaultModel: "global-model" })}`); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + `\uFEFF${JSON.stringify({ defaultProvider: "project-provider" })}`, + ); + + const settings = SettingsManager.create(projectDir, agentDir); + expect(settings.getDefaultModel()).toBe("global-model"); + expect(settings.getDefaultProvider()).toBe("project-provider"); + + settings.setTheme("dark"); + await settings.flush(); + expect(readFileSync(globalSettingsPath, "utf-8")).not.toMatch(/^\uFEFF/); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/8423-extension-factory-failure.test.ts b/packages/coding-agent/test/suite/regressions/8423-extension-factory-failure.test.ts new file mode 100644 index 00000000000..e6511cbb69e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8423-extension-factory-failure.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { createEventBus } from "../../../src/core/event-bus.ts"; +import { createExtensionRuntime, loadExtensionFromFactory } from "../../../src/core/extensions/loader.ts"; +import type { ExtensionAPI, ProviderConfig } from "../../../src/core/extensions/types.ts"; + +const providerConfig = { + baseUrl: "https://provider.test/v1", + apiKey: "provider-test-key", +} satisfies ProviderConfig; + +describe("issue #8423 extension factory failure", () => { + it("discards runtime changes and disables the failed API", async () => { + const runtime = createExtensionRuntime(); + const eventBus = createEventBus(); + let capturedApi: ExtensionAPI | undefined; + let eventCalls = 0; + let flagDuringLoad: boolean | string | undefined; + + await loadExtensionFromFactory( + (pi) => pi.registerProvider("working-provider", providerConfig), + process.cwd(), + eventBus, + runtime, + "", + ); + await expect( + loadExtensionFromFactory( + (pi) => { + capturedApi = pi; + pi.events.on("factory-failure", () => { + eventCalls++; + }); + pi.registerFlag("failed-flag", { type: "boolean", default: true }); + flagDuringLoad = pi.getFlag("failed-flag"); + pi.unregisterProvider("working-provider"); + pi.registerProvider("failed-provider", providerConfig); + throw new Error("factory failed"); + }, + process.cwd(), + eventBus, + runtime, + "", + ), + ).rejects.toThrow("factory failed"); + + eventBus.emit("factory-failure", undefined); + expect(flagDuringLoad).toBe(true); + expect(runtime.flagValues.has("failed-flag")).toBe(false); + expect(runtime.pendingProviderRegistrations.map(({ name }) => name)).toEqual(["working-provider"]); + expect(eventCalls).toBe(0); + expect(capturedApi).toBeDefined(); + expect(() => capturedApi?.registerFlag("late-flag", { type: "boolean", default: true })).toThrow( + 'Extension "" failed to load and its API is no longer active.', + ); + }); + + it("does not discard a concurrently loaded factory's provider", async () => { + const runtime = createExtensionRuntime(); + const eventBus = createEventBus(); + let releaseFailure!: () => void; + const waitBeforeFailure = new Promise((resolve) => { + releaseFailure = resolve; + }); + const failingLoad = loadExtensionFromFactory( + async (pi) => { + pi.registerProvider("failed-provider", providerConfig); + await waitBeforeFailure; + throw new Error("factory failed"); + }, + process.cwd(), + eventBus, + runtime, + "", + ); + + await loadExtensionFromFactory( + (pi) => pi.registerProvider("working-provider", providerConfig), + process.cwd(), + eventBus, + runtime, + "", + ); + releaseFailure(); + + await expect(failingLoad).rejects.toThrow("factory failed"); + expect(runtime.pendingProviderRegistrations.map(({ name }) => name)).toEqual(["working-provider"]); + }); +}); diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts index 6f311e4949f..62fe5f025fb 100644 --- a/packages/coding-agent/test/syntax-highlight.test.ts +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -1,9 +1,46 @@ import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts"; -import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts"; +import { + highlight, + loadAllHighlightLanguages, + renderHighlightedHtml, + supportsLanguage, +} from "../src/utils/syntax-highlight.ts"; + +const eagerLanguages = [ + "python", + "java", + "go", + "javascript", + "cpp", + "typescript", + "php", + "ruby", + "c", + "csharp", + "nix", + "bash", + "rust", + "scala", + "kotlin", + "swift", + "dart", + "groovy", + "perl", + "lua", +]; +const eagerLanguagesLoadedAtStartup = eagerLanguages.every(supportsLanguage); +const uncommonLanguageLoadedAtStartup = supportsLanguage("ada"); describe("syntax highlight renderer", () => { + it("loads the twenty most common languages at startup and defers the rest", async () => { + expect(eagerLanguagesLoadedAtStartup).toBe(true); + expect(uncommonLanguageLoadedAtStartup).toBe(false); + await loadAllHighlightLanguages(); + expect(supportsLanguage("ada")).toBe(true); + }); + it("renders highlighted spans with the provided theme", () => { const rendered = renderHighlightedHtml('const value', { keyword: (text) => `[keyword:${text}]`, diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 6e38fcb0f22..41d83207bd0 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -46,6 +46,20 @@ describe("buildSystemPrompt", () => { expect(prompt).toContain("- write:"); }); + test.each([ + [["powershell"], "Use PowerShell for file operations"], + [["bash", "powershell"], "Use bash or PowerShell for file operations"], + ] as const)("uses shell-specific guidance for %j", (selectedTools, expected) => { + const prompt = buildSystemPrompt({ + selectedTools: [...selectedTools], + contextFiles: [], + skills: [], + cwd: process.cwd(), + }); + + expect(prompt).toContain(expected); + }); + test("instructs models to resolve pi docs and examples under absolute base paths", () => { const prompt = buildSystemPrompt({ contextFiles: [], diff --git a/packages/coding-agent/test/test-theme-colors.ts b/packages/coding-agent/test/test-theme-colors.ts index 70da7b18921..4e7181f3558 100644 --- a/packages/coding-agent/test/test-theme-colors.ts +++ b/packages/coding-agent/test/test-theme-colors.ts @@ -222,6 +222,9 @@ function cmdTheme(themeName: string): void { console.log("\n--- Backgrounds ---"); console.log("userMessageBg:", theme.bg("userMessageBg", " Sample ")); + const searchMatch = theme.bg("searchMatchBg", theme.fg("searchMatchText", " Sample ")); + console.log("searchMatch:", theme.underline(searchMatch)); + console.log("searchCurrentMatch:", theme.bold(theme.inverse(searchMatch))); console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample ")); console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample ")); console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample ")); diff --git a/packages/coding-agent/test/theme-controller.test.ts b/packages/coding-agent/test/theme-controller.test.ts new file mode 100644 index 00000000000..a20009d579f --- /dev/null +++ b/packages/coding-agent/test/theme-controller.test.ts @@ -0,0 +1,125 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { initTheme, type TerminalTheme, theme } from "../src/modes/interactive/theme/theme.ts"; +import { InteractiveThemeController } from "../src/modes/interactive/theme/theme-controller.ts"; + +function createUi() { + const queryTerminalBackgroundColor = vi.fn(); + const queryTerminalColorScheme = vi.fn(); + const setTerminalColorSchemeNotifications = vi.fn(); + let terminalColorSchemeListener: ((terminalTheme: TerminalTheme) => void) | undefined; + const ui = { + invalidate: vi.fn(), + requestRender: vi.fn(), + setTerminalColorSchemeNotifications, + onTerminalColorSchemeChange: vi.fn((listener: (terminalTheme: TerminalTheme) => void) => { + terminalColorSchemeListener = listener; + return vi.fn(); + }), + queryTerminalBackgroundColor, + queryTerminalColorScheme, + } as unknown as TUI; + return { + ui, + queryTerminalBackgroundColor, + queryTerminalColorScheme, + setTerminalColorSchemeNotifications, + emitTerminalColorScheme: (terminalTheme: TerminalTheme) => terminalColorSchemeListener?.(terminalTheme), + }; +} + +function createController(ui: TUI, getSettingsManager: () => SettingsManager, initialThemeSetting?: string) { + return new InteractiveThemeController(ui, { + getSettingsManager, + showError: vi.fn(), + onChanged: vi.fn(), + initialThemeSetting, + }); +} + +afterEach(() => { + initTheme("dark"); + vi.unstubAllEnvs(); +}); + +describe("InteractiveThemeController", () => { + it("uses the initial theme without persisting it", async () => { + const { ui, queryTerminalBackgroundColor } = createUi(); + const manager = SettingsManager.inMemory({ theme: "dark" }); + const setTheme = vi.spyOn(manager, "setTheme"); + const flush = vi.spyOn(manager, "flush"); + const controller = createController(ui, () => manager, "light"); + + expect(theme.name).toBe("light"); + expect(controller.getThemeSelection()).toBe("light"); + await controller.applyFromSettings(); + + expect(queryTerminalBackgroundColor).not.toHaveBeenCalled(); + expect(setTheme).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); + }); + + it("resolves a theme pair and follows terminal appearance changes", async () => { + vi.stubEnv("COLORFGBG", "15;0"); + const { ui, queryTerminalColorScheme, setTerminalColorSchemeNotifications, emitTerminalColorScheme } = createUi(); + queryTerminalColorScheme.mockResolvedValue("light"); + const manager = SettingsManager.inMemory({ theme: "dark/light" }); + const controller = createController(ui, () => manager, "light/dark"); + + expect(theme.name).toBe("dark"); + await controller.applyFromSettings(); + expect(theme.name).toBe("light"); + expect(setTerminalColorSchemeNotifications).toHaveBeenCalledWith(true); + + emitTerminalColorScheme("dark"); + expect(theme.name).toBe("dark"); + }); + + it("detects the current terminal appearance when selecting a theme pair", async () => { + vi.stubEnv("COLORFGBG", ""); + const { ui, queryTerminalColorScheme } = createUi(); + queryTerminalColorScheme.mockResolvedValue("light"); + const manager = SettingsManager.inMemory({ theme: "dark" }); + const controller = createController(ui, () => manager); + + expect(theme.name).toBe("dark"); + await controller.setThemeSetting("light/dark"); + expect(theme.name).toBe("light"); + expect(queryTerminalColorScheme).toHaveBeenCalledOnce(); + }); + + it("lets an explicit selection replace the initial theme", async () => { + const { ui } = createUi(); + const firstManager = SettingsManager.inMemory({ theme: "dark" }); + const secondManager = SettingsManager.inMemory({ theme: "light" }); + let manager = firstManager; + const controller = createController(ui, () => manager, "light"); + await controller.applyFromSettings(); + + expect(controller.setThemeName("dark")).toEqual({ success: true }); + manager = secondManager; + await controller.applyFromSettings(); + + expect(controller.getThemeSelection()).toBe("dark"); + expect(theme.name).toBe("dark"); + }); + + it("reloads theme settings when no initial theme was supplied", async () => { + const { ui } = createUi(); + const firstManager = SettingsManager.inMemory({ theme: "dark" }); + const secondManager = SettingsManager.inMemory({ theme: "light" }); + let manager = firstManager; + const controller = createController(ui, () => manager); + await controller.applyFromSettings(); + + firstManager.applyOverrides({ theme: "light" }); + await controller.applyFromSettings(); + expect(theme.name).toBe("light"); + + secondManager.applyOverrides({ theme: "dark" }); + manager = secondManager; + await controller.applyFromSettings(); + expect(theme.name).toBe("dark"); + }); +}); diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 6bdc597bd7e..c323ec6f858 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { detectTerminalBackgroundFromEnv, detectTerminalBackgroundTheme, + detectTerminalThemeForAuto, getThemeByName, getThemeForRgbColor, parseAutoThemeSetting, @@ -99,6 +100,46 @@ describe("detectTerminalBackgroundTheme", () => { }); }); +describe("detectTerminalThemeForAuto", () => { + it("starts both queries and returns the preferred color-scheme result without waiting", async () => { + let resolveColorScheme!: (theme: "dark" | "light" | undefined) => void; + let backgroundQueryStarted = false; + const detection = detectTerminalThemeForAuto({ + timeoutMs: 100, + ui: { + queryTerminalColorScheme: () => + new Promise((resolve) => { + resolveColorScheme = resolve; + }), + queryTerminalBackgroundColor: () => { + backgroundQueryStarted = true; + return new Promise(() => {}); + }, + }, + }); + + expect(backgroundQueryStarted).toBe(true); + resolveColorScheme("dark"); + await expect(detection).resolves.toBe("dark"); + }); + + it("uses the background result when the color-scheme query fails", async () => { + await expect( + detectTerminalThemeForAuto({ + timeoutMs: 100, + ui: { + async queryTerminalColorScheme(): Promise { + throw new Error("color-scheme query failed"); + }, + async queryTerminalBackgroundColor(): Promise { + return { r: 250, g: 250, b: 250 }; + }, + }, + }), + ).resolves.toBe("light"); + }); +}); + describe("theme color mode", () => { it("uses terminal capabilities", () => { setCapabilities({ images: null, trueColor: false, hyperlinks: false }); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 49b3754ae70..14d3b3f04e1 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -344,7 +344,7 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).toContain("arg:bar"); }); - test("falls back when custom renderers are absent", () => { + test("collapses fallback results until expanded", () => { const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), }; @@ -358,10 +358,20 @@ describe("ToolExecutionComponent parity", () => { createFakeTui(), process.cwd(), ); - component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); - const rendered = stripAnsi(component.render(120).join("\n")); - expect(rendered).toContain("custom_tool"); - expect(rendered).toContain("done"); + const output = Array.from({ length: 15 }, (_, index) => `line-${index + 1}`).join("\n"); + component.updateResult({ content: [{ type: "text", text: output }], details: {}, isError: false }, false); + + const collapsed = stripAnsi(component.render(120).join("\n")); + expect(collapsed).toContain("custom_tool"); + expect(collapsed).toContain("line-10"); + expect(collapsed).not.toContain("line-11"); + expect(collapsed).toContain("5 more lines"); + expect(collapsed).toContain("to expand"); + + component.setExpanded(true); + const expanded = stripAnsi(component.render(120).join("\n")); + expect(expanded).toContain("line-15"); + expect(expanded).not.toContain("more lines"); }); test("trims trailing blank display lines from write previews", () => { diff --git a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts new file mode 100644 index 00000000000..ed39fa508b8 --- /dev/null +++ b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; +import { bashToolSystemPromptContribution, createBashToolDefinition } from "../src/core/tools/bash.ts"; +import { createEditToolDefinition, editToolSystemPromptContribution } from "../src/core/tools/edit.ts"; +import { createFindToolDefinition, findToolSystemPromptContribution } from "../src/core/tools/find.ts"; +import { createGrepToolDefinition, grepToolSystemPromptContribution } from "../src/core/tools/grep.ts"; +import { createLsToolDefinition, lsToolSystemPromptContribution } from "../src/core/tools/ls.ts"; +import { + createPowerShellToolDefinition, + powershellToolSystemPromptContribution, +} from "../src/core/tools/powershell.ts"; +import { createReadToolDefinition, readToolSystemPromptContribution } from "../src/core/tools/read.ts"; +import { createWriteToolDefinition, writeToolSystemPromptContribution } from "../src/core/tools/write.ts"; + +const cases = [ + ["read", readToolSystemPromptContribution, createReadToolDefinition], + ["bash", bashToolSystemPromptContribution, createBashToolDefinition], + ["powershell", powershellToolSystemPromptContribution, createPowerShellToolDefinition], + ["edit", editToolSystemPromptContribution, createEditToolDefinition], + ["write", writeToolSystemPromptContribution, createWriteToolDefinition], + ["grep", grepToolSystemPromptContribution, createGrepToolDefinition], + ["find", findToolSystemPromptContribution, createFindToolDefinition], + ["ls", lsToolSystemPromptContribution, createLsToolDefinition], +] as const; + +describe("built-in tool system prompt contributions", () => { + test.each(cases)( + "keeps the %s tool definition aligned with its contribution", + (_name, contribution, createDefinition) => { + const definition = createDefinition("/workspace"); + + expect(definition.promptSnippet).toBe(contribution.snippet); + expect(definition.promptGuidelines ?? []).toEqual(contribution.guidelines); + }, + ); + + test.each([ + ["bash", createBashToolDefinition], + ["powershell", createPowerShellToolDefinition], + ] as const)("keeps %s session-environment guidance conditional", (_name, createDefinition) => { + const definition = createDefinition("/workspace", { exposeSessionEnvironment: false }); + + expect(definition.promptGuidelines).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/tools-manager.test.ts b/packages/coding-agent/test/tools-manager.test.ts new file mode 100644 index 00000000000..fb53baad4fd --- /dev/null +++ b/packages/coding-agent/test/tools-manager.test.ts @@ -0,0 +1,47 @@ +import type * as ChildProcess from "node:child_process"; +import type * as Fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureTool, type ToolStatus } from "../src/utils/tools-manager.ts"; + +const originalOffline = process.env.PI_OFFLINE; + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + }; +}); + +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: vi.fn(() => ({ error: new Error("not found") })), + }; +}); + +afterEach(() => { + if (originalOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = originalOffline; +}); + +describe("ensureTool", () => { + it("reports status through a callback without writing to the console", async () => { + process.env.PI_OFFLINE = "1"; + const statuses: ToolStatus[] = []; + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + + const result = await ensureTool("fd", (status) => statuses.push(status)); + + expect(result).toBeUndefined(); + expect(statuses).toEqual([ + { + type: "warning", + message: "fd not found. Offline mode enabled, skipping download.", + }, + ]); + expect(consoleLog).not.toHaveBeenCalled(); + consoleLog.mockRestore(); + }); +}); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 63b7f626064..e9c395aa644 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -486,9 +486,8 @@ describe("Coding Agent Tools", () => { }); it("should respect timeout", async () => { - await expect(bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 })).rejects.toThrow( - /timed out/i, - ); + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`; + await expect(bashTool.execute("test-call-10", { command, timeout: 0.05 })).rejects.toThrow(/timed out/i); }); it("should include full output path for truncated timeout and abort errors", async () => { diff --git a/packages/evals/package.json b/packages/evals/package.json index 08585a7e5f7..b02e89a8eca 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-evals", - "version": "0.84.0", + "version": "0.84.3", "private": true, "type": "module", "scripts": { @@ -9,9 +9,9 @@ "test": "vitest run --config vitest.test.config.ts" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-coding-agent": "^0.84.0", - "@types/node": "24.12.4", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-coding-agent": "^0.84.3", + "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", "vitest-evals": "0.15.0", diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index b82e3a98d17..f0c58f21c72 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +## [0.84.2] - 2026-08-14 + +## [0.84.1] - 2026-08-07 + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 12447afef5e..5ac7bf7e726 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-protocol", - "version": "0.84.0", + "version": "0.84.3", "description": "Transport-neutral CBOR protocol for remote pi sessions", "type": "module", "main": "./dist/index.js", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index f4aad93afb2..ab89c9590b7 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +## [0.84.2] - 2026-08-14 + +## [0.84.1] - 2026-08-07 + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/server/package.json b/packages/server/package.json index aefd50f2da4..b079277bf38 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-server", - "version": "0.84.0", + "version": "0.84.3", "description": "experimental server package for pi", "type": "module", "main": "./dist/index.js", @@ -47,8 +47,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 99b5a948e8a..069828e590e 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -44,7 +44,7 @@ type _AiThinkingContentFieldsAccountedFor = Assert< >; type _AiImageContentFieldsAccountedFor = Assert>; type _AiToolCallFieldsAccountedFor = Assert< - ExactKeys + ExactKeys >; type _AiUsageFieldsAccountedFor = Assert< ExactKeys< @@ -94,6 +94,7 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "deferred" | "errorMessage" | "rawStopReason" + | "endTurn" | "timestamp" > >; diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index e18c7330ad7..c9368d12585 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +## [0.84.2] - 2026-08-14 + +## [0.84.1] - 2026-08-07 + +### Added + +- Added the composable, parameterized `sql` template tag for SQLite queries. + +### Fixed + +- Fixed SQLite branch queries to apply filters, cursors, and limits in SQL; bounded log reads; and added covering indexes for session, record, branch, and fact queries ([#7727](https://github.com/earendil-works/pi/pull/7727) by [@cristinaponcela](https://github.com/cristinaponcela)). + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/session-backends/sqlite-node/README.md b/packages/session-backends/sqlite-node/README.md index 40de9f804b4..e56f205f37f 100644 --- a/packages/session-backends/sqlite-node/README.md +++ b/packages/session-backends/sqlite-node/README.md @@ -8,8 +8,15 @@ migrations, materialized views, and optional FTS search. await using repository = new SqliteSessionRepository(options); const search = createSqliteSessionSearch(options); const session = await repository.create({ cwd }); -const hits = await search.search({ text: "needle" }); +await session.appendMessage(message); + +const hits = []; +for await (const hit of search.search("needle")) hits.push(hit); ``` -The repository lazily owns one shared database connection. Search is an independent, -query-only projection over the same canonical database. +The repository lazily owns one shared database connection. Search is an independent +service over the same canonical database: repositories do not expose `search()`. +The FTS table and triggers are created lazily on the first non-blank search; when +FTS is first created, search performs a one-time rebuild from canonical entries. +After that, SQLite triggers keep FTS in sync with canonical entry inserts, deletes, +and payload updates. diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index ab96ff0350c..6849b14e898 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.0", + "version": "0.84.3", "description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions", "type": "module", "main": "./dist/index.js", @@ -34,8 +34,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-agent-core": "^0.84.0" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-agent-core": "^0.84.3" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", diff --git a/packages/session-backends/sqlite-node/src/index.ts b/packages/session-backends/sqlite-node/src/index.ts index 98c7fdfa47e..14bd887c9f7 100644 --- a/packages/session-backends/sqlite-node/src/index.ts +++ b/packages/session-backends/sqlite-node/src/index.ts @@ -1,5 +1,6 @@ import type { SQLInputValue } from "node:sqlite"; import { DatabaseSync } from "node:sqlite"; +import { sql } from "./sqlite/sql.ts"; import type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, SqliteStatement } from "./sqlite/types.ts"; function isNamedParameters(value: unknown): value is Record { @@ -47,6 +48,15 @@ class NodeSqliteStatement implements SqliteStatement { : this.statement.all(...(params as SQLInputValue[])) ) as TRow[]; } + + iterate(...params: unknown[]): Iterable { + const [first, ...rest] = params; + return ( + isNamedParameters(first) + ? this.statement.iterate(first, ...(rest as SQLInputValue[])) + : this.statement.iterate(...(params as SQLInputValue[])) + ) as Iterable; + } } class NodeSqliteDatabase implements SqliteDatabase { @@ -65,17 +75,17 @@ class NodeSqliteDatabase implements SqliteDatabase { } transaction(fn: () => T): T { - this.db.exec("BEGIN IMMEDIATE"); + sql`BEGIN IMMEDIATE`.exec(this); try { const result = fn(); if (isAsyncResult(result)) { throw new TypeError("SQLite transaction callbacks must be synchronous"); } - this.db.exec("COMMIT"); + sql`COMMIT`.exec(this); return result; } catch (error) { try { - this.db.exec("ROLLBACK"); + sql`ROLLBACK`.exec(this); } catch { // Ignore rollback errors to rethrow original error. } diff --git a/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts b/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts index 0e5024cb8d3..eaef85f5877 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts @@ -1,5 +1,6 @@ import { SessionError } from "@earendil-works/pi-agent-core"; import { uuidv7 } from "@earendil-works/pi-ai"; +import { sql } from "./sql.ts"; import { copyBranchEntriesThroughSeq, deleteBranchEntries, @@ -7,7 +8,6 @@ import { insertBranchEntry, readBranchContainingEntry, } from "./storage/branch-entries.ts"; - import { deleteBranchTips, insertBranchTip, readBranchTipBranchId, updateBranchTip } from "./storage/branch-tips.ts"; import type { SqliteDatabase } from "./types.ts"; @@ -17,32 +17,28 @@ export function deleteBranchCache(db: SqliteDatabase, sessionId: string) { } export function rebuildBranchCache(db: SqliteDatabase, sessionId: string) { - const tips = db - .prepare( - `SELECT leaf.id - FROM entries AS leaf - WHERE leaf.session_id = ? - AND NOT EXISTS ( - SELECT 1 FROM entries AS child WHERE child.session_id = leaf.session_id AND child.parent_id = leaf.id - ) - ORDER BY leaf.seq`, - ) - .all<{ id: string }>(sessionId); + const tips = sql`SELECT leaf.id + FROM entries AS leaf + WHERE leaf.session_id = ${sessionId} + AND NOT EXISTS ( + SELECT 1 FROM entries AS child WHERE child.session_id = leaf.session_id AND child.parent_id = leaf.id + ) + ORDER BY leaf.seq`.all<{ id: string }>(db); deleteBranchCache(db, sessionId); for (const tip of tips) buildCachedBranch(db, sessionId, tip.id); } export function buildCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { - db.exec("SAVEPOINT build_branch_cache"); + sql`SAVEPOINT build_branch_cache`.exec(db); try { const branchId = uuidv7(); insertBranchEntriesForPath(db, sessionId, branchId, leafId); insertBranchTip(db, sessionId, leafId, branchId); - db.exec("RELEASE SAVEPOINT build_branch_cache"); + sql`RELEASE SAVEPOINT build_branch_cache`.exec(db); } catch (error) { try { - db.exec("ROLLBACK TO SAVEPOINT build_branch_cache"); - db.exec("RELEASE SAVEPOINT build_branch_cache"); + sql`ROLLBACK TO SAVEPOINT build_branch_cache`.exec(db); + sql`RELEASE SAVEPOINT build_branch_cache`.exec(db); } catch { // Preserve the original build failure. } diff --git a/packages/session-backends/sqlite-node/src/sqlite/index.ts b/packages/session-backends/sqlite-node/src/sqlite/index.ts index 738f086a1f9..73f6a62dd4a 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/index.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/index.ts @@ -5,6 +5,7 @@ export { type SqliteWriterLeaseOptions, } from "./repo.ts"; export * from "./search-backend.ts"; +export * from "./sql.ts"; export type { SqliteDatabase, SqliteDatabaseFactory, diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations.ts b/packages/session-backends/sqlite-node/src/sqlite/migrations.ts index a0debe3f061..e171f4a0196 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/migrations.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; +import { sql } from "./sql.ts"; import type { SqliteDatabase } from "./types.ts"; export interface SqliteMigration { @@ -23,28 +24,25 @@ export async function loadMigrations(): Promise { } function ensureMigrationsTable(db: SqliteDatabase): void { - db.exec(` + sql` CREATE TABLE IF NOT EXISTS migrations ( id TEXT PRIMARY KEY, applied_at TEXT NOT NULL ); -`); +`.exec(db); } export async function applyMigrations(db: SqliteDatabase): Promise { ensureMigrationsTable(db); const migrations = await loadMigrations(); - const appliedRows = db.prepare("SELECT id FROM migrations ORDER BY applied_at, id").all<{ id: string }>(); + const appliedRows = sql`SELECT id FROM migrations ORDER BY applied_at, id`.all<{ id: string }>(db); const applied = new Set(appliedRows.map((row) => row.id)); for (const migration of migrations) { if (applied.has(migration.id)) continue; db.transaction(() => { db.exec(migration.sql); - db.prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)").run( - migration.id, - new Date().toISOString(), - ); + sql`INSERT INTO migrations (id, applied_at) VALUES (${migration.id}, ${new Date().toISOString()})`.run(db); }); applied.add(migration.id); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql index e400a4367c7..fc9e277e26a 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql +++ b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql @@ -1,14 +1,13 @@ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, + created_at INTEGER NOT NULL, cwd TEXT NOT NULL, parent_session_id TEXT NULL, metadata TEXT NULL ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd); -CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); +CREATE INDEX IF NOT EXISTS idx_sessions_cwd_created_at ON sessions(cwd, created_at DESC); CREATE TABLE IF NOT EXISTS entries ( session_id TEXT NOT NULL, @@ -16,13 +15,12 @@ CREATE TABLE IF NOT EXISTS entries ( id TEXT NOT NULL, parent_id TEXT NULL, type TEXT NOT NULL, - timestamp TEXT NOT NULL, + timestamp INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (session_id, id), UNIQUE (session_id, seq) ); -CREATE INDEX IF NOT EXISTS idx_entries_session_seq ON entries(session_id, seq); CREATE INDEX IF NOT EXISTS idx_entries_session_parent ON entries(session_id, parent_id); CREATE INDEX IF NOT EXISTS idx_entries_session_type_seq ON entries(session_id, type, seq); @@ -53,7 +51,7 @@ CREATE TABLE IF NOT EXISTS branch_entries ( ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_seq ON branch_entries(session_id, branch_id, entry_seq); -CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id); +CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id, branch_id, entry_seq); CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_type_seq ON branch_entries(session_id, branch_id, entry_type, entry_seq); CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_custom_seq ON branch_entries(session_id, branch_id, custom_type, entry_seq); @@ -73,13 +71,15 @@ CREATE TABLE IF NOT EXISTS records ( run_id TEXT NULL, type TEXT NOT NULL, op_kind TEXT NULL, - timestamp TEXT NOT NULL, + timestamp INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (session_id, id), UNIQUE (session_id, seq) ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_records_session_seq ON records(session_id, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_lane_seq ON records(session_id, lane, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_type_seq ON records(session_id, type, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_type_op_kind_seq ON records(session_id, type, op_kind, seq); CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_seq ON records(session_id, lane, type, seq); CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_op_kind_seq ON records(session_id, lane, type, op_kind, seq); CREATE INDEX IF NOT EXISTS idx_records_session_run_id_seq ON records(session_id, run_id, seq); @@ -92,7 +92,6 @@ CREATE TABLE IF NOT EXISTS lane_moves ( PRIMARY KEY (session_id, seq) ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_lane_moves_session_lane_seq ON lane_moves(session_id, lane, seq); CREATE TABLE IF NOT EXISTS facts ( session_id TEXT NOT NULL, @@ -107,8 +106,8 @@ CREATE INDEX IF NOT EXISTS idx_facts_session_kind_key_seq ON facts(session_id, k CREATE TABLE IF NOT EXISTS branch_tips ( session_id TEXT NOT NULL, - tip_id TEXT NOT NULL, branch_id TEXT NOT NULL, + tip_id TEXT NOT NULL, PRIMARY KEY (session_id, tip_id), UNIQUE (session_id, branch_id) ) WITHOUT ROWID; diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts index d75c7a5abe1..81f86bea964 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/repo.ts @@ -20,6 +20,7 @@ import { import { uuidv7 } from "@earendil-works/pi-ai"; import { appendEntryToBranchCache, buildCachedBranch, deleteBranchCache, rebuildBranchCache } from "./branch-cache.ts"; import { applyMigrations } from "./migrations.ts"; +import { sql } from "./sql.ts"; import { type CachedBranchEntryRow, queryCachedBranchRows, readCachedBranch } from "./storage/branch-entries.ts"; import { readBranchTipIds } from "./storage/branch-tips.ts"; import { @@ -169,17 +170,9 @@ function getParentPath(path: string): string { } function configureSqliteDatabase(db: SqliteDatabase): void { - db.exec("PRAGMA journal_mode=WAL"); - db.exec("PRAGMA synchronous=FULL"); - db.exec("PRAGMA busy_timeout=5000"); -} - -function timestampToText(timestamp: number): string { - return new Date(timestamp).toISOString(); -} - -function timestampFromText(timestamp: string): number { - return Date.parse(timestamp); + sql`PRAGMA journal_mode=WAL`.exec(db); + sql`PRAGMA synchronous=FULL`.exec(db); + sql`PRAGMA busy_timeout=5000`.exec(db); } function entryRowFromCached(row: CachedBranchEntryRow): EntryRow { @@ -197,9 +190,7 @@ function readObjectPayload(row: EntryRow): Record { function decodeEntry(row: EntryRow): Entry { try { const payload = readObjectPayload(row); - const timestamp = timestampFromText(row.timestamp); - if (!Number.isFinite(timestamp)) throw new Error(`Invalid timestamp ${row.timestamp}`); - const base = { id: row.id, seq: row.seq, parentId: row.parent_id, timestamp }; + const base = { id: row.id, seq: row.seq, parentId: row.parent_id, timestamp: row.timestamp }; switch (row.type) { case "message": if (typeof payload.message !== "object" || payload.message === null) throw new Error("Missing message"); @@ -282,14 +273,12 @@ function recordOpKind(record: NewRecord): string | undefined { return record.type === "operation_started" ? record.intent.kind : undefined; } -function decodeRecord(row: { seq: number; timestamp: string; payload: string }): LaneRecord { +function decodeRecord(row: { seq: number; timestamp: number; payload: string }): LaneRecord { try { - const timestamp = timestampFromText(row.timestamp); - if (!Number.isFinite(timestamp)) throw new Error(`Invalid timestamp ${row.timestamp}`); return { ...(JSON.parse(row.payload) as object), seq: row.seq, - timestamp, + timestamp: row.timestamp, } as LaneRecord; } catch (error) { throw new SessionError( @@ -300,10 +289,15 @@ function decodeRecord(row: { seq: number; timestamp: string; payload: string }): } } -function validateCachedBranchRows(rows: readonly CachedBranchEntryRow[], query: BranchBounds): void { - if (rows.length === 0) return; +function validateCachedBranchRows(rows: readonly CachedBranchEntryRow[], query: BranchBounds & EntryQuery): void { + if (rows.length === 0 || query.type !== undefined || query.customType !== undefined) return; const path = [...rows].sort((left, right) => left.entry_seq - right.entry_seq); - if (query.stopAtId === undefined && query.stopAtType === undefined && path[0]?.parent_id !== null) { + const shouldIncludeRoot = + query.stopAtId === undefined && + query.stopAtType === undefined && + query.cursor === undefined && + (query.order === "oldestFirst" || query.limit === undefined); + if (shouldIncludeRoot && path[0]?.parent_id !== null) { throw new SessionError("invalid_entry", `Entry ${path[0]?.parent_id} not found`); } for (let index = 1; index < path.length; index++) { @@ -470,7 +464,7 @@ class SqliteSessionStorage implements SessionStorage { id: committed.id, parentId: committed.parentId, type: committed.type, - timestamp: timestampToText(committed.timestamp), + timestamp: committed.timestamp, payload: JSON.stringify(entryPayload(committed)), }); setLaneLeaf(this.db, this.metadata.id, lane, committed.id); @@ -508,7 +502,7 @@ class SqliteSessionStorage implements SessionStorage { runId: recordRunId(record), type: record.type, opKind: recordOpKind(record), - timestamp: timestampToText(committed.timestamp), + timestamp: committed.timestamp, payload: JSON.stringify(record), }); if (record.type === "operation_finished") { @@ -526,7 +520,14 @@ class SqliteSessionStorage implements SessionStorage { } async findEntries(query: EntryQuery = {}): Promise { - const rows = readEntryRows(this.db, this.metadata.id, { order: query.order }); + const sqlType = query.type ?? (query.customType === undefined ? undefined : "custom"); + const sqlLimit = query.customType === undefined ? query.limit : undefined; + const rows = readEntryRows(this.db, this.metadata.id, { + cursor: query.cursor, + limit: sqlLimit, + order: query.order, + type: sqlType, + }); const entries = rows.map(decodeEntry).filter((entry) => matchesEntryQuery(entry, query)); return query.limit === undefined ? entries : entries.slice(0, query.limit); } @@ -566,33 +567,47 @@ class SqliteSessionStorage implements SessionStorage { async getLog(options: LogOptions = {}): Promise { const afterSeq = options.afterSeq ?? 0; - const entryRows = readEntryRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst" }); - const recordRows = readRecordRows(this.db, this.metadata.id, { afterSeq }); - const laneRows = readLaneMoveRows(this.db, this.metadata.id, { afterSeq }); - const factRows = readFactRows(this.db, this.metadata.id, { afterSeq }); - - const log: LogItem[] = [ - ...entryRows.map((row) => ({ kind: "entry" as const, seq: row.seq, entry: decodeEntry(row) })), - ...recordRows.map((row) => ({ kind: "record" as const, seq: row.seq, record: decodeRecord(row) })), - ...laneRows.map((row) => ({ kind: "lane" as const, seq: row.seq, lane: row.lane, leafId: row.leaf_id })), - ...factRows.map((row) => { - if (row.kind === "name") + const limit = options.limit; + const entryRows = readEntryRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit }); + const recordRows = readRecordRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit }); + const laneRows = readLaneMoveRows(this.db, this.metadata.id, { afterSeq, limit }); + const factRows = readFactRows(this.db, this.metadata.id, { afterSeq, limit }); + + const logRows: { seq: number; decode: () => LogItem }[] = [ + ...entryRows.map((row) => ({ + seq: row.seq, + decode: () => ({ kind: "entry" as const, seq: row.seq, entry: decodeEntry(row) }), + })), + ...recordRows.map((row) => ({ + seq: row.seq, + decode: () => ({ kind: "record" as const, seq: row.seq, record: decodeRecord(row) }), + })), + ...laneRows.map((row) => ({ + seq: row.seq, + decode: () => ({ kind: "lane" as const, seq: row.seq, lane: row.lane, leafId: row.leaf_id }), + })), + ...factRows.map((row) => ({ + seq: row.seq, + decode: () => { + if (row.kind === "name") + return { + kind: "fact" as const, + seq: row.seq, + fact: "name" as const, + name: row.value === null ? undefined : (JSON.parse(row.value) as string), + }; return { kind: "fact" as const, seq: row.seq, - fact: "name" as const, - name: JSON.parse(row.value ?? "null") as string, + fact: "label" as const, + targetId: row.key ?? "", + label: row.value === null ? undefined : (JSON.parse(row.value) as string), }; - return { - kind: "fact" as const, - seq: row.seq, - fact: "label" as const, - targetId: row.key ?? "", - label: row.value === null ? undefined : (JSON.parse(row.value) as string), - }; - }), + }, + })), ].sort((left, right) => left.seq - right.seq); - return options.limit === undefined ? log : log.slice(0, options.limit); + const selectedRows = options.limit === undefined ? logRows : logRows.slice(0, options.limit); + return selectedRows.map((row) => row.decode()); } async getName(): Promise { @@ -600,10 +615,10 @@ class SqliteSessionStorage implements SessionStorage { return row?.value === undefined || row.value === null ? undefined : (JSON.parse(row.value) as string); } - async setName(name: string): Promise { + async setName(name: string | undefined): Promise { return this.enqueueWrite(() => { const seq = getNextSequence(this.db, this.metadata.id); - appendFact(this.db, this.metadata.id, seq, "name", null, JSON.stringify(name)); + appendFact(this.db, this.metadata.id, seq, "name", null, name === undefined ? null : JSON.stringify(name)); advanceSequence(this.db, this.metadata.id, seq); }); } @@ -712,7 +727,7 @@ export class SqliteSessionRepository const lease = db.transaction(() => { insertSessionRow(db, { id, - createdAt: timestampToText(createdAt), + createdAt, cwd: options.cwd, parentSessionId: options.parentSessionId, metadata: options.metadata, @@ -843,7 +858,7 @@ export class SqliteSessionRepository lease = db.transaction(() => { insertSessionRow(db, { id, - createdAt: timestampToText(createdAt), + createdAt, cwd: options.cwd, parentSessionId: options.parentSessionId ?? source.id, metadata, diff --git a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts index 11f309f196a..6e28bfefccd 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts @@ -1,6 +1,13 @@ -import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "@earendil-works/pi-agent-core"; -import { getFileSystemResultOrThrow } from "@earendil-works/pi-agent-core"; +import type { + FileError, + Result, + SessionSearch, + SessionSearchHit, + SessionSearchOptions, +} from "@earendil-works/pi-agent-core"; +import { SessionError } from "@earendil-works/pi-agent-core"; import { applyMigrations } from "./migrations.ts"; +import { sql } from "./sql.ts"; import { decodeSessionMetadata, type SessionRow } from "./storage/sessions.ts"; import type { SqliteDatabase, @@ -9,6 +16,14 @@ import type { SqliteSessionRepositoryEnv, } from "./types.ts"; +function getFileSystemResultOrThrow(result: Result, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + function getParentPath(path: string): string { const normalized = path.replace(/[\\/]+$/, ""); const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); @@ -17,10 +32,18 @@ function getParentPath(path: string): string { return normalized.slice(0, lastSlash); } +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + throw error; +} + function configureSqliteDatabase(db: SqliteDatabase): void { - db.exec("PRAGMA journal_mode=WAL"); - db.exec("PRAGMA synchronous=FULL"); - db.exec("PRAGMA busy_timeout=5000"); + sql`PRAGMA journal_mode=WAL`.exec(db); + sql`PRAGMA synchronous=FULL`.exec(db); + sql`PRAGMA busy_timeout=5000`.exec(db); } export interface SqliteSessionSearchOptions { @@ -30,14 +53,20 @@ export interface SqliteSessionSearchOptions { } function tableExists(db: SqliteDatabase, name: string): boolean { - return !!db - .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1") - .get<{ found: number }>(name); + return !!sql`SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ${name} LIMIT 1`.get<{ + found: number; + }>(db); +} + +function rebuildSearchIndex(db: SqliteDatabase): void { + sql`INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')`.run(db); } function ensureSearchSchema(db: SqliteDatabase): void { const ftsExists = tableExists(db, "session_search_fts"); - db.exec(` + const entriesExist = tableExists(db, "entries"); + db.transaction(() => { + sql` CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( payload, content = 'entries', @@ -54,12 +83,19 @@ CREATE TRIGGER IF NOT EXISTS session_search_fts_au AFTER UPDATE OF payload ON en INSERT INTO session_search_fts(session_search_fts, rowid, payload) VALUES('delete', old.rowid, old.payload); INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); END; -`); - if (!ftsExists) db.exec("INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')"); +`.exec(db); + if (!ftsExists && entriesExist) rebuildSearchIndex(db); + }); +} + +export interface SqliteSessionSearchHit extends SessionSearchHit { + readonly metadata: SqliteSessionMetadata; + readonly timestamp: number; + readonly score: number; } /** SQLite FTS search over a co-located canonical session database. */ -class SqliteSessionSearch implements SessionSearch { +class SqliteSessionSearch implements SessionSearch { private readonly options: SqliteSessionSearchOptions; private databasePath: string | undefined; @@ -96,12 +132,20 @@ class SqliteSessionSearch implements SessionSearch { } } - async search(options: SessionSearchOptions): Promise[]> { - const text = options.text.trim(); - if (!text) return []; + async *search(text: string, options: SessionSearchOptions = {}): AsyncIterable { + const queryText = text.trim(); + if (!queryText || (options.limit !== undefined && options.limit <= 0)) return; + if (options.entryTypes?.length === 0) return; + throwIfAborted(options.signal); const db = await this.openDatabase(); try { - const query = `"${text.replaceAll('"', '""')}"`; + const query = `"${queryText.replaceAll('"', '""')}"`; + const predicates = ["session_search_fts MATCH ?"]; + const params: unknown[] = [query]; + if (options.entryTypes !== undefined) { + predicates.push(`se.type IN (${options.entryTypes.map(() => "?").join(", ")})`); + params.push(...options.entryTypes); + } const rows = db .prepare( `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, @@ -120,27 +164,31 @@ class SqliteSessionSearch implements SessionSearch { FROM facts AS f WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL ) - WHERE session_search_fts MATCH ? AND (? IS NULL OR s.cwd = ?) - ORDER BY score`, + WHERE ${predicates.join(" AND ")} + ORDER BY score + LIMIT ?`, ) - .all( - query, - options.cwd ?? null, - options.cwd ?? null, + .iterate( + ...params, + options.limit ?? -1, ); const path = await this.getDatabasePath(); - return rows.map((row) => ({ - metadata: decodeSessionMetadata(row, path), - entryId: row.entry_id, - timestamp: row.timestamp, - score: row.score, - })); + for (const row of rows) { + throwIfAborted(options.signal); + yield { + sessionId: row.id, + metadata: decodeSessionMetadata(row, path), + entryId: row.entry_id, + timestamp: row.timestamp, + score: row.score, + }; + } } finally { db.close(); } } } -export function createSqliteSessionSearch(options: SqliteSessionSearchOptions): SessionSearch { +export function createSqliteSessionSearch(options: SqliteSessionSearchOptions): SessionSearch { return new SqliteSessionSearch(options); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/sql.ts b/packages/session-backends/sqlite-node/src/sqlite/sql.ts new file mode 100644 index 00000000000..82c1d44f24a --- /dev/null +++ b/packages/session-backends/sqlite-node/src/sqlite/sql.ts @@ -0,0 +1,66 @@ +import type { SqliteDatabase, SqliteRunResult } from "./types.ts"; + +type SqlTemplateValue = unknown | SqlQuery; + +/** A parameterized SQLite query produced by {@link sql}. */ +export class SqlQuery { + readonly queryText: string; + readonly params: readonly unknown[]; + + constructor(queryText: string, params: readonly unknown[] = []) { + this.queryText = queryText; + this.params = params; + } + + exec(db: SqliteDatabase): void { + if (this.params.length > 0) throw new TypeError("SQLite exec queries cannot have parameters"); + db.exec(this.queryText); + } + + run(db: SqliteDatabase): SqliteRunResult { + return db.prepare(this.queryText).run(...this.params); + } + + get(db: SqliteDatabase): TRow | undefined { + return db.prepare(this.queryText).get(...this.params); + } + + all(db: SqliteDatabase): TRow[] { + return db.prepare(this.queryText).all(...this.params); + } + + iterate(db: SqliteDatabase): Iterable { + return db.prepare(this.queryText).iterate(...this.params); + } +} + +/** Builds a parameterized query. Nested queries are inlined; other interpolations become `?` parameters. */ +export function sql(strings: TemplateStringsArray, ...values: SqlTemplateValue[]): SqlQuery { + let queryText = strings[0] ?? ""; + const params: unknown[] = []; + for (let index = 0; index < values.length; index++) { + const value = values[index]; + if (value instanceof SqlQuery) { + queryText += value.queryText; + params.push(...value.params); + } else { + queryText += "?"; + params.push(value); + } + queryText += strings[index + 1] ?? ""; + } + return new SqlQuery(queryText, params); +} + +/** Joins trusted query fragments while preserving their parameter order. */ +export function joinSqlFragments(fragments: readonly SqlQuery[], separator: string): SqlQuery { + let queryText = ""; + const params: unknown[] = []; + for (let index = 0; index < fragments.length; index++) { + if (index > 0) queryText += separator; + const fragment = fragments[index]!; + queryText += fragment.queryText; + params.push(...fragment.params); + } + return new SqlQuery(queryText, params); +} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts index 9b604976e67..276ca89477a 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts @@ -1,4 +1,5 @@ -import type { Entry } from "@earendil-works/pi-agent-core"; +import { type Entry, SessionError } from "@earendil-works/pi-agent-core"; +import { joinSqlFragments, sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; /** Derived root-to-tip branch cache membership. Canonical parent links remain in entries. */ @@ -13,22 +14,34 @@ export interface CachedBranchEntryRow { entry_seq: number; parent_id: string | null; type: Entry["type"]; - timestamp: string; + timestamp: number; payload: string; } export interface CachedBranchQuery { + type?: Entry["type"]; + customType?: string; stopAtType?: Entry["type"]; stopAtId?: string; + cursor?: { afterSeq: number }; order?: "newestFirst" | "oldestFirst"; + limit?: number; +} + +interface BranchPathEntryRow { + id: string; + seq: number; + parent_id: string | null; + type: Entry["type"]; + payload: string; } export function readCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { - const membership = db - .prepare( - "SELECT branch_id, entry_seq FROM branch_entries WHERE session_id = ? AND entry_id = ? ORDER BY branch_id LIMIT 1", - ) - .get<{ branch_id: string; entry_seq: number }>(sessionId, leafId); + const membership = sql`SELECT branch_id, entry_seq + FROM branch_entries + WHERE session_id = ${sessionId} AND entry_id = ${leafId} + ORDER BY branch_id + LIMIT 1`.get<{ branch_id: string; entry_seq: number }>(db); if (!membership) return undefined; return { branchId: membership.branch_id, leafSeq: membership.entry_seq }; } @@ -40,46 +53,48 @@ export function queryCachedBranchRows( query: CachedBranchQuery, ) { const oldestFirst = query.order === "oldestFirst"; - const boundaryParams: unknown[] = [sessionId, branch.branchId, branch.leafSeq]; - const stopPredicates: string[] = []; - if (query.stopAtType !== undefined) { - stopPredicates.push("stop_entry.type = ?"); - boundaryParams.push(query.stopAtType); - } - if (query.stopAtId !== undefined) { - stopPredicates.push("stop.entry_id = ?"); - boundaryParams.push(query.stopAtId); + const stopPredicates: ReturnType[] = []; + if (query.stopAtType !== undefined) stopPredicates.push(sql`stop.entry_type = ${query.stopAtType}`); + if (query.stopAtId !== undefined) stopPredicates.push(sql`stop.entry_id = ${query.stopAtId}`); + + const aggregate = oldestFirst ? sql`MIN` : sql`MAX`; + const boundaryComparison = oldestFirst ? sql`<=` : sql`>=`; + const cursorComparison = oldestFirst ? sql`>` : sql`<`; + const direction = oldestFirst ? sql`ASC` : sql`DESC`; + const boundary = + stopPredicates.length === 0 + ? sql`` + : sql`SELECT ${aggregate}(stop.entry_seq) + FROM branch_entries AS stop + WHERE stop.session_id = ${sessionId} + AND stop.branch_id = ${branch.branchId} + AND stop.entry_seq <= ${branch.leafSeq} + AND (${joinSqlFragments(stopPredicates, " OR ")})`; + + const predicates = [ + sql`b.session_id = ${sessionId}`, + sql`b.branch_id = ${branch.branchId}`, + sql`b.entry_seq <= ${branch.leafSeq}`, + ]; + if (stopPredicates.length > 0) { + predicates.push( + sql`b.entry_seq ${boundaryComparison} COALESCE((${boundary}), ${oldestFirst ? branch.leafSeq : 0})`, + ); } + if (query.cursor !== undefined) predicates.push(sql`b.entry_seq ${cursorComparison} ${query.cursor.afterSeq}`); + if (query.type !== undefined) predicates.push(sql`b.entry_type = ${query.type}`); + if (query.customType !== undefined) predicates.push(sql`b.custom_type = ${query.customType}`); + const limit = query.limit === undefined ? sql`` : sql` LIMIT ${query.limit}`; - const boundary = stopPredicates.length - ? `WITH boundary AS ( - SELECT ${oldestFirst ? "MIN" : "MAX"}(stop.entry_seq) AS entry_seq - FROM branch_entries AS stop - JOIN entries AS stop_entry - ON stop_entry.session_id = stop.session_id AND stop_entry.id = stop.entry_id - WHERE stop.session_id = ? AND stop.branch_id = ? AND stop.entry_seq <= ? - AND (${stopPredicates.join(" OR ")}) - )` - : ""; - const range = stopPredicates.length - ? `AND b.entry_seq ${oldestFirst ? "<=" : ">="} COALESCE( - (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} - )` - : ""; - const sql = `${boundary} - SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload + return sql`SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload FROM branch_entries AS b JOIN entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq <= ? - ${range} - ORDER BY b.entry_seq ${oldestFirst ? "ASC" : "DESC"}`; - - const params = [...(stopPredicates.length === 0 ? [] : boundaryParams), sessionId, branch.branchId, branch.leafSeq]; - return db.prepare(sql).all(...params); + WHERE ${joinSqlFragments(predicates, " AND ")} + ORDER BY b.entry_seq ${direction}${limit}`.all(db); } export function deleteBranchEntries(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run(sessionId); + sql`DELETE FROM branch_entries WHERE session_id = ${sessionId}`.run(db); } export function insertBranchEntry( @@ -91,42 +106,57 @@ export function insertBranchEntry( entryType: string, customType: string | null, ) { - db.prepare( - `INSERT INTO branch_entries + sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run(sessionId, branchId, entryId, entrySeq, entryType, customType); + VALUES (${sessionId}, ${branchId}, ${entryId}, ${entrySeq}, ${entryType}, ${customType})`.run(db); +} + +function customTypeFromPayload(row: BranchPathEntryRow): string | null { + if (row.type !== "custom") return null; + try { + const payload = JSON.parse(row.payload) as unknown; + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new Error("Payload is not an object"); + } + const customType = (payload as { customType?: unknown }).customType; + if (typeof customType !== "string") throw new Error("Invalid custom payload"); + return customType; + } catch (error) { + throw new SessionError( + "invalid_entry", + `Invalid SQLite session entry ${row.id}: failed to decode entry ${row.id}`, + error instanceof Error ? error : undefined, + ); + } } export function insertBranchEntriesForPath(db: SqliteDatabase, sessionId: string, branchId: string, leafId: string) { - db.prepare( - `WITH RECURSIVE path(id, entry_seq, parent_id, type, custom_type) AS ( - SELECT id, seq, parent_id, type, - CASE WHEN type = 'custom' THEN json_extract(payload, '$.customType') ELSE NULL END - FROM entries - WHERE session_id = ? AND id = ? - UNION ALL - SELECT parent.id, parent.seq, parent.parent_id, parent.type, - CASE WHEN parent.type = 'custom' THEN json_extract(parent.payload, '$.customType') ELSE NULL END - FROM entries AS parent - JOIN path AS child ON child.parent_id = parent.id - WHERE parent.session_id = ? - ) - INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - SELECT ?, ?, id, entry_seq, type, custom_type FROM path`, - ).run(sessionId, leafId, sessionId, sessionId, branchId); + const path: BranchPathEntryRow[] = []; + const seen = new Set(); + let entryId: string | null = leafId; + + while (entryId !== null) { + if (seen.has(entryId)) throw new SessionError("invalid_entry", `Entry parent cycle at ${entryId}`); + seen.add(entryId); + const row: BranchPathEntryRow | undefined = sql`SELECT id, seq, parent_id, type, payload + FROM entries + WHERE session_id = ${sessionId} AND id = ${entryId}`.get(db); + if (!row) throw new SessionError("invalid_entry", `Entry ${entryId} not found`); + path.push(row); + entryId = row.parent_id; + } + + for (const row of path.reverse()) { + insertBranchEntry(db, sessionId, branchId, row.id, row.seq, row.type, customTypeFromPayload(row)); + } } export function readBranchContainingEntry(db: SqliteDatabase, sessionId: string, entryId: string) { - const row = db - .prepare( - `SELECT b.branch_id, b.entry_seq - FROM branch_entries AS b - WHERE b.session_id = ? AND b.entry_id = ? - ORDER BY b.branch_id - LIMIT 1`, - ) - .get<{ branch_id: string; entry_seq: number }>(sessionId, entryId); + const row = sql`SELECT b.branch_id, b.entry_seq + FROM branch_entries AS b + WHERE b.session_id = ${sessionId} AND b.entry_id = ${entryId} + ORDER BY b.branch_id + LIMIT 1`.get<{ branch_id: string; entry_seq: number }>(db); return row === undefined ? undefined : { branchId: row.branch_id, entrySeq: row.entry_seq }; } @@ -137,10 +167,8 @@ export function copyBranchEntriesThroughSeq( sourceBranchId: string, throughSeq: number, ) { - db.prepare( - `INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - SELECT session_id, ?, entry_id, entry_seq, entry_type, custom_type - FROM branch_entries - WHERE session_id = ? AND branch_id = ? AND entry_seq <= ?`, - ).run(targetBranchId, sessionId, sourceBranchId, throughSeq); + sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) + SELECT session_id, ${targetBranchId}, entry_id, entry_seq, entry_type, custom_type + FROM branch_entries + WHERE session_id = ${sessionId} AND branch_id = ${sourceBranchId} AND entry_seq <= ${throughSeq}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts index e4dffc48855..da593e93aad 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts @@ -1,25 +1,21 @@ +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export function readBranchTipIds(db: SqliteDatabase, sessionId: string) { - return db - .prepare("SELECT tip_id FROM branch_tips WHERE session_id = ? ORDER BY tip_id") - .all<{ tip_id: string }>(sessionId) + return sql`SELECT tip_id FROM branch_tips WHERE session_id = ${sessionId} ORDER BY tip_id` + .all<{ tip_id: string }>(db) .map((row) => row.tip_id); } export function readBranchTipBranchId(db: SqliteDatabase, sessionId: string, tipId: string) { - const tip = db - .prepare("SELECT branch_id FROM branch_tips WHERE session_id = ? AND tip_id = ?") - .get<{ branch_id: string }>(sessionId, tipId); + const tip = sql`SELECT branch_id FROM branch_tips WHERE session_id = ${sessionId} AND tip_id = ${tipId}`.get<{ + branch_id: string; + }>(db); return tip?.branch_id; } export function insertBranchTip(db: SqliteDatabase, sessionId: string, tipId: string, branchId: string) { - db.prepare("INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (?, ?, ?)").run( - sessionId, - tipId, - branchId, - ); + sql`INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (${sessionId}, ${tipId}, ${branchId})`.run(db); } export function updateBranchTip( @@ -29,12 +25,11 @@ export function updateBranchTip( oldTipId: string, newTipId: string, ) { - const result = db - .prepare("UPDATE branch_tips SET tip_id = ? WHERE session_id = ? AND branch_id = ? AND tip_id = ?") - .run(newTipId, sessionId, branchId, oldTipId); + const result = sql`UPDATE branch_tips SET tip_id = ${newTipId} + WHERE session_id = ${sessionId} AND branch_id = ${branchId} AND tip_id = ${oldTipId}`.run(db); return result.changes === 1; } export function deleteBranchTips(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run(sessionId); + sql`DELETE FROM branch_tips WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts index 76063c29e36..2c5b9472c90 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts @@ -1,4 +1,5 @@ import type { Entry, EntryOrder } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface EntryRow { @@ -7,7 +8,7 @@ export interface EntryRow { id: string; parent_id: string | null; type: Entry["type"]; - timestamp: string; + timestamp: number; payload: string; } @@ -16,7 +17,7 @@ export interface NewEntryRow { id: string; parentId: string | null; type: Entry["type"]; - timestamp: string; + timestamp: number; payload: string; } @@ -25,51 +26,53 @@ export function entryPayload(entry: Entry): Record { return payload; } -function orderedSql(order: EntryOrder | undefined): string { - return order === "oldestFirst" ? "ASC" : "DESC"; -} - export function insertEntryRow(db: SqliteDatabase, sessionId: string, entry: NewEntryRow) { - db.prepare( - "INSERT INTO entries (session_id, id, seq, parent_id, type, timestamp, payload) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run(sessionId, entry.id, entry.seq, entry.parentId, entry.type, entry.timestamp, entry.payload); + sql`INSERT INTO entries (session_id, id, seq, parent_id, type, timestamp, payload) + VALUES (${sessionId}, ${entry.id}, ${entry.seq}, ${entry.parentId}, ${entry.type}, ${entry.timestamp}, ${entry.payload})`.run( + db, + ); } export function readEntryRow(db: SqliteDatabase, sessionId: string, entryId: string) { - return db - .prepare( - "SELECT session_id, seq, id, parent_id, type, timestamp, payload FROM entries WHERE session_id = ? AND id = ?", - ) - .get(sessionId, entryId); + return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload + FROM entries + WHERE session_id = ${sessionId} AND id = ${entryId}`.get(db); } export function readEntryRows( db: SqliteDatabase, sessionId: string, - options: { afterSeq?: number; order?: EntryOrder } = {}, + options: { + afterSeq?: number; + cursor?: { afterSeq: number }; + type?: Entry["type"]; + order?: EntryOrder; + limit?: number; + } = {}, ) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (options.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(options.afterSeq); - } - return db - .prepare( - `SELECT session_id, seq, id, parent_id, type, timestamp, payload - FROM entries - WHERE ${predicates.join(" AND ")} - ORDER BY seq ${orderedSql(options.order)}`, - ) - .all(...params); + const oldestFirst = options.order === "oldestFirst"; + const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + const cursor = + options.cursor === undefined + ? sql`` + : oldestFirst + ? sql` AND seq > ${options.cursor.afterSeq}` + : sql` AND seq < ${options.cursor.afterSeq}`; + const type = options.type === undefined ? sql`` : sql` AND type = ${options.type}`; + const direction = oldestFirst ? sql`ASC` : sql`DESC`; + const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; + return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload + FROM entries + WHERE session_id = ${sessionId}${after}${cursor}${type} + ORDER BY seq ${direction}${limit}`.all(db); } export function idExistsInEntries(db: SqliteDatabase, sessionId: string, id: string) { - return !!db - .prepare("SELECT 1 AS found FROM entries WHERE session_id = ? AND id = ? LIMIT 1") - .get<{ found: number }>(sessionId, id); + return !!sql`SELECT 1 AS found FROM entries WHERE session_id = ${sessionId} AND id = ${id} LIMIT 1`.get<{ + found: number; + }>(db); } export function deleteEntryRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM entries WHERE session_id = ?").run(sessionId); + sql`DELETE FROM entries WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts index 50d056f4741..19fa76e751b 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts @@ -1,3 +1,4 @@ +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface FactRow { @@ -16,58 +17,48 @@ export function appendFact( key: string | null, value: string | null, ) { - db.prepare("INSERT INTO facts (session_id, seq, kind, key, value) VALUES (?, ?, ?, ?, ?)").run( - sessionId, - seq, - kind, - key, - value, + sql`INSERT INTO facts (session_id, seq, kind, key, value) VALUES (${sessionId}, ${seq}, ${kind}, ${key}, ${value})`.run( + db, ); } export function readLatestFact(db: SqliteDatabase, sessionId: string, kind: string, key: string | null) { - return db - .prepare( - `SELECT session_id, seq, kind, key, value - FROM facts - WHERE session_id = ? AND kind = ? AND key IS ? - ORDER BY seq DESC - LIMIT 1`, - ) - .get(sessionId, kind, key); + return sql`SELECT session_id, seq, kind, key, value + FROM facts INDEXED BY idx_facts_session_kind_key_seq + WHERE session_id = ${sessionId} AND kind = ${kind} AND key IS ${key} + ORDER BY seq DESC + LIMIT 1`.get(db); } export function readLatestLabelFacts(db: SqliteDatabase, sessionId: string) { - return db - .prepare( - `SELECT key, value FROM ( - SELECT key, value, ROW_NUMBER() OVER (PARTITION BY key ORDER BY seq DESC) AS rank - FROM facts - WHERE session_id = ? AND kind = 'label' + return sql`SELECT f.key, f.value + FROM facts AS f INDEXED BY idx_facts_session_kind_key_seq + WHERE f.session_id = ${sessionId} + AND f.kind = 'label' + AND f.value IS NOT NULL + AND f.seq = ( + SELECT MAX(candidate.seq) + FROM facts AS candidate INDEXED BY idx_facts_session_kind_key_seq + WHERE candidate.session_id = f.session_id + AND candidate.kind = f.kind + AND candidate.key IS f.key ) - WHERE rank = 1 AND value IS NOT NULL - ORDER BY key`, - ) - .all<{ key: string; value: string }>(sessionId); + ORDER BY f.key`.all<{ key: string; value: string }>(db); } -export function readFactRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (options.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(options.afterSeq); - } - return db - .prepare( - `SELECT session_id, seq, kind, key, value - FROM facts - WHERE ${predicates.join(" AND ")} - ORDER BY seq`, - ) - .all(...params); +export function readFactRows( + db: SqliteDatabase, + sessionId: string, + options: { afterSeq?: number; limit?: number } = {}, +) { + const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; + return sql`SELECT session_id, seq, kind, key, value + FROM facts + WHERE session_id = ${sessionId}${after} + ORDER BY seq${limit}`.all(db); } export function deleteFactRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM facts WHERE session_id = ?").run(sessionId); + sql`DELETE FROM facts WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts index d6f54a3a585..02b40d540e2 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts @@ -1,4 +1,5 @@ import { SessionError } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface LaneRow { @@ -16,29 +17,22 @@ export interface LaneMoveRow { } export function createInitialLane(db: SqliteDatabase, sessionId: string, lane = "main", leafId: string | null = null) { - db.prepare("INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) VALUES (?, ?, ?, NULL)").run( - sessionId, - lane, - leafId, - ); + sql`INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) + VALUES (${sessionId}, ${lane}, ${leafId}, NULL)`.run(db); } export function readLanes(db: SqliteDatabase, sessionId: string) { - const rows = db - .prepare( - `SELECT - l.session_id, - l.lane, - l.leaf_id, - l.open_operation_id, - (l.leaf_id IS NULL OR EXISTS ( - SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id - )) AS leaf_exists - FROM lanes AS l - WHERE l.session_id = ? - ORDER BY l.lane`, - ) - .all(sessionId); + const rows = sql`SELECT + l.session_id, + l.lane, + l.leaf_id, + l.open_operation_id, + (l.leaf_id IS NULL OR EXISTS ( + SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id + )) AS leaf_exists + FROM lanes AS l + WHERE l.session_id = ${sessionId} + ORDER BY l.lane`.all(db); for (const row of rows) { if (row.leaf_exists === 0) { throw new SessionError("storage", `Lane ${row.lane} points at missing entry ${row.leaf_id}`); @@ -53,56 +47,47 @@ export function readLanes(db: SqliteDatabase, sessionId: string) { } export function readLane(db: SqliteDatabase, sessionId: string, lane: string) { - return db - .prepare("SELECT session_id, lane, leaf_id, open_operation_id FROM lanes WHERE session_id = ? AND lane = ?") - .get(sessionId, lane); + return sql`SELECT session_id, lane, leaf_id, open_operation_id + FROM lanes + WHERE session_id = ${sessionId} AND lane = ${lane}`.get(db); } export function readLaneHead(db: SqliteDatabase, sessionId: string, lane: string) { - const row = db - .prepare( - `SELECT - l.leaf_id, - (l.leaf_id IS NULL OR EXISTS ( - SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id - )) AS leaf_exists - FROM lanes AS l - WHERE l.session_id = ? AND l.lane = ?`, - ) - .get<{ leaf_id: string | null; leaf_exists: number }>(sessionId, lane); + const row = sql`SELECT + l.leaf_id, + (l.leaf_id IS NULL OR EXISTS ( + SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id + )) AS leaf_exists + FROM lanes AS l + WHERE l.session_id = ${sessionId} AND l.lane = ${lane}`.get<{ + leaf_id: string | null; + leaf_exists: number; + }>(db); if (!row) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); if (row.leaf_exists === 0) throw new SessionError("storage", `Entry ${row.leaf_id} not found`); return { leafId: row.leaf_id }; } export function createLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - db.prepare("INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) VALUES (?, ?, ?, NULL)").run( - sessionId, - lane, - leafId, - ); + sql`INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) + VALUES (${sessionId}, ${lane}, ${leafId}, NULL)`.run(db); appendLaneMove(db, sessionId, seq, lane, leafId); } export function moveLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - const result = db - .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") - .run(leafId, sessionId, lane); + const result = sql`UPDATE lanes SET leaf_id = ${leafId} WHERE session_id = ${sessionId} AND lane = ${lane}`.run(db); if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); appendLaneMove(db, sessionId, seq, lane, leafId); } export function setLaneLeaf(db: SqliteDatabase, sessionId: string, lane: string, leafId: string | null) { - const result = db - .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") - .run(leafId, sessionId, lane); + const result = sql`UPDATE lanes SET leaf_id = ${leafId} WHERE session_id = ${sessionId} AND lane = ${lane}`.run(db); if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); } export function startLaneOperation(db: SqliteDatabase, sessionId: string, lane: string, runId: string) { - const result = db - .prepare("UPDATE lanes SET open_operation_id = ? WHERE session_id = ? AND lane = ? AND open_operation_id IS NULL") - .run(runId, sessionId, lane); + const result = sql`UPDATE lanes SET open_operation_id = ${runId} + WHERE session_id = ${sessionId} AND lane = ${lane} AND open_operation_id IS NULL`.run(db); if (result.changes === 1) return; const current = readLane(db, sessionId, lane); if (!current) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); @@ -110,38 +95,30 @@ export function startLaneOperation(db: SqliteDatabase, sessionId: string, lane: } export function finishLaneOperation(db: SqliteDatabase, sessionId: string, lane: string, runId: string) { - db.prepare( - "UPDATE lanes SET open_operation_id = NULL WHERE session_id = ? AND lane = ? AND open_operation_id = ?", - ).run(sessionId, lane, runId); + sql`UPDATE lanes SET open_operation_id = NULL + WHERE session_id = ${sessionId} AND lane = ${lane} AND open_operation_id = ${runId}`.run(db); } -export function readLaneMoveRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (options.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(options.afterSeq); - } - return db - .prepare( - `SELECT session_id, seq, lane, leaf_id - FROM lane_moves - WHERE ${predicates.join(" AND ")} - ORDER BY seq`, - ) - .all(...params); +export function readLaneMoveRows( + db: SqliteDatabase, + sessionId: string, + options: { afterSeq?: number; limit?: number } = {}, +) { + const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; + return sql`SELECT session_id, seq, lane, leaf_id + FROM lane_moves + WHERE session_id = ${sessionId}${after} + ORDER BY seq${limit}`.all(db); } export function deleteLaneRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM lane_moves WHERE session_id = ?").run(sessionId); - db.prepare("DELETE FROM lanes WHERE session_id = ?").run(sessionId); + sql`DELETE FROM lane_moves WHERE session_id = ${sessionId}`.run(db); + sql`DELETE FROM lanes WHERE session_id = ${sessionId}`.run(db); } function appendLaneMove(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - db.prepare("INSERT INTO lane_moves (session_id, seq, lane, leaf_id) VALUES (?, ?, ?, ?)").run( - sessionId, - seq, - lane, - leafId, + sql`INSERT INTO lane_moves (session_id, seq, lane, leaf_id) VALUES (${sessionId}, ${seq}, ${lane}, ${leafId})`.run( + db, ); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts index c68276877ce..7c810c2c8a5 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts @@ -1,4 +1,5 @@ import { SessionError } from "@earendil-works/pi-agent-core"; +import { joinSqlFragments, sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface RecordRow { @@ -9,7 +10,7 @@ export interface RecordRow { run_id: string | null; type: string; op_kind: string | null; - timestamp: string; + timestamp: number; payload: string; } @@ -20,36 +21,26 @@ export interface NewRecordRow { runId?: string; type: string; opKind?: string; - timestamp: string; + timestamp: number; payload: string; } export function appendRecordRow(db: SqliteDatabase, sessionId: string, record: NewRecordRow) { - db.prepare( - `INSERT INTO records + sql`INSERT INTO records (session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - sessionId, - record.seq, - record.id, - record.lane, - record.runId ?? null, - record.type, - record.opKind ?? null, - record.timestamp, - record.payload, + VALUES (${sessionId}, ${record.seq}, ${record.id}, ${record.lane}, ${record.runId ?? null}, ${record.type}, ${record.opKind ?? null}, ${record.timestamp}, ${record.payload})`.run( + db, ); } export function idExistsInRecords(db: SqliteDatabase, sessionId: string, id: string) { - return !!db - .prepare("SELECT 1 AS found FROM records WHERE session_id = ? AND id = ? LIMIT 1") - .get<{ found: number }>(sessionId, id); + return !!sql`SELECT 1 AS found FROM records WHERE session_id = ${sessionId} AND id = ${id} LIMIT 1`.get<{ + found: number; + }>(db); } export function deleteRecordRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM records WHERE session_id = ?").run(sessionId); + sql`DELETE FROM records WHERE session_id = ${sessionId}`.run(db); } export function readRecordRows( @@ -65,39 +56,18 @@ export function readRecordRows( limit?: number; } = {}, ) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (query.lane !== undefined) { - predicates.push("lane = ?"); - params.push(query.lane); - } - if (query.type !== undefined) { - predicates.push("type = ?"); - params.push(query.type); - } - if (query.runId !== undefined) { - predicates.push("run_id = ?"); - params.push(query.runId); - } - if (query.operationKind !== undefined) { - predicates.push("op_kind = ?"); - params.push(query.operationKind); - } - if (query.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(query.afterSeq); - } - const limit = query.limit === undefined ? "" : " LIMIT ?"; - if (query.limit !== undefined) params.push(query.limit); - const direction = query.order === "oldestFirst" ? "ASC" : "DESC"; - return db - .prepare( - `SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload - FROM records - WHERE ${predicates.join(" AND ")} - ORDER BY seq ${direction}${limit}`, - ) - .all(...params); + const predicates = [sql`session_id = ${sessionId}`]; + if (query.lane !== undefined) predicates.push(sql`lane = ${query.lane}`); + if (query.type !== undefined) predicates.push(sql`type = ${query.type}`); + if (query.runId !== undefined) predicates.push(sql`run_id = ${query.runId}`); + if (query.operationKind !== undefined) predicates.push(sql`op_kind = ${query.operationKind}`); + if (query.afterSeq !== undefined) predicates.push(sql`seq > ${query.afterSeq}`); + const direction = query.order === "oldestFirst" ? sql`ASC` : sql`DESC`; + const limit = query.limit === undefined ? sql`` : sql` LIMIT ${query.limit}`; + return sql`SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload + FROM records + WHERE ${joinSqlFragments(predicates, " AND ")} + ORDER BY seq ${direction}${limit}`.all(db); } export function readOpenOperationRows( @@ -106,19 +76,15 @@ export function readOpenOperationRows( lane: string, _options: { limit?: number } = {}, ): RecordRow[] { - const laneRow = db - .prepare("SELECT open_operation_id FROM lanes WHERE session_id = ? AND lane = ?") - .get<{ open_operation_id: string | null }>(sessionId, lane); + const laneRow = sql`SELECT open_operation_id FROM lanes WHERE session_id = ${sessionId} AND lane = ${lane}`.get<{ + open_operation_id: string | null; + }>(db); if (!laneRow?.open_operation_id) return []; - const record = db - .prepare( - `SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload - FROM records - WHERE session_id = ? - AND id = ?`, - ) - .get(sessionId, laneRow.open_operation_id); + const record = sql`SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload + FROM records + WHERE session_id = ${sessionId} + AND id = ${laneRow.open_operation_id}`.get(db); if (!record) { throw new SessionError("storage", `Lane ${lane} points at missing open operation ${laneRow.open_operation_id}`); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts index 578005c4dd9..16f7df44c3e 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts @@ -1,14 +1,15 @@ import { SessionError } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export function createSequence(db: SqliteDatabase, sessionId: string, nextSeq = 1) { - db.prepare("INSERT INTO session_sequences (session_id, next_seq) VALUES (?, ?)").run(sessionId, nextSeq); + sql`INSERT INTO session_sequences (session_id, next_seq) VALUES (${sessionId}, ${nextSeq})`.run(db); } export function getNextSequence(db: SqliteDatabase, sessionId: string) { - const sequenceRow = db - .prepare("SELECT next_seq FROM session_sequences WHERE session_id = ?") - .get<{ next_seq: number }>(sessionId); + const sequenceRow = sql`SELECT next_seq FROM session_sequences WHERE session_id = ${sessionId}`.get<{ + next_seq: number; + }>(db); if (!sequenceRow) { throw new SessionError("storage", `Missing sequence row for session ${sessionId}`); } @@ -16,7 +17,7 @@ export function getNextSequence(db: SqliteDatabase, sessionId: string) { } export function setNextSequence(db: SqliteDatabase, sessionId: string, nextSeq: number) { - db.prepare("UPDATE session_sequences SET next_seq = ? WHERE session_id = ?").run(nextSeq, sessionId); + sql`UPDATE session_sequences SET next_seq = ${nextSeq} WHERE session_id = ${sessionId}`.run(db); } export function advanceSequence(db: SqliteDatabase, sessionId: string, seq: number) { @@ -24,5 +25,5 @@ export function advanceSequence(db: SqliteDatabase, sessionId: string, seq: numb } export function deleteSequence(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM session_sequences WHERE session_id = ?").run(sessionId); + sql`DELETE FROM session_sequences WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts index 370cfb9472f..85a3a458106 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts @@ -1,5 +1,6 @@ import { SessionError, type SessionStats } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface SessionStatsRow { @@ -12,21 +13,15 @@ export interface SessionStatsRow { } export function createStats(db: SqliteDatabase, sessionId: string, messageCount = 0): void { - db.prepare( - `INSERT INTO session_stats + sql`INSERT INTO session_stats (session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total) - VALUES (?, ?, 0, 0, 0, 0)`, - ).run(sessionId, messageCount); + VALUES (${sessionId}, ${messageCount}, 0, 0, 0, 0)`.run(db); } export function readStats(db: SqliteDatabase, sessionId: string): SessionStats { - const row = db - .prepare( - `SELECT session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total - FROM session_stats - WHERE session_id = ?`, - ) - .get(sessionId); + const row = sql`SELECT session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total + FROM session_stats + WHERE session_id = ${sessionId}`.get(db); if (!row) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); return { messageCount: row.message_count, @@ -38,26 +33,22 @@ export function readStats(db: SqliteDatabase, sessionId: string): SessionStats { } export function incrementMessageCount(db: SqliteDatabase, sessionId: string): void { - const result = db - .prepare("UPDATE session_stats SET message_count = message_count + 1 WHERE session_id = ?") - .run(sessionId); + const result = sql`UPDATE session_stats SET message_count = message_count + 1 WHERE session_id = ${sessionId}`.run( + db, + ); if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); } export function addUsageToStats(db: SqliteDatabase, sessionId: string, usage: Usage): void { - const result = db - .prepare( - `UPDATE session_stats - SET cached_tokens = cached_tokens + ?, - uncached_tokens = uncached_tokens + ?, - total_tokens = total_tokens + ?, - cost_total = cost_total + ? - WHERE session_id = ?`, - ) - .run(usage.cacheRead, usage.input + usage.cacheWrite, usage.totalTokens, usage.cost.total, sessionId); + const result = sql`UPDATE session_stats + SET cached_tokens = cached_tokens + ${usage.cacheRead}, + uncached_tokens = uncached_tokens + ${usage.input + usage.cacheWrite}, + total_tokens = total_tokens + ${usage.totalTokens}, + cost_total = cost_total + ${usage.cost.total} + WHERE session_id = ${sessionId}`.run(db); if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); } export function deleteStats(db: SqliteDatabase, sessionId: string): void { - db.prepare("DELETE FROM session_stats WHERE session_id = ?").run(sessionId); + sql`DELETE FROM session_stats WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts index d963c5c89d4..e954c0b38b1 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts @@ -1,9 +1,10 @@ import { assertJsonSerializable, SessionError } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts"; export interface SessionRow { id: string; - created_at: string; + created_at: number; metadata: string | null; cwd: string; parent_session_id: string | null; @@ -13,7 +14,7 @@ export interface SessionRow { export interface NewSessionRow { id: string; - createdAt: string; + createdAt: number; cwd: string; parentSessionId?: string; metadata?: Record; @@ -38,7 +39,7 @@ function parseMetadata(metadata: string | null, sessionId: string): Record(sessionId); + return !!sql`SELECT 1 AS found FROM sessions WHERE id = ${sessionId}`.get<{ found: number }>(db); } function serializeMetadata(metadata: Record | undefined): string | null { @@ -51,67 +52,54 @@ function serializeMetadata(metadata: Record | undefined): strin } export function insertSessionRow(db: SqliteDatabase, session: NewSessionRow) { - db.prepare("INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id) VALUES (?, ?, ?, ?, ?)").run( - session.id, - session.createdAt, - serializeMetadata(session.metadata), - session.cwd, - session.parentSessionId ?? null, + sql`INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id) + VALUES (${session.id}, ${session.createdAt}, ${serializeMetadata(session.metadata)}, ${session.cwd}, ${session.parentSessionId ?? null})`.run( + db, ); } export function readSessionRow(db: SqliteDatabase, sessionId: string) { - return db - .prepare( - `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name - FROM sessions AS s - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - WHERE s.id = ?`, - ) - .get(sessionId); + return sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, + name_fact.seq IS NOT NULL AS has_session_name, + name_fact.value AS session_name + FROM sessions AS s + LEFT JOIN facts AS name_fact + ON name_fact.session_id = s.id + AND name_fact.kind = 'name' + AND name_fact.key IS NULL + AND name_fact.seq = ( + SELECT MAX(f.seq) + FROM facts AS f + WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + ) + WHERE s.id = ${sessionId}`.get(db); } export function readSessionRows(db: SqliteDatabase, options: { cwd?: string } = {}) { - const where = options.cwd === undefined ? "" : "WHERE s.cwd = ?"; - return db - .prepare( - `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name - FROM sessions AS s - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - ${where} - ORDER BY s.created_at DESC`, - ) - .all(...(options.cwd === undefined ? [] : [options.cwd])); + const where = options.cwd === undefined ? sql`` : sql`WHERE s.cwd = ${options.cwd}`; + return sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, + name_fact.seq IS NOT NULL AS has_session_name, + name_fact.value AS session_name + FROM sessions AS s + LEFT JOIN facts AS name_fact + ON name_fact.session_id = s.id + AND name_fact.kind = 'name' + AND name_fact.key IS NULL + AND name_fact.seq = ( + SELECT MAX(f.seq) + FROM facts AS f + WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + ) + ${where} + ORDER BY s.created_at DESC`.all(db); } export function deleteSessionRow(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); + sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db); } -function parseSessionName(value: string | null, sessionId: string): string { - if (value === null) { - throw new SessionError("storage", `Invalid SQLite session ${sessionId}: name must be a string`); - } +function parseSessionName(value: string | null, sessionId: string): string | undefined { + if (value === null) return undefined; let parsed: unknown; try { parsed = JSON.parse(value); @@ -133,7 +121,7 @@ export function decodeSessionMetadata(row: SessionRow, path: string): SqliteSess const name = row.has_session_name === 0 ? undefined : parseSessionName(row.session_name, row.id); return { id: row.id, - createdAt: Date.parse(row.created_at), + createdAt: row.created_at, ...(name === undefined ? {} : { name }), cwd: row.cwd, path, diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts index 02cf38ec0dd..ec7fcaa916d 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts @@ -1,3 +1,4 @@ +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface WriterLease { @@ -19,18 +20,14 @@ export function acquireWriterLease( now: number, expiresAtMs: number, ) { - const row = db - .prepare( - `INSERT INTO writer_leases (session_id, owner_id, fence, expires_at_ms) - VALUES (?, ?, 1, ?) - ON CONFLICT(session_id) DO UPDATE SET - owner_id = excluded.owner_id, - fence = writer_leases.fence + 1, - expires_at_ms = excluded.expires_at_ms - WHERE writer_leases.expires_at_ms <= ? - RETURNING owner_id, fence, expires_at_ms`, - ) - .get(sessionId, ownerId, expiresAtMs, now); + const row = sql`INSERT INTO writer_leases (session_id, owner_id, fence, expires_at_ms) + VALUES (${sessionId}, ${ownerId}, 1, ${expiresAtMs}) + ON CONFLICT(session_id) DO UPDATE SET + owner_id = excluded.owner_id, + fence = writer_leases.fence + 1, + expires_at_ms = excluded.expires_at_ms + WHERE writer_leases.expires_at_ms <= ${now} + RETURNING owner_id, fence, expires_at_ms`.get(db); return row === undefined ? undefined : { ownerId: row.owner_id, fence: row.fence, expiresAtMs: row.expires_at_ms }; } @@ -41,25 +38,21 @@ export function renewWriterLease( now: number, expiresAtMs: number, ) { - const result = db - .prepare( - `UPDATE writer_leases - SET expires_at_ms = ? - WHERE session_id = ? AND owner_id = ? AND fence = ? AND expires_at_ms > ?`, - ) - .run(expiresAtMs, sessionId, lease.ownerId, lease.fence, now); + const result = sql`UPDATE writer_leases + SET expires_at_ms = ${expiresAtMs} + WHERE session_id = ${sessionId} + AND owner_id = ${lease.ownerId} + AND fence = ${lease.fence} + AND expires_at_ms > ${now}`.run(db); if (result.changes === 1) lease.expiresAtMs = expiresAtMs; return result.changes === 1; } export function releaseWriterLease(db: SqliteDatabase, sessionId: string, lease: WriterLease) { - db.prepare("DELETE FROM writer_leases WHERE session_id = ? AND owner_id = ? AND fence = ?").run( - sessionId, - lease.ownerId, - lease.fence, - ); + sql`DELETE FROM writer_leases + WHERE session_id = ${sessionId} AND owner_id = ${lease.ownerId} AND fence = ${lease.fence}`.run(db); } export function deleteWriterLease(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM writer_leases WHERE session_id = ?").run(sessionId); + sql`DELETE FROM writer_leases WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/types.ts b/packages/session-backends/sqlite-node/src/sqlite/types.ts index 349a09d3d26..6afadfd679f 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/types.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/types.ts @@ -13,6 +13,7 @@ export interface SqliteStatement { run(...params: unknown[]): SqliteRunResult; get(...params: unknown[]): TRow | undefined; all(...params: unknown[]): TRow[]; + iterate(...params: unknown[]): Iterable; } /** SQLite database capability used by the SQLite session backend. */ diff --git a/packages/session-backends/sqlite-node/test/branch-query.test.ts b/packages/session-backends/sqlite-node/test/branch-query.test.ts index 210f7475380..54f017b68a4 100644 --- a/packages/session-backends/sqlite-node/test/branch-query.test.ts +++ b/packages/session-backends/sqlite-node/test/branch-query.test.ts @@ -51,7 +51,7 @@ describe("SQLite branch queries", () => { }); }); - it("validates entries before branch query filters and limits", async () => { + it("does not decode entries excluded by branch query filters and limits", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -70,10 +70,9 @@ describe("SQLite branch queries", () => { } finally { await db.close(); } - await expect(session.findEntriesOnBranch({ start: leafId, type: "message", limit: 1 })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); + expect( + (await session.findEntriesOnBranch({ start: leafId, type: "message", limit: 1 })).map((entry) => entry.id), + ).toEqual([leafId]); const invalidJsonDb = await sqlite.open(databasePath); try { @@ -83,10 +82,7 @@ describe("SQLite branch queries", () => { } finally { await invalidJsonDb.close(); } - await expect(session.findEntriesOnBranch({ start: leafId, customType: "other" })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); + expect(await session.findEntriesOnBranch({ start: leafId, customType: "other" })).toEqual([]); }); it("does not validate ancestors beyond newest-first stop bounds", async () => { diff --git a/packages/session-backends/sqlite-node/test/facts-query.test.ts b/packages/session-backends/sqlite-node/test/facts-query.test.ts new file mode 100644 index 00000000000..84cc4987ba3 --- /dev/null +++ b/packages/session-backends/sqlite-node/test/facts-query.test.ts @@ -0,0 +1,31 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { applyMigrations, createNodeSqliteFactory } from "../src/index.ts"; +import { appendFact, readLatestFact, readLatestLabelFacts } from "../src/sqlite/storage/facts.ts"; +import { createTempDir } from "./test-utils.ts"; + +describe("SQLite fact queries", () => { + it("reads latest facts and latest non-null labels", async () => { + const databasePath = join(createTempDir(), "sessions.sqlite"); + const db = await createNodeSqliteFactory().open(databasePath); + try { + await applyMigrations(db); + appendFact(db, "session-1", 1, "label", "entry-1", JSON.stringify("old")); + appendFact(db, "session-1", 2, "label", "entry-2", JSON.stringify("kept")); + appendFact(db, "session-1", 3, "label", "entry-1", JSON.stringify("new")); + appendFact(db, "session-1", 4, "label", "entry-3", JSON.stringify("removed")); + appendFact(db, "session-1", 5, "label", "entry-3", null); + appendFact(db, "session-1", 6, "name", null, JSON.stringify("session name")); + appendFact(db, "other-session", 1, "label", "entry-1", JSON.stringify("other")); + + expect(readLatestFact(db, "session-1", "label", "entry-1")?.value).toBe(JSON.stringify("new")); + expect(readLatestFact(db, "session-1", "name", null)?.value).toBe(JSON.stringify("session name")); + expect(readLatestLabelFacts(db, "session-1")).toEqual([ + { key: "entry-1", value: JSON.stringify("new") }, + { key: "entry-2", value: JSON.stringify("kept") }, + ]); + } finally { + db.close(); + } + }); +}); diff --git a/packages/session-backends/sqlite-node/test/log-query.test.ts b/packages/session-backends/sqlite-node/test/log-query.test.ts new file mode 100644 index 00000000000..385c05d9c0f --- /dev/null +++ b/packages/session-backends/sqlite-node/test/log-query.test.ts @@ -0,0 +1,35 @@ +import { join } from "node:path"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { describe, expect, it } from "vitest"; +import { createNodeSqliteFactory, SqliteSessionRepository } from "../src/index.ts"; +import { createTempDir, createUserMessage } from "./test-utils.ts"; + +describe("SQLite log queries", () => { + it("does not decode rows beyond the requested log limit", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + const rootId = await session.appendMessage(createUserMessage("root")); + await session.setName("name"); + const tailId = await session.appendMessage(createUserMessage("tail")); + + const db = await sqlite.open(databasePath); + try { + await db + .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") + .run("not json", "session-1", tailId); + } finally { + await db.close(); + } + + expect(await session.getLog({ limit: 1 })).toEqual([ + expect.objectContaining({ kind: "entry", seq: 1, entry: expect.objectContaining({ id: rootId }) }), + ]); + expect(await session.getLog({ afterSeq: 1, limit: 1 })).toEqual([ + expect.objectContaining({ kind: "fact", seq: 2, fact: "name", name: "name" }), + ]); + }); +}); diff --git a/packages/session-backends/sqlite-node/test/migrations.test.ts b/packages/session-backends/sqlite-node/test/migrations.test.ts index 5d59f55d613..fb16311009f 100644 --- a/packages/session-backends/sqlite-node/test/migrations.test.ts +++ b/packages/session-backends/sqlite-node/test/migrations.test.ts @@ -34,8 +34,26 @@ describe("SQLite migrations", () => { ); const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id"); + const sessionIndexes = db.prepare("PRAGMA index_list(sessions)").all<{ name: string }>(); + expect(sessionIndexes.map((index) => index.name)).toContain("idx_sessions_cwd_created_at"); + expect(sessionIndexes.map((index) => index.name)).not.toContain("idx_sessions_parent"); const laneColumns = db.prepare("PRAGMA table_info(lanes)").all<{ name: string }>(); expect(laneColumns.map((column) => column.name)).toContain("open_operation_id"); + const entryIndexes = db.prepare("PRAGMA index_list(entries)").all<{ name: string }>(); + expect(entryIndexes.map((index) => index.name)).not.toContain("idx_entries_session_seq"); + const branchEntryIndexes = db.prepare("PRAGMA index_list(branch_entries)").all<{ name: string }>(); + expect(branchEntryIndexes.map((index) => index.name)).toContain("idx_branch_entries_session_entry"); + const recordIndexes = db.prepare("PRAGMA index_list(records)").all<{ name: string }>(); + expect(recordIndexes.map((index) => index.name)).toEqual( + expect.arrayContaining([ + "idx_records_session_lane_seq", + "idx_records_session_type_seq", + "idx_records_session_type_op_kind_seq", + ]), + ); + expect(recordIndexes.map((index) => index.name)).not.toContain("idx_records_session_seq"); + const laneMoveIndexes = db.prepare("PRAGMA index_list(lane_moves)").all<{ name: string }>(); + expect(laneMoveIndexes.map((index) => index.name)).not.toContain("idx_lane_moves_session_lane_seq"); } finally { db.close(); } diff --git a/packages/session-backends/sqlite-node/test/repository.test.ts b/packages/session-backends/sqlite-node/test/repository.test.ts index 633c5d3c23b..151c0a42a8c 100644 --- a/packages/session-backends/sqlite-node/test/repository.test.ts +++ b/packages/session-backends/sqlite-node/test/repository.test.ts @@ -37,6 +37,8 @@ class ThrowingStatement implements SqliteStatement { all(..._params: unknown[]): TRow[] { return []; } + + *iterate(..._params: unknown[]): Iterable {} } class CountingDatabase implements SqliteDatabase { @@ -275,7 +277,6 @@ END; it.each([ ["invalid JSON", "not json", "name is not valid JSON"], ["a non-string value", "{}", "name must be a string"], - ["a NULL value", null, "name must be a string"], ])("rejects stored session names containing %s", async (_case, stored, message) => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); @@ -302,6 +303,26 @@ END; }); }); + it("omits a cleared session name from metadata", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + await using repo = new SqliteSessionRepository({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const session = await repo.create({ cwd: root, id: "session-1" }); + await session.setName("Temporary"); + expect(await session.getMetadata()).toMatchObject({ name: "Temporary" }); + + await session.setName(undefined); + + expect(await session.getName()).toBeUndefined(); + expect(await session.getMetadata()).not.toHaveProperty("name"); + expect((await repo.list())[0]).not.toHaveProperty("name"); + }); + it("fails loudly when a stored entry is read and cannot be decoded", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); diff --git a/packages/session-backends/sqlite-node/test/search.test.ts b/packages/session-backends/sqlite-node/test/search.test.ts index 7917dd52929..bebffd6afbe 100644 --- a/packages/session-backends/sqlite-node/test/search.test.ts +++ b/packages/session-backends/sqlite-node/test/search.test.ts @@ -13,8 +13,14 @@ function createSqliteFixture(options: ConstructorParameters(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) items.push(item); + return items; +} + describe("SQLite FTS5 session search", () => { - it("matches trigrams within one cwd", async () => { + it("matches trigrams", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); @@ -25,24 +31,39 @@ describe("SQLite FTS5 session search", () => { const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" }); const entryId = await included.appendMessage(createUserMessage("Find the auth defect")); await included.setName("Canonical name"); - await excluded.appendMessage(createUserMessage("Find the auth defect")); + const excludedEntryId = await excluded.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth", cwd: root })).resolves.toEqual([ - expect.objectContaining({ - entryId, - metadata: expect.objectContaining({ - id: "included", - name: "Canonical name", - metadata: { name: "application-owned" }, + const authHits = await collect(search.search("auth")); + expect(authHits).toHaveLength(2); + expect(authHits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "included", + entryId, + timestamp: expect.any(Number), + metadata: expect.objectContaining({ + id: "included", + createdAt: expect.any(Number), + name: "Canonical name", + metadata: { name: "application-owned" }, + }), }), - }), - ]); - await expect(search.search({ text: "uth", cwd: root })).resolves.toEqual([ - expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }), - ]); + expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), + ]), + ); + expect(await collect(search.search("uth"))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "included", + entryId, + metadata: expect.objectContaining({ id: "included" }), + }), + expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), + ]), + ); }); - it("rejects a stored NULL session name", async () => { + it("omits a cleared session name from search metadata", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); @@ -50,20 +71,13 @@ describe("SQLite FTS5 session search", () => { await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository, search } = fixture; const session = await repository.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("Find the auth defect")); - await session.setName("valid name"); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("UPDATE facts SET value = NULL WHERE session_id = ? AND kind = 'name'").run("session-1"); - } finally { - await db.close(); - } + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + await session.setName("Temporary"); + await session.setName(undefined); - await expect(search.search({ text: "auth" })).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("name must be a string"), - }); + const [result] = await collect(search.search("auth")); + expect(result).toMatchObject({ sessionId: "session-1", entryId, metadata: { id: "session-1" } }); + expect(result?.metadata).not.toHaveProperty("name"); }); it("handles quoted search text without exposing FTS syntax", async () => { @@ -75,7 +89,63 @@ describe("SQLite FTS5 session search", () => { }); const { search } = fixture; - await expect(search.search({ text: 'missing "phrase"' })).resolves.toEqual([]); + expect(await collect(search.search('missing "phrase"'))).toEqual([]); + }); + + it("rebuilds existing entries when FTS is first initialized", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + + expect(await collect(search.search("auth"))).toEqual([ + expect.objectContaining({ sessionId: "session-1", entryId }), + ]); + }); + + it("honors entry type filters", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const messageEntryId = await session.appendMessage(createUserMessage("Find the auth defect")); + await session.appendCustomEntry("note", { text: "Find the auth custom entry" }); + + expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toEqual([ + expect.objectContaining({ sessionId: "session-1", entryId: messageEntryId }), + ]); + }); + + it("honors result limits", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const first = await repository.create({ cwd: root, id: "session-1" }); + const second = await repository.create({ cwd: root, id: "session-2" }); + await first.appendMessage(createUserMessage("Find the auth defect")); + await second.appendMessage(createUserMessage("Find the auth defect too")); + + expect(await collect(search.search("auth", { limit: 1 }))).toHaveLength(1); + expect(await collect(search.search("auth", { limit: 0 }))).toEqual([]); }); it("removes deleted session entries from the index", async () => { @@ -90,11 +160,52 @@ describe("SQLite FTS5 session search", () => { const { repository, search } = fixture; const session = await repository.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth" })).resolves.toHaveLength(1); + expect(await collect(search.search("auth"))).toHaveLength(1); await repository.delete(await session.getMetadata()); - await expect(search.search({ text: "auth" })).resolves.toEqual([]); + expect(await collect(search.search("auth"))).toEqual([]); + }); + + it("indexes and removes session entries through triggers after FTS initialization", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + expect(await collect(search.search("auth"))).toEqual([]); + const session = await repository.create({ cwd: root, id: "session-1" }); + await session.appendMessage(createUserMessage("Find the auth defect")); + expect(await collect(search.search("auth"))).toHaveLength(1); + + await repository.delete(await session.getMetadata()); + + expect(await collect(search.search("auth"))).toEqual([]); + }); + + it("removes deleted entries from FTS through triggers", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ env, sqlite, databasePath }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + expect(await collect(search.search("auth"))).toHaveLength(1); + + const db = await sqlite.open(databasePath); + try { + await db.prepare("DELETE FROM entries WHERE session_id = ? AND id = ?").run("session-1", entryId); + } finally { + await db.close(); + } + + expect(await collect(search.search("auth"))).toEqual([]); }); it("does not initialize FTS for canonical writes or blank searches", async () => { @@ -104,7 +215,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await expect(search.search({ text: " " })).resolves.toEqual([]); + expect(await collect(search.search(" "))).toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); @@ -126,7 +237,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await search.search({ text: "initialize" }); + await collect(search.search("initialize")); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); @@ -147,7 +258,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await search.search({ text: "initialize" }); + await collect(search.search("initialize")); const session = await repo.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("must remain")); const metadata = await session.getMetadata(); @@ -173,7 +284,7 @@ describe("SQLite FTS5 session search", () => { databasePath: join(root, "sessions.sqlite"), }); - await expect(search.search({ text: "auth" })).rejects.toThrow("setup failed"); + await expect(collect(search.search("auth"))).rejects.toThrow("setup failed"); expect(counts.closes).toBe(1); }); @@ -187,12 +298,16 @@ describe("SQLite FTS5 session search", () => { }); const { repository: repo, search } = fixture; - await expect(search.search({ text: "auth" })).resolves.toEqual([]); + expect(await collect(search.search("auth"))).toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth" })).resolves.toEqual([ - expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "session-1" }) }), + expect(await collect(search.search("auth"))).toEqual([ + expect.objectContaining({ + sessionId: "session-1", + entryId, + metadata: expect.objectContaining({ id: "session-1" }), + }), ]); await expect(session.appendMessage(createUserMessage("Still writable"))).resolves.toBeTypeOf("string"); }); diff --git a/packages/session-backends/sqlite-node/test/sql.test.ts b/packages/session-backends/sqlite-node/test/sql.test.ts new file mode 100644 index 00000000000..020d1c46340 --- /dev/null +++ b/packages/session-backends/sqlite-node/test/sql.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { createNodeSqliteFactory, joinSqlFragments, sql } from "../src/index.ts"; + +describe("sql", () => { + it("composes SQLite queries without renumbering parameters", async () => { + const db = await createNodeSqliteFactory().open(":memory:"); + try { + sql`CREATE TABLE entries (id TEXT PRIMARY KEY, kind TEXT NOT NULL, active INTEGER NOT NULL)`.exec(db); + sql`INSERT INTO entries (id, kind, active) VALUES (${"one"}, ${"message"}, ${1})`.run(db); + sql`INSERT INTO entries (id, kind, active) VALUES (${"two"}, ${"message"}, ${0})`.run(db); + const filters = joinSqlFragments([sql`kind = ${"message"}`, sql`active = ${1}`], " AND "); + + expect(sql`SELECT id FROM entries WHERE ${filters} LIMIT ${10}`.all<{ id: string }>(db)).toEqual([ + { id: "one" }, + ]); + } finally { + db.close(); + } + }); + + it("executes parameterized queries", async () => { + const db = await createNodeSqliteFactory().open(":memory:"); + try { + sql`CREATE TABLE values_table (id INTEGER PRIMARY KEY, value TEXT NOT NULL)`.exec(db); + sql`INSERT INTO values_table (id, value) VALUES (${1}, ${"one"})`.run(db); + sql`INSERT INTO values_table (id, value) VALUES (${2}, ${"two"})`.run(db); + + expect(sql`SELECT value FROM values_table WHERE id = ${1}`.get<{ value: string }>(db)).toEqual({ + value: "one", + }); + expect(sql`SELECT value FROM values_table ORDER BY id`.all<{ value: string }>(db)).toEqual([ + { value: "one" }, + { value: "two" }, + ]); + } finally { + db.close(); + } + }); +}); diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 1592c327e6c..e11e6186a90 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +## [0.84.2] - 2026-08-14 + +## [0.84.1] - 2026-08-07 + ## [0.84.0] - 2026-08-06 ### Added diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index cfc4c6f8176..49ffa4396d0 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-telemetry", - "version": "0.84.0", + "version": "0.84.3", "description": "Vendor-neutral telemetry contracts and typed schema utilities for pi", "type": "module", "main": "./dist/index.js", @@ -41,7 +41,7 @@ "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "vitest": "4.1.9" } } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 42b2163b712..cc92212ee18 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,49 @@ ## [Unreleased] +## [0.84.3] - 2026-08-24 + +### Fixed + +- Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). +- Fixed padded text exceeding narrow terminal widths ([#8252](https://github.com/earendil-works/pi/issues/8252)). +- Fixed wrapped Markdown table links leaking color into borders and neighboring cells, including tables inside blockquotes ([#8335](https://github.com/earendil-works/pi/issues/8335)). + +## [0.84.2] - 2026-08-14 + +### Added + +- Added unbound single-line transcript scrolling actions, `tui.altScreen.lineUp` and `tui.altScreen.lineDown`, for fullscreen TUI keybindings ([#7903](https://github.com/earendil-works/pi/pull/7903) by [@midastruth](https://github.com/midastruth)). +- Added incremental primary-scroll-view search to the fullscreen TUI with configurable match styles, `Ctrl+Shift+F`, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. + +### Changed + +- Reduced alternate-screen per-frame allocation churn roughly 9-18x by painting full-width layout rows as direct line references instead of recompositing every visible row through ANSI/grapheme segmentation on each frame. + +### Fixed + +- Fixed fullscreen mouse drag selection and OSC 8 link activation in terminals that report generic SGR mouse release button codes ([#7963](https://github.com/earendil-works/pi/issues/7963)). +- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented SGR mouse input leaking into the search query. +- Fixed required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). +- Fixed LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. +- Fixed focused fullscreen overlays not receiving mouse wheel or viewport scroll keys such as PageUp and PageDown ([#7894](https://github.com/earendil-works/pi/issues/7894)). +- Fixed split `Alt+Enter` input over SSH being misread as Escape, added `PI_TUI_ESC_TIMEOUT` for high-latency terminals, and limited that timeout to lone Escape input ([#7899](https://github.com/earendil-works/pi/pull/7899) by [@powerfooI](https://github.com/powerfooI)). +- Fixed idle fullscreen sessions repainting and clearing text selection when the terminal loses focus ([#7892](https://github.com/earendil-works/pi/pull/7892) by [@terrorobe](https://github.com/terrorobe)). +- Fixed fullscreen selection copy falsely reporting success when OSC 52 is unsupported by allowing host clipboard integration and reporting verified failures ([#8110](https://github.com/earendil-works/pi/pull/8110) by [@Panoplos](https://github.com/Panoplos)). + +## [0.84.1] - 2026-08-07 + +### Added + +- Added unbound half-page transcript scrolling actions, `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown`, for fullscreen TUI keybindings ([#7735](https://github.com/earendil-works/pi/issues/7735)). +- Added double-click word and whitespace selection, granularity-aware drag selection, and triple-click paragraph selection in the fullscreen TUI ([#7725](https://github.com/earendil-works/pi/issues/7725), [#7733](https://github.com/earendil-works/pi/pull/7733) by [@volsa](https://github.com/volsa)). +- Added an optional right-click paste handler to the alternate-screen TUI, currently enabled on Windows. + +### Fixed + +- Fixed LaTeX relation, multiplication, and named-operator spacing, and correctly composed matrices with stacked fractions, operator limits, and adjacent matrices. +- Reduced fullscreen mouse event volume under tmux, Zellij, and GNU Screen by using button-motion tracking instead of all-motion tracking. + ## [0.84.0] - 2026-08-06 ### Added diff --git a/packages/tui/README.md b/packages/tui/README.md index dfcfc9ea550..58e82937081 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -121,7 +121,7 @@ if (isViewportTUI(tui)) { } ``` -Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. +Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Press `Ctrl+Shift+F` to search its rendered content, `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G` to move between matches, and `Escape` to close search. `TuiAltScreenOptions.searchMatchStyle` and `searchCurrentMatchStyle` customize match highlighting. Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling `render(width)` directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen. diff --git a/packages/tui/package.json b/packages/tui/package.json index 4ed8d148a5b..7e015439b9b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.84.0", + "version": "0.84.3", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/src/alt-screen-search.ts b/packages/tui/src/alt-screen-search.ts new file mode 100644 index 00000000000..98926523f96 --- /dev/null +++ b/packages/tui/src/alt-screen-search.ts @@ -0,0 +1,157 @@ +import { Input } from "./components/input.ts"; +import type { Component, Focusable } from "./tui.ts"; +import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.ts"; + +const segmenter = getGraphemeSegmenter(); + +interface SearchSourceSpan { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchSegment { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchMatch { + segments: AltScreenSearchSegment[]; +} + +function appendMappedText( + text: string, + span: SearchSourceSpan | undefined, + corpus: { text: string; source: Array }, +): void { + corpus.text += text; + for (let index = 0; index < text.length; index++) corpus.source.push(span); +} + +function buildSearchCorpus(lines: readonly string[]): { + text: string; + source: Array; +} { + const corpus: { text: string; source: Array } = { text: "", source: [] }; + let pendingSeparator = false; + + for (let row = 0; row < lines.length; row++) { + const line = stripTerminalSequences(lines[row] ?? ""); + let column = 0; + for (const grapheme of segmenter.segment(line)) { + const text = grapheme.segment; + const width = visibleWidth(text); + if (/^\s+$/u.test(text)) { + if (corpus.text.length > 0) pendingSeparator = true; + column += width; + continue; + } + if (pendingSeparator) { + appendMappedText(" ", undefined, corpus); + pendingSeparator = false; + } + appendMappedText(text, { row, startCol: column, endCol: column + width }, corpus); + column += width; + } + if (corpus.text.length > 0) pendingSeparator = true; + } + + return corpus; +} + +function normalizeQuery(query: string): string { + return query.replace(/\s+/gu, " ").trim(); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] { + const normalizedQuery = normalizeQuery(query); + if (!normalizedQuery) return []; + + const corpus = buildSearchCorpus(lines); + const expression = new RegExp(escapeRegExp(normalizedQuery), "giu"); + const matches: AltScreenSearchMatch[] = []; + + for (const match of corpus.text.matchAll(expression)) { + const start = match.index; + const end = start + match[0].length; + const segments: AltScreenSearchSegment[] = []; + for (let index = start; index < end; index++) { + const span = corpus.source[index]; + if (!span) continue; + const previous = segments[segments.length - 1]; + if (previous && previous.row === span.row && span.startCol <= previous.endCol) { + previous.endCol = Math.max(previous.endCol, span.endCol); + } else { + segments.push({ ...span }); + } + } + if (segments.length > 0) matches.push({ segments }); + } + + return matches; +} + +export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string { + const first = match.segments[0]; + const last = match.segments[match.segments.length - 1]; + return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : ""; +} + +export class AltScreenSearchComponent implements Component, Focusable { + private readonly input = new Input(); + private readonly onQueryChange: (query: string) => void; + private resultCount = 0; + private resultIndex = -1; + private _focused = false; + + constructor(onQueryChange: (query: string) => void) { + this.onQueryChange = onQueryChange; + } + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.input.focused = value; + } + + setResult(index: number, count: number): void { + this.resultIndex = index; + this.resultCount = count; + } + + handleInput(data: string): void { + const previous = this.input.getValue(); + this.input.handleInput(data); + const query = this.input.getValue(); + if (query !== previous) this.onQueryChange(query); + } + + invalidate(): void { + this.input.invalidate(); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const label = " Find transcript"; + const query = this.input.getValue(); + const status = !query + ? "" + : this.resultCount === 0 + ? "No matches " + : `${this.resultIndex + 1}/${this.resultCount} `; + const labelWidth = visibleWidth(label); + const statusWidth = visibleWidth(status); + const gap = " ".repeat(Math.max(1, safeWidth - labelWidth - statusWidth)); + const title = truncateToWidth(`${label}${gap}${status}`, safeWidth, ""); + const padding = " ".repeat(Math.max(0, safeWidth - visibleWidth(title))); + return [`\x1b[7m${title}${padding}\x1b[27m`, ...this.input.render(safeWidth)]; + } +} diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 608936c7bc3..f66666ea981 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -826,8 +826,13 @@ export class Markdown implements Component { * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled * consistently with the rest of the renderer. */ - private wrapCellText(text: string, maxWidth: number): string[] { - return wrapTextWithAnsi(text, Math.max(1, maxWidth)); + private wrapCellText(text: string, maxWidth: number, stylePrefix = ""): string[] { + const lines = wrapTextWithAnsi(text, Math.max(1, maxWidth)); + return lines.map((line, index) => { + // Reset text styles after each non-final fragment, then restore the surrounding style before padding and borders. + const styleReset = index < lines.length - 1 ? "\x1b[22;23;24;25;27;28;29;39m" : ""; + return `${line}${styleReset}${stylePrefix}`; + }); } /** @@ -958,7 +963,7 @@ export class Markdown implements Component { // Render header with wrapping const headerCellLines: string[][] = token.header.map((cell, i) => { const text = this.renderInlineTokens(cell.tokens || [], styleContext); - return this.wrapCellText(text, columnWidths[i]); + return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix); }); const headerLineCount = Math.max(...headerCellLines.map((c) => c.length)); @@ -981,7 +986,7 @@ export class Markdown implements Component { const row = token.rows[rowIndex]; const rowCellLines: string[][] = row.map((cell, i) => { const text = this.renderInlineTokens(cell.tokens || [], styleContext); - return this.wrapCellText(text, columnWidths[i]); + return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix); }); const rowLineCount = Math.max(...rowCellLines.map((c) => c.length)); diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts index f2c0b5fd7bd..a9a50f020a1 100644 --- a/packages/tui/src/components/scroll-view.ts +++ b/packages/tui/src/components/scroll-view.ts @@ -13,6 +13,11 @@ export interface ScrollViewOptions { scrollbarHideDelayMs?: number; } +export interface ScrollViewScrollToOptions { + /** Keep follow-end disabled even when the target is the current content end. */ + disableFollow?: boolean; +} + export class ScrollView extends Container { private readonly child: Component; private readonly followEnd: boolean; @@ -25,6 +30,7 @@ export class ScrollView extends Container { private contentHeight = 0; private currentViewportHeight = 0; private followingEnd: boolean; + private followSuppressedAtEnd = false; private requestRenderCallback: (() => void) | undefined; private transientScrollbarVisible = false; private scrollbarActive = false; @@ -110,14 +116,24 @@ export class ScrollView extends Container { this.markScrollbarActivity(); } - scrollTo(scrollTop: number): void { + scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void { const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop; const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); const next = Math.max(0, Math.min(maxScrollTop, requested)); - if (next === this.currentScrollTop) return; + const nextFollowSuppressedAtEnd = options.disableFollow === true && next === maxScrollTop; + const nextFollowingEnd = !nextFollowSuppressedAtEnd && this.followEnd && next === maxScrollTop; + if ( + next === this.currentScrollTop && + nextFollowingEnd === this.followingEnd && + nextFollowSuppressedAtEnd === this.followSuppressedAtEnd + ) { + return; + } + const moved = next !== this.currentScrollTop; this.currentScrollTop = next; - this.followingEnd = this.followEnd && next === maxScrollTop; - this.markScrollbarActivity(); + this.followingEnd = nextFollowingEnd; + this.followSuppressedAtEnd = nextFollowSuppressedAtEnd; + if (moved) this.markScrollbarActivity(); this.requestRenderCallback?.(); } @@ -128,12 +144,12 @@ export class ScrollView extends Container { const start = this.followingEnd ? maxScrollTop : this.currentScrollTop; const next = Math.max(0, Math.min(maxScrollTop, start + requested)); const moved = next - start; + const wasFollowingEnd = this.followingEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd && next === maxScrollTop; - if (moved !== 0) { - this.markScrollbarActivity(); - this.requestRenderCallback?.(); - } + this.followSuppressedAtEnd = false; + if (moved !== 0) this.markScrollbarActivity(); + if (moved !== 0 || this.followingEnd !== wasFollowingEnd) this.requestRenderCallback?.(); return requested - moved; } @@ -143,6 +159,7 @@ export class ScrollView extends Container { this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight); this.currentScrollTop = 0; this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -154,6 +171,7 @@ export class ScrollView extends Container { const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -167,7 +185,10 @@ export class ScrollView extends Container { const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); if (this.followingEnd) this.currentScrollTop = maxScrollTop; else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); - if (this.followEnd && this.currentScrollTop === maxScrollTop) this.followingEnd = true; + if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false; + if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { + this.followingEnd = true; + } if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar(); } diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 7dce91b87cf..d18732fdbe5 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -15,8 +15,12 @@ export interface SettingItem { currentValue: string; /** If provided, Enter/Space cycles through these values */ values?: string[]; - /** If provided, Enter opens this submenu. Receives current value and done callback. */ - submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; + /** If provided, Enter opens this submenu. Receives current value and done callback. + * done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */ + submenu?: ( + currentValue: string, + done: (selectedValue?: string, options?: { navigateTo?: string }) => void, + ) => Component; } export interface SettingsListTheme { @@ -45,6 +49,7 @@ export class SettingsList implements Component { // Submenu state private submenuComponent: Component | null = null; private submenuItemIndex: number | null = null; + private navigateAfterClose: string | null = null; constructor( items: SettingItem[], @@ -74,6 +79,15 @@ export class SettingsList implements Component { } } + /** Move selection to the item with the given id (no-op if not found). */ + selectItem(id: string): void { + const items = this.searchEnabled ? this.filteredItems : this.items; + const index = items.findIndex((i) => i.id === id); + if (index !== -1) { + this.selectedIndex = index; + } + } + invalidate(): void { this.submenuComponent?.invalidate?.(); } @@ -118,7 +132,7 @@ export class SettingsList implements Component { const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); // Calculate max label width for alignment - const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + const maxLabelWidth = Math.min(36, Math.max(...this.items.map((item) => visibleWidth(item.label)))); // Render visible items for (let i = startIndex; i < endIndex; i++) { @@ -202,13 +216,19 @@ export class SettingsList implements Component { if (item.submenu) { // Open submenu, passing current value so it can pre-select correctly this.submenuItemIndex = this.selectedIndex; - this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { - if (selectedValue !== undefined) { - item.currentValue = selectedValue; - this.onChange(item.id, selectedValue); - } - this.closeSubmenu(); - }); + this.submenuComponent = item.submenu( + item.currentValue, + (selectedValue?: string, options?: { navigateTo?: string }) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + if (options?.navigateTo) { + this.navigateAfterClose = options.navigateTo; + } + this.closeSubmenu(); + }, + ); } else if (item.values && item.values.length > 0) { // Cycle through values const currentIndex = item.values.indexOf(item.currentValue); @@ -221,8 +241,15 @@ export class SettingsList implements Component { private closeSubmenu(): void { this.submenuComponent = null; - // Restore selection to the item that opened the submenu - if (this.submenuItemIndex !== null) { + if (this.navigateAfterClose !== null) { + const id = this.navigateAfterClose; + this.navigateAfterClose = null; + this.submenuItemIndex = null; + this.selectItem(id); + // Open the target item's submenu automatically + this.activateItem(); + } else if (this.submenuItemIndex !== null) { + // Restore selection to the item that opened the submenu this.selectedIndex = this.submenuItemIndex; this.submenuItemIndex = null; } diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 3809a48a86f..7a50e272102 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -60,15 +60,16 @@ export class Text implements Component { // Replace tabs with 3 spaces const normalizedText = this.text.replace(/\t/g, " "); - // Calculate content width (subtract left/right margins) - const contentWidth = Math.max(1, width - this.paddingX * 2); + // Reduce margins when necessary so content and padding fit within the available width. + const paddingX = Math.min(this.paddingX, Math.max(0, Math.floor((width - 1) / 2))); + const contentWidth = Math.max(1, width - paddingX * 2); // Wrap text (this preserves ANSI codes but does NOT pad) const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth); // Add margins and background to each line - const leftMargin = " ".repeat(this.paddingX); - const rightMargin = " ".repeat(this.paddingX); + const leftMargin = " ".repeat(paddingX); + const rightMargin = " ".repeat(paddingX); const contentLines: string[] = []; for (const line of wrappedLines) { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 00e9d414f44..0d5a4a1093b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -18,7 +18,12 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; -export { ScrollView, type ScrollViewOptions, type ScrollViewScrollbar } from "./components/scroll-view.ts"; +export { + ScrollView, + type ScrollViewOptions, + type ScrollViewScrollbar, + type ScrollViewScrollToOptions, +} from "./components/scroll-view.ts"; export { type SelectItem, SelectList, diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index d371d4c2fc3..d6afb5ec396 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -44,8 +44,16 @@ export interface Keybindings { // Alternate-screen viewport navigation "tui.altScreen.pageUp": true; "tui.altScreen.pageDown": true; + "tui.altScreen.halfPageUp": true; + "tui.altScreen.halfPageDown": true; + "tui.altScreen.lineUp": true; + "tui.altScreen.lineDown": true; "tui.altScreen.previousPrompt": true; "tui.altScreen.nextPrompt": true; + "tui.altScreen.search": true; + "tui.altScreen.searchNext": true; + "tui.altScreen.searchPrevious": true; + "tui.altScreen.searchClose": true; "tui.altScreen.top": true; "tui.altScreen.bottom": true; } @@ -157,14 +165,46 @@ export const TUI_KEYBINDINGS = { defaultKeys: "pageDown", description: "Scroll viewport down one page", }, + "tui.altScreen.halfPageUp": { + defaultKeys: [], + description: "Scroll viewport up half a page", + }, + "tui.altScreen.halfPageDown": { + defaultKeys: [], + description: "Scroll viewport down half a page", + }, + "tui.altScreen.lineUp": { + defaultKeys: [], + description: "Scroll viewport up one line", + }, + "tui.altScreen.lineDown": { + defaultKeys: [], + description: "Scroll viewport down one line", + }, "tui.altScreen.previousPrompt": { - defaultKeys: "ctrl+shift+up", + defaultKeys: ["ctrl+shift+up", "ctrl+up"], description: "Jump to previous semantic prompt", }, "tui.altScreen.nextPrompt": { - defaultKeys: "ctrl+shift+down", + defaultKeys: ["ctrl+shift+down", "ctrl+down"], description: "Jump to next semantic prompt", }, + "tui.altScreen.search": { + defaultKeys: "ctrl+shift+f", + description: "Search the primary scroll view", + }, + "tui.altScreen.searchNext": { + defaultKeys: ["enter", "ctrl+g"], + description: "Select the next search match", + }, + "tui.altScreen.searchPrevious": { + defaultKeys: ["shift+enter", "ctrl+shift+g"], + description: "Select the previous search match", + }, + "tui.altScreen.searchClose": { + defaultKeys: "escape", + description: "Close transcript search", + }, "tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" }, "tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" }, } as const satisfies KeybindingDefinitions; diff --git a/packages/tui/src/latex.ts b/packages/tui/src/latex.ts index 02be3b7ac33..3170bf596a4 100644 --- a/packages/tui/src/latex.ts +++ b/packages/tui/src/latex.ts @@ -288,6 +288,90 @@ const DISPLAY_LIMIT_SYMBOLS = new Set([ "sum", ]); +const RELATION_COMMANDS = new Set([ + "Leftarrow", + "Leftrightarrow", + "Longleftarrow", + "Longleftrightarrow", + "Longrightarrow", + "Rightarrow", + "Vdash", + "Vvdash", + "approx", + "asymp", + "cong", + "dashv", + "doteq", + "downarrow", + "equiv", + "ge", + "geq", + "geqslant", + "gets", + "gg", + "hookleftarrow", + "hookrightarrow", + "iff", + "implies", + "in", + "leadsto", + "le", + "leftarrow", + "leftharpoondown", + "leftharpoonup", + "leftrightarrow", + "leftrightharpoons", + "leq", + "leqslant", + "ll", + "longleftarrow", + "longleftrightarrow", + "longmapsto", + "longrightarrow", + "mapsto", + "mid", + "models", + "ne", + "nearrow", + "neq", + "ni", + "notin", + "nvdash", + "nvDash", + "nwarrow", + "parallel", + "perp", + "prec", + "preceq", + "propto", + "rightharpoondown", + "rightharpoonup", + "rightleftharpoons", + "rightarrow", + "rightsquigarrow", + "searrow", + "sim", + "simeq", + "sqsubset", + "sqsubseteq", + "sqsupset", + "sqsupseteq", + "subset", + "subseteq", + "succ", + "succeq", + "supset", + "supseteq", + "swarrow", + "to", + "triangleleft", + "triangleright", + "twoheadleftarrow", + "twoheadrightarrow", + "uparrow", + "vdash", +]); + const NEGATED_SYMBOLS: Readonly> = { "<": "≮", ">": "≯", @@ -515,7 +599,7 @@ function replaceCharacters(value: string, replacements: Readonly line.replace(/[ \t]+/g, " ").trim()) .filter((line, index, lines) => line.length > 0 || (index > 0 && index < lines.length - 1)) @@ -562,7 +655,13 @@ interface OperatorNode { upper?: string; } -type LayoutNode = FractionNode | OperatorNode; +interface MatrixNode { + type: "matrix"; + lines: string[]; + baseline: number; +} + +type LayoutNode = FractionNode | OperatorNode | MatrixNode; interface Layout { lines: string[]; @@ -573,7 +672,8 @@ interface Layout { const LAYOUT_MARKER_START = "\u{f0000}"; const LAYOUT_MARKER_END = "\u{f0001}"; const LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}/gu; -const PROTECTED_SPACE = "\u00a0"; +const TRAILING_LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}$/u; +const PROTECTED_SPACE = "\u{f0002}"; function padLayoutLine(line: string, width: number, centered = false): string { const padding = Math.max(0, width - visibleWidth(line)); @@ -612,18 +712,25 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { for (const sourceLine of source.split("\n")) { const layouts: Layout[] = []; let position = 0; - let previousWasNode = false; + let previousNode: LayoutNode | undefined; for (const match of sourceLine.matchAll(LAYOUT_MARKER_PATTERN)) { const index = match.index; - if (index > position) { - const sliced = sourceLine.slice(position, index); - const text = (previousWasNode ? sliced.trimStart() : sliced).trimEnd(); - layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); - } const node = nodes[Number(match[1])]; if (!node) { continue; } + if (index > position) { + const sliced = sourceLine.slice(position, index); + const trimmed = (previousNode ? sliced.trimStart() : sliced).trimEnd(); + const preserveLeadingSpace = previousNode?.type === "matrix" && /^\s/.test(sliced); + const preserveTrailingSpace = node.type === "matrix" && /\s$/.test(sliced); + const text = trimmed + ? `${preserveLeadingSpace ? " " : ""}${trimmed}${preserveTrailingSpace ? " " : ""}` + : preserveLeadingSpace || preserveTrailingSpace + ? " " + : ""; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } if (node.type === "fraction") { const numerator = renderLayout(node.numerator, nodes); const denominator = renderLayout(node.denominator, nodes); @@ -638,7 +745,7 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { width, baseline: numerator.lines.length, }); - } else { + } else if (node.type === "operator") { const contentWidth = Math.max( visibleWidth(node.operator), node.lower === undefined ? 0 : visibleWidth(node.lower), @@ -657,13 +764,21 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { width: contentWidth + 1, baseline: node.upper === undefined ? 0 : 1, }); + } else { + const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line))); + layouts.push({ + lines: node.lines.map((line) => padLayoutLine(line, width)), + width, + baseline: node.baseline, + }); } position = index + match[0].length; - previousWasNode = true; + previousNode = node; } if (position < sourceLine.length) { const sliced = sourceLine.slice(position); - const text = previousWasNode ? sliced.trimStart() : sliced; + const trimmed = previousNode ? sliced.trimStart() : sliced; + const text = previousNode?.type === "matrix" && /^\s/.test(sliced) ? ` ${trimmed}` : trimmed; layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); } const lineLayout = joinLayouts(layouts); @@ -681,14 +796,16 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { class LatexParser { private readonly source: string; - private readonly layoutNodes: LayoutNode[] | undefined; + private readonly layoutNodes: LayoutNode[]; + private readonly display: boolean; private position = 0; private supported = true; private stackFractions = true; - constructor(source: string, layoutNodes?: LayoutNode[]) { + constructor(source: string, layoutNodes: LayoutNode[], display: boolean) { this.source = source; this.layoutNodes = layoutNodes; + this.display = display; } render(): string | undefined { @@ -723,6 +840,9 @@ class LatexParser { const command = this.parseCommand(); if (command === NEGATIVE_SPACE) { result = result.trimEnd(); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = result.slice(0, -NAMED_OPERATOR_END.length); + } } else { result += command; } @@ -732,7 +852,12 @@ class LatexParser { if (character === "^" || character === "_") { this.position++; result = result.trimEnd(); - result += formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`; + } else { + result += script; + } continue; } @@ -741,6 +866,12 @@ class LatexParser { continue; } + if (character === "=" || character === "<" || character === ">") { + result = `${result.trimEnd()} ${character} `; + this.position++; + continue; + } + if (character === "&") { this.position++; continue; @@ -752,6 +883,17 @@ class LatexParser { continue; } + if (character === ".") { + const marker = TRAILING_LAYOUT_MARKER_PATTERN.exec(result); + const node = marker ? this.layoutNodes[Number(marker[1])] : undefined; + if (node?.type === "matrix") { + const lastLine = node.lines.length - 1; + node.lines[lastLine] = `${node.lines[lastLine] ?? ""}${character}`; + this.position++; + continue; + } + } + result += character; this.position++; } @@ -778,6 +920,13 @@ class LatexParser { let command = ""; const first = this.source[this.position] ?? ""; + if (first === "\n" || first === "\r") { + this.position++; + if (first === "\r" && this.source[this.position] === "\n") { + this.position++; + } + return " "; + } if (/[A-Za-z]/.test(first)) { const start = this.position; while (this.position < this.source.length && /[A-Za-z]/.test(this.source[this.position] ?? "")) { @@ -819,14 +968,14 @@ class LatexParser { const value = this.parseRequiredArgument(false).trim(); const negated = NEGATED_SYMBOLS[value]; if (negated !== undefined) { - return negated; + return ` ${negated} `; } const characters = Array.from(value); if (characters.length === 0) { this.supported = false; return ""; } - return `${characters[0]}\u0338${characters.slice(1).join("")}`; + return ` ${characters[0]}\u0338${characters.slice(1).join("")} `; } if (LIMIT_OPERATORS.has(command)) { return this.parseOperator(command, "bracket", true, true); @@ -834,10 +983,13 @@ class LatexParser { const symbol = SYMBOLS[command]; if (symbol !== undefined) { - return DISPLAY_LIMIT_SYMBOLS.has(command) ? this.parseOperator(symbol, "script", true) : symbol; + if (DISPLAY_LIMIT_SYMBOLS.has(command)) { + return this.parseOperator(symbol, "script", true); + } + return command === "cdot" || command === "times" || RELATION_COMMANDS.has(command) ? ` ${symbol} ` : symbol; } if (NAMED_OPERATORS.has(command)) { - return ` ${command} `; + return `${NAMED_OPERATOR_START}${command}${NAMED_OPERATOR_END}`; } if (SIZE_COMMANDS.has(command)) { return ""; @@ -849,10 +1001,10 @@ class LatexParser { return ""; } if (command === "frac" || command === "dfrac" || command === "tfrac") { - const shouldStack = this.layoutNodes !== undefined && this.stackFractions && command !== "tfrac"; + const shouldStack = this.display && this.stackFractions && command !== "tfrac"; const numerator = this.parseRequiredArgument(!shouldStack); const denominator = this.parseRequiredArgument(!shouldStack); - if (shouldStack && this.layoutNodes) { + if (shouldStack) { const index = this.layoutNodes.push({ type: "fraction", @@ -976,7 +1128,7 @@ class LatexParser { } } - if (this.layoutNodes && useDisplayLimits && (lower !== undefined || upper !== undefined)) { + if (this.display && useDisplayLimits && (lower !== undefined || upper !== undefined)) { const index = this.layoutNodes.push({ type: "operator", operator, lower, upper }) - 1; return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; } @@ -1000,7 +1152,7 @@ class LatexParser { } private parseRequiredArgumentValue(): string { - while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { this.position++; } if (this.position >= this.source.length) { @@ -1157,36 +1309,39 @@ class LatexParser { return `${cell}${PROTECTED_SPACE.repeat(Math.max(0, (columnWidths[column] ?? 0) - visibleWidth(cell)))}`; }).join(" │ "), ); - if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { - return rows.join("\n"); - } - const delimiters: Readonly> = { - pmatrix: ["⎛", "⎞", "⎜", "⎟", "⎝", "⎠"], - bmatrix: ["⎡", "⎤", "⎢", "⎥", "⎣", "⎦"], - Bmatrix: ["⎧", "⎫", "⎨", "⎬", "⎩", "⎭"], - vmatrix: ["│", "│", "│", "│", "│", "│"], - Vmatrix: ["║", "║", "║", "║", "║", "║"], - }; - const delimiter = delimiters[environment]; - if (!delimiter) { - this.supported = false; - return rows.join("\n"); - } - if (rows.length === 1) { - return `${delimiter[0]} ${rows[0]} ${delimiter[1]}`; - } - return rows - .map((row, index) => { + let lines: string[]; + if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { + lines = rows; + } else { + const delimiters: Readonly> = { + pmatrix: ["⎛", "⎞", "⎜", "⎟", "⎝", "⎠"], + bmatrix: ["⎡", "⎤", "⎢", "⎥", "⎣", "⎦"], + Bmatrix: ["⎧", "⎫", "⎨", "⎬", "⎩", "⎭"], + vmatrix: ["│", "│", "│", "│", "│", "│"], + Vmatrix: ["║", "║", "║", "║", "║", "║"], + }; + const delimiter = delimiters[environment]; + if (!delimiter) { + this.supported = false; + return rows.join("\n"); + } + lines = rows.map((row, index) => { const left = index === 0 ? delimiter[0] : index === rows.length - 1 ? delimiter[4] : delimiter[2]; const right = index === 0 ? delimiter[1] : index === rows.length - 1 ? delimiter[5] : delimiter[3]; return `${left} ${row} ${right}`; - }) - .join("\n"); + }); + } + + if (lines.length <= 1) { + return lines[0] ?? ""; + } + const index = this.layoutNodes.push({ type: "matrix", lines, baseline: 0 }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; } private renderNested(source: string, stackFractions = true): string { - const rendered = new LatexParser(source, stackFractions ? this.layoutNodes : undefined).render(); + const rendered = new LatexParser(source, this.layoutNodes, this.display && stackFractions).render(); if (rendered === undefined) { this.supported = false; return source; @@ -1205,12 +1360,12 @@ export interface RenderLatexOptions { * Returns undefined when the expression contains unsupported or malformed syntax. */ export function renderLatex(source: string, options: RenderLatexOptions = {}): string | undefined { - const layoutNodes: LayoutNode[] | undefined = options.display ? [] : undefined; - const rendered = new LatexParser(source, layoutNodes).render(); + const layoutNodes: LayoutNode[] = []; + const rendered = new LatexParser(source, layoutNodes, options.display === true).render(); if (rendered === undefined) { return undefined; } - if (!layoutNodes || layoutNodes.length === 0) { + if (layoutNodes.length === 0) { return rendered.replaceAll(PROTECTED_SPACE, " "); } const lines = renderLayout(rendered, layoutNodes).lines; diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index a139a2fff81..51f738ecded 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -316,8 +316,16 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { const visibleRows = Math.min(imageMetadata.rows, clipBottom - row); if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows); } - if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line; - else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + // Fast path: a full-width box painting onto an untouched row can use the + // source line reference directly. Compositing here would rebuild the row + // string through ANSI/grapheme segmentation every frame; padding is + // unnecessary because rows are written with erase-line and the final + // width clamp still truncates over-wide lines. + if (box.rect.x === 0 && box.rect.width >= totalWidth && (isImageLine(line) || !screen[row])) { + screen[row] = line; + } else { + screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + } } } for (const child of box.children) paintBox(child, screen, totalWidth); diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts index 549ce47a93f..88684cc10f2 100644 --- a/packages/tui/src/native-modifiers.ts +++ b/packages/tui/src/native-modifiers.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { getNativeModuleCandidates } from "./native-module-path.ts"; const cjsRequire = createRequire(import.meta.url); @@ -33,14 +33,7 @@ function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { return undefined; } - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ - path.join(moduleDir, "..", nativePath), - path.join(moduleDir, nativePath), - path.join(path.dirname(process.execPath), nativePath), - ]; - - for (const modulePath of candidates) { + for (const modulePath of getNativeModuleCandidates(nativePath)) { try { const helper = cjsRequire(modulePath) as unknown; if (isNativeModifiersHelper(helper)) { diff --git a/packages/tui/src/native-module-path.ts b/packages/tui/src/native-module-path.ts new file mode 100644 index 00000000000..b75117feb73 --- /dev/null +++ b/packages/tui/src/native-module-path.ts @@ -0,0 +1,31 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const moduleRequire = createRequire(import.meta.url); +const TUI_PACKAGE_NAME = "@earendil-works/pi-tui"; + +export interface NativeModuleCandidateOptions { + moduleUrl?: string; + execPath?: string; + resolvePackage?: (specifier: string) => string; +} + +export function getNativeModuleCandidates(nativePath: string, options: NativeModuleCandidateOptions = {}): string[] { + const moduleDir = dirname(fileURLToPath(options.moduleUrl ?? import.meta.url)); + const candidates: string[] = []; + + try { + const packageEntry = (options.resolvePackage ?? moduleRequire.resolve)(TUI_PACKAGE_NAME); + candidates.push(join(dirname(packageEntry), "..", nativePath)); + } catch { + // Standalone binaries do not have an installed TUI package. + } + + candidates.push( + join(moduleDir, "..", nativePath), + join(moduleDir, nativePath), + join(dirname(options.execPath ?? process.execPath), nativePath), + ); + return Array.from(new Set(candidates)); +} diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index a8b0b847892..cc10c61a551 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -20,6 +20,8 @@ import { EventEmitter } from "events"; const ESC = "\x1b"; +const DEFAULT_SEQUENCE_TIMEOUT_MS = 50; +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -256,10 +258,15 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for sequence completion (default: 10ms) - * After this time, the buffer is flushed even if incomplete + * Maximum time to wait for an incomplete sequence such as CSI or mouse + * (default: 50ms). */ timeout?: number; + /** + * Maximum time to wait after a lone ESC before treating it as Escape + * (default: 10ms). Increase for high-latency Alt+key input (SSH). + */ + escapeTimeout?: number; }; export type StdinBufferEventMap = { @@ -275,13 +282,15 @@ export class StdinBuffer extends EventEmitter { private buffer: string = ""; private timeout: ReturnType | null = null; private readonly timeoutMs: number; + private readonly escapeTimeoutMs: number; private pasteMode: boolean = false; private pasteBuffer: string = ""; private pendingKittyPrintableCodepoint: number | undefined; constructor(options: StdinBufferOptions = {}) { super(); - this.timeoutMs = options.timeout ?? 10; + this.timeoutMs = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT_MS; + this.escapeTimeoutMs = options.escapeTimeout ?? DEFAULT_ESCAPE_TIMEOUT_MS; } public process(data: string | Buffer): void { @@ -376,13 +385,14 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { + const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); for (const sequence of flushed) { this.emitDataSequence(sequence); } - }, this.timeoutMs); + }, timeoutMs); } } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 1014b80fcc3..f858bf9f252 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,9 +1,9 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; import { setKittyProtocolActive } from "./keys.ts"; import { isNativeModifierPressed } from "./native-modifiers.ts"; +import { getNativeModuleCandidates } from "./native-module-path.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const cjsRequire = createRequire(import.meta.url); @@ -101,6 +101,25 @@ export interface Terminal { setProgress(active: boolean): void; } +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; +const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100; + +/** + * Resolve how long to wait for the rest of an escape sequence before + * dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus + * another byte, so high-latency transports need a longer reassembly window. + */ +export function resolveEscapeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const configured = Number(env.PI_TUI_ESC_TIMEOUT); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + if (env.SSH_CONNECTION || env.SSH_TTY) { + return DEFAULT_SSH_ESCAPE_TIMEOUT_MS; + } + return DEFAULT_ESCAPE_TIMEOUT_MS; +} + /** * Real terminal using process.stdin/stdout */ @@ -183,7 +202,7 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + this.stdinBuffer = new StdinBuffer({ escapeTimeout: resolveEscapeTimeoutMs() }); // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { @@ -351,16 +370,10 @@ export class ProcessTerminal implements Terminal { if (arch !== "x64" && arch !== "arm64") return; // Dynamic require so non-Windows and bundled/browser paths never load the - // native helper. In the npm package native/ is next to dist/; in compiled - // binary archives native/ is copied next to the executable. - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + // native helper. Installed packages resolve it from pi-tui; standalone + // binaries resolve the copy next to the executable. const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); - const candidates = [ - path.join(moduleDir, "..", nativePath), - path.join(moduleDir, nativePath), - path.join(path.dirname(process.execPath), nativePath), - ]; - for (const modulePath of candidates) { + for (const modulePath of getNativeModuleCandidates(nativePath)) { try { const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean }; helper.enableVirtualTerminalInput?.(); diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index aa062e4f546..855fa0f710c 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -1,3 +1,9 @@ +import { + AltScreenSearchComponent, + type AltScreenSearchMatch, + findAltScreenSearchMatches, + getAltScreenSearchMatchKey, +} from "./alt-screen-search.ts"; import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts"; import { ScrollView } from "./components/scroll-view.ts"; import { getKeybindings } from "./keybindings.ts"; @@ -26,6 +32,7 @@ import { type Component, CURSOR_MARKER, compositeTuiLine, + type OverlayHandle, TuiBase, type TuiStopOptions, VIEWPORT_TUI, @@ -35,6 +42,7 @@ import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, + getWordSegmenter, sliceByColumn, stripTerminalSequences, visibleWidth, @@ -44,7 +52,8 @@ const ENTER_ALT_SCREEN = "\x1b[?1049h"; const EXIT_ALT_SCREEN = "\x1b[?1049l"; const DISABLE_AUTOWRAP = "\x1b[?7l"; const ENABLE_AUTOWRAP = "\x1b[?7h"; -const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; +const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h"; +const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; const FOCUS_IN = "\x1b[I"; const FOCUS_OUT = "\x1b[O"; @@ -56,6 +65,8 @@ const PAGE_SCROLL_OVERLAP = 4; const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16; const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024; const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024; +const DOUBLE_CLICK_INTERVAL_MS = 500; +const wordSegmenter = getWordSegmenter(); interface CachedKittyImage { transmissionGeneration: number; @@ -67,6 +78,24 @@ interface SelectionPoint { row: number; col: number; scrollView?: ScrollView; + /** Whether this point lies between terminal cells rather than on a cell. */ + boundary?: boolean; +} + +interface SelectionRange { + start: SelectionPoint; + end: SelectionPoint; +} + +type SelectionGranularity = "character" | "word" | "line"; + +interface ClickTarget { + timestamp: number; + count: number; + row: number; + scrollView?: ScrollView; + wordStart: number; + wordEnd: number; } interface SgrMouseEvent { @@ -92,13 +121,43 @@ interface ScrollbarTarget { geometry: ScrollbarGeometry; } +type SearchSelectionMode = "query" | "retain" | "next" | "previous"; + +interface ActiveSearch { + component: AltScreenSearchComponent; + overlay?: OverlayHandle; + query: string; + matches: AltScreenSearchMatch[]; + selectedIndex: number; + selectedKey?: string; + anchorRow: number; + selectionMode: SearchSelectionMode; +} + +interface SearchHighlightRange { + startCol: number; + endCol: number; + current: boolean; +} + export interface TuiAltScreenOptions { /** Number of logical lines moved for each mouse-wheel event. */ wheelScrollLines?: number; /** Capture mouse events for viewport scrolling and application-owned text selection. */ mouse?: boolean; + /** Style a non-current transcript search match. */ + searchMatchStyle?: (text: string) => string; + /** Style the current transcript search match. */ + searchCurrentMatchStyle?: (text: string) => string; /** Open an OSC 8 hyperlink activated with a primary-button click. */ openUrl?: (url: string) => void; + /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ + onRightClickPaste?: () => void; + /** + * Copy selected text to the system clipboard. Return `true` on success; the caller flashes + * an error otherwise. When omitted, the selection is copied via an OSC 52 write. + */ + copySelection?: (text: string) => Promise; } /** Alternate-screen TUI with a scrollable, application-owned viewport. */ @@ -120,17 +179,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly uploadedKittyImages = new Map(); private selectionAnchor?: SelectionPoint; private selectionFocus?: SelectionPoint; + private selectionGranularity: SelectionGranularity = "character"; + private selectionInitialRange?: SelectionRange; + private lastClick?: ClickTarget; private selectionDragPointer?: { x: number; y: number }; private selectionAutoScrollDirection: -1 | 0 | 1 = 0; private selectionAutoScrollTimer?: NodeJS.Timeout; private selectionPressActive = false; private scrollbarDrag?: ScrollbarDrag; private scrollbarHover?: ScrollView; + private activeSearch?: ActiveSearch; private pressedUrl?: string; private selectionDragged = false; private readonly wheelScrollLines: number; private readonly mouseEnabled: boolean; + private readonly searchMatchStyle: (text: string) => string; + private readonly searchCurrentMatchStyle: (text: string) => string; private readonly openUrl?: (url: string) => void; + private readonly onRightClickPaste?: () => void; + private readonly copySelection?: (text: string) => Promise; constructor( terminal: Terminal, @@ -149,7 +216,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flashes = new AltScreenFlashContainer(() => this.requestRender()); this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); this.mouseEnabled = options.mouse ?? true; + this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`); + this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); this.openUrl = options.openUrl; + this.onRightClickPaste = options.onRightClickPaste; + this.copySelection = options.copySelection; this.addInputListener((data) => this.handleViewportInput(data)); } @@ -198,15 +269,30 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.lastDocument = []; this.selectionAnchor = undefined; this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + this.lastClick = undefined; this.pressedUrl = undefined; this.selectionDragged = false; this.resetRenderState(); + const term = process.env.TERM?.toLowerCase() ?? ""; + // Multiplexers can lag when every pointer movement is forwarded. Button-motion + // tracking preserves clicks, wheel events, selections, and scrollbar dragging. + const mouseSequence = + process.env.TMUX !== undefined || + process.env.ZELLIJ !== undefined || + process.env.STY !== undefined || + term.startsWith("tmux") || + term.startsWith("screen") + ? ENABLE_BUTTON_MOTION_MOUSE + : ENABLE_ALL_MOTION_MOUSE; this.terminal.write( - `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? ENABLE_MOUSE : ""}\x1b[2J\x1b[H\x1b[?25l`, + `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`, ); } protected override beforeTerminalStop(_options: TuiStopOptions): void { + this.closeSearch(); this.stopSelectionAutoScroll(); this.selectionPressActive = false; this.stopScrollbarHover(); @@ -334,14 +420,126 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } } + private openSearch(): void { + if (this.activeSearch) { + this.activeSearch.overlay?.focus(); + return; + } + const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query)); + const search: ActiveSearch = { + component, + query: "", + matches: [], + selectedIndex: -1, + anchorRow: this.getPrimaryScrollView().scrollTop, + selectionMode: "query", + }; + this.activeSearch = search; + search.overlay = this.showOverlay(component, { + anchor: "top-right", + width: "40%", + minWidth: 24, + margin: 1, + }); + } + + private closeSearch(): void { + const search = this.activeSearch; + if (!search) return; + this.activeSearch = undefined; + search.overlay?.hide(); + this.requestRender(); + } + + private updateSearchQuery(query: string): void { + const search = this.activeSearch; + if (!search || query === search.query) return; + const selected = search.matches[search.selectedIndex]; + search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop; + search.query = query; + search.selectionMode = "query"; + search.component.setResult(-1, 0); + this.requestRender(); + } + + private navigateSearch(direction: -1 | 1): void { + const search = this.activeSearch; + if (!search?.query) return; + search.selectionMode = direction < 0 ? "previous" : "next"; + this.requestRender(); + } + + private refreshSearch(layout: LayoutFrame): boolean { + const search = this.activeSearch; + if (!search) return false; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + const lines = box?.scrollContentLines; + if (!lines || !search.query.trim()) { + search.matches = []; + search.selectedIndex = -1; + search.selectedKey = undefined; + search.selectionMode = "retain"; + search.component.setResult(-1, 0); + return false; + } + + const shouldRevealSelection = search.selectionMode !== "retain"; + const matches = findAltScreenSearchMatches(lines, search.query); + const exactIndex = search.selectedKey + ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey) + : -1; + let selectedIndex = -1; + if (matches.length > 0) { + if (search.selectionMode === "query") { + selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow); + if (selectedIndex < 0) selectedIndex = 0; + } else if (search.selectionMode === "next") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length; + } else if (search.selectionMode === "previous") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length; + } else { + selectedIndex = + exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1); + } + } + + search.matches = matches; + search.selectedIndex = selectedIndex; + search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]!) : undefined; + search.selectionMode = "retain"; + search.component.setResult(selectedIndex, matches.length); + if (!shouldRevealSelection) return false; + + const selected = matches[selectedIndex]; + const firstSegment = selected?.segments[0]; + const lastSegment = selected?.segments[selected.segments.length - 1]; + if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0) return false; + const before = scrollView.scrollTop; + const visibleBottom = before + scrollView.viewportHeight - 1; + let target = before; + if (firstSegment.row < before || lastSegment.row > visibleBottom) { + target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3); + } + scrollView.scrollTo(target, { disableFollow: true }); + return scrollView.scrollTop !== before; + } + /** Show a transient message in the alternate-screen flash stack. */ flash(message: string, durationMs?: number): void { this.flashes.flash(message, durationMs); } + private shouldDeferViewportInputToOverlay(): boolean { + return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true; + } + private handleViewportInput(data: string): { consume?: boolean } | undefined { if (data === FOCUS_OUT) { const hadActiveSelection = this.selectionPressActive; + const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined; this.selectionPressActive = false; this.stopSelectionAutoScroll(); this.stopScrollbarHover(); @@ -351,19 +549,24 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (hadActiveSelection) { this.selectionAnchor = undefined; this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + if (hadNonEmptyActiveSelection) this.requestRender(); } - this.requestRender(); + this.lastClick = undefined; return { consume: true }; } if (data === FOCUS_IN) return { consume: true }; const wheelEvent = this.parseWheelEvent(data); if (wheelEvent) { + if (this.shouldDeferViewportInputToOverlay()) return undefined; this.routeWheel(wheelEvent); return { consume: true }; } const mouseEvent = this.parseSgrMouseEvent(data); if (mouseEvent) { + if (this.handleRightClickPaste(mouseEvent)) return { consume: true }; const handled = this.handleScrollbarMouseEvent(mouseEvent); if (!this.scrollbarDrag) this.updateScrollbarHover(mouseEvent.x, mouseEvent.y); if (!handled) this.handleSelectionMouseEvent(mouseEvent); @@ -373,6 +576,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const keybindings = getKeybindings(); const isRelease = isKeyRelease(data); + if (keybindings.matches(data, "tui.altScreen.search")) { + if (!isRelease) this.openSearch(); + return { consume: true }; + } + if (this.activeSearch?.overlay?.isFocused()) { + if (keybindings.matches(data, "tui.altScreen.searchNext")) { + if (!isRelease) this.navigateSearch(1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchPrevious")) { + if (!isRelease) this.navigateSearch(-1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchClose")) { + if (!isRelease) this.closeSearch(); + return { consume: true }; + } + } + if (this.shouldDeferViewportInputToOverlay()) return undefined; if (keybindings.matches(data, "tui.altScreen.pageUp")) { if (!isRelease) { this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); @@ -385,6 +607,22 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } return { consume: true }; } + if (keybindings.matches(data, "tui.altScreen.halfPageUp")) { + if (!isRelease) this.scrollBy(-Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.halfPageDown")) { + if (!isRelease) this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.lineUp")) { + if (!isRelease) this.scrollBy(-1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.lineDown")) { + if (!isRelease) this.scrollBy(1); + return { consume: true }; + } if (keybindings.matches(data, "tui.altScreen.previousPrompt")) { if (!isRelease) this.scrollToPrompt(-1); return { consume: true }; @@ -456,6 +694,24 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; } + private handleRightClickPaste(event: SgrMouseEvent): boolean { + if ( + !this.onRightClickPaste || + process.platform !== "win32" || + process.env.TERM_PROGRAM?.toLowerCase() === "vscode" || + event.release || + event.button !== 2 + ) { + return false; + } + try { + this.onRightClickPaste(); + } catch { + // Clipboard paste is best-effort. + } + return true; + } + private getScrollbarTargetAt(x: number, y: number): ScrollbarTarget | undefined { if (this.hasOverlay() || !this.currentLayout) return undefined; for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { @@ -518,6 +774,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionPressActive = false; this.selectionAnchor = undefined; this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + this.lastClick = undefined; this.pressedUrl = undefined; this.selectionDragged = false; this.setScrollbarHover(target.scrollView); @@ -563,6 +822,83 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; } + private getSelectionSourceLine(point: SelectionPoint): string { + if (point.scrollView && this.currentLayout) { + const lines = getScrollViewBox(this.currentLayout, point.scrollView)?.scrollContentLines; + if (lines) return lines[point.row] ?? ""; + } + return this.previousScreen[point.row] ?? ""; + } + + private getWordSelection(point: SelectionPoint): SelectionRange | undefined { + const line = stripTerminalSequences(this.getSelectionSourceLine(point)); + let start = 0; + for (const segment of wordSegmenter.segment(line)) { + const end = start + visibleWidth(segment.segment); + if (point.col >= start && point.col < end) { + return { + start: { ...point, col: start }, + end: { ...point, col: end, boundary: true }, + }; + } + start = end; + } + return undefined; + } + + private getLineSelection(point: SelectionPoint): SelectionRange { + return { + start: { ...point, col: 0 }, + end: { ...point, col: visibleWidth(this.getSelectionSourceLine(point)), boundary: true }, + }; + } + + private updateSelectionFocus(point: SelectionPoint): void { + if (this.selectionGranularity === "character" || !this.selectionInitialRange) { + this.selectionFocus = point; + return; + } + const range = this.selectionGranularity === "word" ? this.getWordSelection(point) : this.getLineSelection(point); + if (!range) return; + const initial = this.selectionInitialRange; + const targetBeforeInitial = + range.start.row < initial.start.row || + (range.start.row === initial.start.row && range.start.col < initial.start.col); + if (targetBeforeInitial) { + this.selectionAnchor = initial.end; + this.selectionFocus = range.start; + } else { + this.selectionAnchor = initial.start; + this.selectionFocus = range.end; + } + } + + private getClickCount(point: SelectionPoint, word: SelectionRange | undefined): number { + const now = Date.now(); + const previous = this.lastClick; + const count = + word && + previous && + now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS && + previous.row === point.row && + previous.scrollView === point.scrollView && + previous.wordStart === word.start.col && + previous.wordEnd === word.end.col + ? (previous.count % 3) + 1 + : 1; + this.lastClick = word + ? { + timestamp: now, + count, + row: point.row, + scrollView: point.scrollView, + wordStart: word.start.col, + wordEnd: word.end.col, + } + : undefined; + return count; + } + private updateSelectionAutoScroll(event: SgrMouseEvent): void { const scrollView = this.selectionAnchor?.scrollView; if (!scrollView || !this.currentLayout) { @@ -605,7 +941,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return; } const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y); - if (point) this.selectionFocus = point; + if (point) this.updateSelectionFocus(point); this.requestRender(); } @@ -619,7 +955,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private handleSelectionMouseEvent(event: SgrMouseEvent): void { - if ((event.button & 3) !== 0) return; + const button = event.button & 3; + if (button !== 0 && !(event.release && button === 3)) return; const anchorScrollView = this.selectionAnchor?.scrollView; const point = this.getSelectionPoint(event, anchorScrollView); if (event.release) { @@ -627,7 +964,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionPressActive = false; this.stopSelectionAutoScroll(); if (!this.selectionAnchor) return; - this.selectionFocus = point; + this.updateSelectionFocus(point); const clickedUrl = !this.selectionDragged && this.selectionAnchor.scrollView === point.scrollView && @@ -647,15 +984,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.requestRender(); return; } - this.copySelectionToClipboard(); + void this.copySelectionToClipboard(); this.requestRender(); return; } if ((event.button & 32) !== 0) { if (!this.selectionPressActive || !this.selectionAnchor) return; this.selectionDragged = true; + this.lastClick = undefined; this.pressedUrl = undefined; - this.selectionFocus = point; + this.updateSelectionFocus(point); this.updateSelectionAutoScroll(event); this.requestRender(); return; @@ -667,13 +1005,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { ? getScrollViewsAt(this.currentLayout, event.x, event.y)[0] : undefined; const anchor = this.getSelectionPoint(event, scrollView); - this.selectionAnchor = anchor; - this.selectionFocus = anchor; + const word = this.getWordSelection(anchor); + const clickCount = this.getClickCount(anchor, word); + const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined; + this.selectionGranularity = range ? (clickCount === 2 ? "word" : "line") : "character"; + this.selectionInitialRange = range; + this.selectionAnchor = range?.start ?? anchor; + this.selectionFocus = range?.end ?? anchor; this.selectionDragged = false; - this.pressedUrl = getOsc8LinkAtColumn( - this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", - Math.max(0, Math.min(this.terminal.columns - 1, event.x)), - ); + this.pressedUrl = range + ? undefined + : getOsc8LinkAtColumn( + this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", + Math.max(0, Math.min(this.terminal.columns - 1, event.x)), + ); this.requestRender(); } @@ -708,12 +1053,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth); } if (row === selection.end.row) { - end = getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth); + end = selection.end.boundary + ? Math.min(selection.end.col, lineWidth) + : (getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth)); } return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) }; } - private copySelectionToClipboard(): void { + private async copySelectionToClipboard(): Promise { const selection = this.getSelectionBounds(); if (!selection) return; let sourceLines: readonly string[] = this.previousScreen; @@ -735,10 +1082,89 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } const text = lines.join("\n"); if (text.length === 0) return; + // Prefer an injected clipboard implementation (native clipboard + platform tools with a + // verified success path) when the host app provides one. A bare OSC 52 write can show + // "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux + // without OSC 52 clipboard passthrough), so only report success when it actually copies. + if (this.copySelection) { + const ok = await this.copySelection(text); + this.flash(ok ? "Copied!" : "Copy failed"); + return; + } this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); this.flash("Copied!"); } + private applySearchTextHighlight(text: string, current: boolean): string { + const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle; + let result = ""; + let plainStart = 0; + let index = 0; + while (index < text.length) { + const ansi = extractAnsiCode(text, index); + if (!ansi) { + index += 1; + continue; + } + if (index > plainStart) result += style(text.slice(plainStart, index)); + result += ansi.code; + index += ansi.length; + plainStart = index; + } + if (plainStart < text.length) result += style(text.slice(plainStart)); + return result; + } + + private applySearchHighlights(screen: string[], layout: LayoutFrame): string[] { + const search = this.activeSearch; + if (!search || search.selectedIndex < 0 || search.matches.length === 0) return screen; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + if (!box) return screen; + + const rangesByRow = new Map(); + const scrollbarColumn = getScrollbarGeometry(box)?.column; + const minRow = Math.max(0, box.rect.y, box.clip.y); + const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height); + const minColumn = Math.max(0, box.rect.x, box.clip.x); + const maxColumn = Math.min( + this.terminal.columns, + box.rect.x + box.rect.width, + box.clip.x + box.clip.width, + scrollbarColumn ?? Number.POSITIVE_INFINITY, + ); + for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) { + for (const segment of search.matches[matchIndex]!.segments) { + const row = box.rect.y + segment.row - scrollView.scrollTop; + if (row < minRow || row >= maxRow) continue; + const startCol = Math.max(minColumn, box.rect.x + segment.startCol); + const endCol = Math.min(maxColumn, box.rect.x + segment.endCol); + if (endCol <= startCol) continue; + const ranges = rangesByRow.get(row) ?? []; + ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex }); + rangesByRow.set(row, ranges); + } + } + + const result = [...screen]; + for (const [row, ranges] of rangesByRow) { + let line = result[row] ?? ""; + if (isImageLine(line)) continue; + const lineWidth = visibleWidth(line); + for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) { + const startCol = Math.min(range.startCol, lineWidth); + const endCol = Math.min(range.endCol, lineWidth); + if (endCol <= startCol) continue; + const before = sliceByColumn(line, 0, startCol, true); + const highlighted = sliceByColumn(line, startCol, endCol - startCol, true); + const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true); + line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`; + } + result[row] = line; + } + return result; + } + private applySelectionHighlight(text: string): string { let result = "\x1b[7m"; let index = 0; @@ -774,14 +1200,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width); screenSelection = { start: { + ...selection.start, row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop, col: box.rect.x + selection.start.col, - scrollView: selection.start.scrollView, }, end: { + ...selection.end, row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop, col: box.rect.x + selection.end.col, - scrollView: selection.start.scrollView, }, }; } @@ -828,8 +1254,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const width = Math.max(1, this.terminal.columns); const height = Math.max(1, this.terminal.rows); const root = this.layoutRoot ?? this.implicitScrollView; - const nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + if (this.refreshSearch(nextLayout)) { + nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + } let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + screen = this.applySearchHighlights(screen, nextLayout); screen = this.compositeOverlays(screen, width, height); if (screen.length > height) screen = screen.slice(screen.length - height); screen = this.applySelection(screen, nextLayout); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5242fa72b68..5172a9a142c 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -662,6 +662,13 @@ export abstract class TuiBase extends Container implements TUI { return this.overlayStack.some((o) => this.isOverlayVisible(o)); } + /** Check if the focused component is a visible overlay */ + protected isOverlayFocused(): boolean { + return this.overlayStack.some( + (entry) => entry.component === this.focusedComponent && this.isOverlayVisible(entry), + ); + } + /** Check if an overlay entry is currently visible */ private isOverlayVisible(entry: OverlayStackEntry): boolean { if (entry.hidden) return false; diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index 811a121797b..b0c827dde18 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -32,8 +32,16 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageUp"), ["pageUp"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]); - assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); - assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageUp"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineUp"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineDown"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up", "ctrl+up"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down", "ctrl+down"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]); }); diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index d6d65e5f2d6..609bb0d64f8 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -37,7 +37,7 @@ describe("renderLatex", () => { ["G = u^2 z + y^2(4+3xy)", "G = u² z + y²(4+3xy)"], ["F_1 = uG", "F₁ = uG"], ["F_2 = y + 3xG", "F₂ = y + 3xG"], - ["x=0", "x=0"], + ["x=0", "x = 0"], ["F_2 = F_3 = 0", "F₂ = F₃ = 0"], ["xy = -3/2", "xy = -3/2"], ["x^2 z = 13/2", "x² z = 13/2"], @@ -94,7 +94,7 @@ describe("renderLatex", () => { defineCases([ [ String.raw`\det\!\left(\frac{\partial(F_1,F_2,F_3)}{\partial(x,y,z)}\right)=-2.`, - "det((∂(F₁,F₂,F₃))/(∂(x,y,z)))=-2.", + "det((∂(F₁,F₂,F₃))/(∂(x,y,z))) = -2.", ], [ String.raw`\begin{aligned} @@ -102,9 +102,9 @@ F(0,0,-\tfrac14)&=(-\tfrac14,0,0),\\ F(1,-\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0),\\ F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0). \end{aligned}`, - "F(0,0,-1/4)=(-1/4,0,0),\nF(1,-3/2,13/2)=(-1/4,0,0),\nF(-1,3/2,13/2)=(-1/4,0,0).", + "F(0,0,-1/4) = (-1/4,0,0),\nF(1,-3/2,13/2) = (-1/4,0,0),\nF(-1,3/2,13/2) = (-1/4,0,0).", ], - ["F=(F_1,F_2,F_3)", "F=(F₁,F₂,F₃)"], + ["F=(F_1,F_2,F_3)", "F = (F₁,F₂,F₃)"], ["F", "F"], ["3", "3"], ]); @@ -118,7 +118,7 @@ F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0). \frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y} & \frac{\partial f_2}{\partial z} \\ \frac{\partial f_3}{\partial x} & \frac{\partial f_3}{\partial y} & \frac{\partial f_3}{\partial z} \end{pmatrix}`, - "J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠", + "J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n ⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n ⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠", ], [ String.raw`\begin{aligned} @@ -166,7 +166,7 @@ f_3 = x\,(2 - 3u - t) describe("extended formulas from a renderer stress-test session", () => { defineCases([ - [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1=0"], + [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1 = 0"], [ String.raw`\boxed{ \mathcal{Z}(\beta) @@ -193,7 +193,7 @@ R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu} &= \frac{8\pi G}{c^4}T_{\mu\nu}. \end{aligned}`, - "∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) =0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).", + "∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) = 0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).", ], [ String.raw`f(z) @@ -208,7 +208,11 @@ R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu} 0 & -f & \lambda-g \end{pmatrix} =0.`, - "f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞\n⎜ -c │ λ-d │ -e ⎟\n⎝ 0 │ -f │ λ-g ⎠ =0.", + [ + "f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞ = 0.", + `${" ".repeat(40)}⎜ -c │ λ-d │ -e ⎟`, + `${" ".repeat(40)}⎝ 0 │ -f │ λ-g ⎠`, + ].join("\n"), ], [ String.raw`\Psi(x,t)= @@ -226,27 +230,27 @@ c_n \Psi^\ast\Psi, & 0 { @@ -315,12 +319,12 @@ c_n renderLatex(String.raw`\lvert{x}\rvert+\lVert{v}\rVert+\left.\frac{dy}{dx}\right|_{x=0}`), "|x|+‖v‖+dy/(dx)|ₓ₌₀", ); - assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x>0 }"); + assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x > 0 }"); }); it("renders named, modular, overlaid, and underlaid operators", () => { assert.strictEqual(renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`), "arg max[x∈X] f(x)"); - assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a≡ b (mod n)"); + assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a ≡ b (mod n)"); assert.strictEqual(renderLatex(String.raw`\overset{!}{=}+\underset{n}{x}+\stackrel{def}{=}`), "=^!+xₙ+=ᵈᵉᶠ"); }); @@ -339,18 +343,18 @@ c_n it("renders additional display environments", () => { assert.strictEqual( renderLatex(String.raw`\begin{equation}\begin{split}a&=b\\&=c\end{split}\end{equation}`), - "a=b\n=c", + "a = b\n= c", ); assert.strictEqual( renderLatex(String.raw`\begin{alignedat}{2}a&=b&\quad c&=d\\e&=f&g&=h\end{alignedat}`), - "a=b c=d\ne=f g=h", + "a = b c = d\ne = f g = h", ); }); it("uses natural case conditions and aligns matrix columns", () => { assert.strictEqual( renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), - "⎧ a if x<0\n⎨ b if x=0\n⎩ c otherwise", + "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise", ); assert.strictEqual( renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`), @@ -358,6 +362,71 @@ c_n ); }); + it("composes matrices with fractions and adjacent matrices", () => { + assert.strictEqual( + renderLatex( + String.raw`R\left(\frac{\pi}{4}\right) += +\begin{pmatrix} +\frac{\sqrt{2}}{2} & -\frac{\sqrt{2}}{2}\\ +\frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2} +\end{pmatrix}.`, + { display: true }, + ), + " π\nR( ─ ) = ⎛ (√2)/2 │ -(√2)/2 ⎞\n 4 ⎝ (√2)/2 │ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`\mathbf w += +R\left(\frac{\pi}{4}\right) +\begin{pmatrix}1\\0\end{pmatrix} += +\begin{pmatrix}\frac{\sqrt{2}}{2}\\\frac{\sqrt{2}}{2}\end{pmatrix}.`, + { display: true }, + ), + " π\nw = R( ─ ) ⎛ 1 ⎞ = ⎛ (√2)/2 ⎞\n 4 ⎝ 0 ⎠ ⎝ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`A\mathbf e_1=\begin{pmatrix}\pi\\0\end{pmatrix},\qquad A\mathbf e_2=\begin{pmatrix}0\\\frac{1}{\pi}\end{pmatrix}.`, + { display: true }, + ), + "Ae₁ = ⎛ π ⎞, Ae₂ = ⎛ 0 ⎞\n ⎝ 0 ⎠ ⎝ 1/π ⎠.", + ); + assert.strictEqual( + renderLatex(String.raw`\sum_{i=0}^n x_i=\begin{pmatrix}a&b\\c&d\end{pmatrix}.`, { display: true }), + " n\n ∑ xᵢ = ⎛ a │ b ⎞\ni=0 ⎝ c │ d ⎠.", + ); + }); + + it("normalizes relation, multiplication, and named-operator spacing", () => { + for (const source of ["x=y", "x =y", "x=\ny", "x\n=\ny"]) { + assert.strictEqual(renderLatex(source), "x = y"); + } + assert.strictEqual(renderLatex("x_{i=0}"), "xᵢ₌₀"); + assert.strictEqual(renderLatex(String.raw`x\neq0`), "x ≠ 0"); + assert.strictEqual(renderLatex(String.raw`A\to B`), "A → B"); + assert.strictEqual(renderLatex(String.raw`\pi\cdot\frac{1}{\pi}`), "π · 1/π"); + assert.strictEqual(renderLatex(String.raw`\sin\theta`), "sin θ"); + assert.strictEqual(renderLatex(String.raw`\sin^2 x`), "sin² x"); + assert.strictEqual(renderLatex(String.raw`-\sin\theta`), "-sin θ"); + assert.strictEqual(renderLatex(String.raw`i\sin\theta`), "i sin θ"); + assert.strictEqual(renderLatex(String.raw`\det(A)`), "det(A)"); + }); + + it("treats a backslash followed by a line ending as control space", () => { + const source = String.raw`\boxed{ +(1,1,1),\ (1,1,2),\ (1,2,5),\ (1,5,13),\ (2,5,29),\ +(1,13,34),\ (1,34,89) +}.`; + assert.strictEqual( + renderLatex(source, { display: true }), + "[(1,1,1), (1,1,2), (1,2,5), (1,5,13), (2,5,29), (1,13,34), (1,34,89)].", + ); + assert.strictEqual(renderLatex("a\\\r\nb"), "a b"); + }); + it("stacks operator limits in display mode", () => { assert.strictEqual(renderLatex(String.raw`\sum_{i=0}^n x_i`, { display: true }), " n\n ∑ xᵢ\ni=0"); assert.strictEqual(renderLatex(String.raw`\min_{x\in X} f(x)`, { display: true }), "min f(x)\nx∈X"); @@ -374,7 +443,7 @@ c_n it("uses the middle brace for intermediate case rows", () => { assert.strictEqual( renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`), - "⎧ a if x<0\n⎨ b if x=0\n⎩ c if x>0", + "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0", ); }); @@ -383,9 +452,10 @@ c_n renderLatex(String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, { display: true, }), - " -b±√(b²-4ac)\nx= ────────────\n 2a", + " -b±√(b²-4ac)\nx = ────────────\n 2a", ); assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1"); + assert.strictEqual(renderLatex("\\frac{1}\n{2}", { display: true }), "1\n─\n2"); }); it("keeps nested display fractions linear", () => { @@ -396,7 +466,7 @@ c_n ], [ String.raw`\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0`, - " (sin x)/x-1\nlim ─────────── =0\nx→0 (eˣ-1)/x-1", + " (sin x)/x-1\nlim ─────────── = 0\nx→0 (eˣ-1)/x-1", ], [ String.raw`\frac{1+\frac{1}{1+\frac{1}{x}}}{1-\frac{1}{1-\frac{1}{x}}}`, diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 549b3a4fd2c..6347aa5d762 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert"; import { afterEach, describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; -import { Markdown } from "../src/components/markdown.ts"; +import { Markdown, type MarkdownTheme } from "../src/components/markdown.ts"; import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.ts"; import type { Component, TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; @@ -12,24 +12,14 @@ import { VirtualTerminal } from "./virtual-terminal.ts"; // Force full color in CI so ANSI assertions are deterministic const chalk = new Chalk({ level: 3 }); -function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { +function getCell(terminal: VirtualTerminal, row: number, col: number) { const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; const buffer = xterm.buffer.active; const line = buffer.getLine(buffer.viewportY + row); assert.ok(line, `Missing buffer line at row ${row}`); const cell = line.getCell(col); assert.ok(cell, `Missing cell at row ${row} col ${col}`); - return cell.isItalic(); -} - -function getCellUnderline(terminal: VirtualTerminal, row: number, col: number): number { - const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; - const buffer = xterm.buffer.active; - const line = buffer.getLine(buffer.viewportY + row); - assert.ok(line, `Missing buffer line at row ${row}`); - const cell = line.getCell(col); - assert.ok(cell, `Missing cell at row ${row} col ${col}`); - return cell.isUnderline(); + return cell; } function stripAnsi(line: string): string { @@ -479,6 +469,105 @@ describe("Markdown component", () => { assert.ok(allText.includes("Install"), "Should contain 'Install'"); }); + it("should not leak wrapped link styles into table borders or plain cells", async () => { + const source = `| Link | Plain | +| --- | --- | +| [**one two three four five six**](https://example.com) | normal text |`; + + try { + for (const hyperlinks of [true, false]) { + setCapabilities({ images: null, trueColor: false, hyperlinks }); + const terminal = new VirtualTerminal(24, 16); + const tui: TUI = new TuiMainScreen(terminal); + tui.addChild(new Markdown(source, 0, 0, defaultMarkdownTheme)); + tui.start(); + + try { + await terminal.waitForRender(); + const viewport = terminal.getViewport(); + const row = viewport.findIndex((line) => line.includes("one") && line.includes("norm")); + assert.notStrictEqual(row, -1, `Missing wrapped table row: ${JSON.stringify(viewport)}`); + const line = viewport[row]; + const linkCol = line.indexOf("one"); + const separatorCol = line.indexOf("│", linkCol); + const plainCol = line.indexOf("norm"); + assert.ok(linkCol >= 0 && separatorCol > linkCol && plainCol > separatorCol); + assert.strictEqual(getCell(terminal, row, linkCol).isFgDefault(), false); + assert.strictEqual(getCell(terminal, row, separatorCol).isFgDefault(), true); + assert.strictEqual(getCell(terminal, row, plainCol).isFgDefault(), true); + assert.notStrictEqual(getCell(terminal, row, linkCol).isBold(), 0); + assert.strictEqual(getCell(terminal, row, separatorCol).isBold(), 0); + assert.strictEqual(getCell(terminal, row, plainCol).isBold(), 0); + + if (!hyperlinks) { + const urlRow = viewport.findIndex((viewportLine) => viewportLine.includes("https")); + assert.notStrictEqual(urlRow, -1, `Missing fallback URL row: ${JSON.stringify(viewport)}`); + const urlLine = viewport[urlRow]; + const urlCol = urlLine.indexOf("https"); + const urlSeparatorCol = urlLine.indexOf("│", urlCol); + const urlBorderCol = urlLine.lastIndexOf("│"); + assert.ok(urlCol >= 0 && urlSeparatorCol > urlCol && urlBorderCol > urlSeparatorCol); + assert.notStrictEqual(getCell(terminal, urlRow, urlCol).isDim(), 0); + assert.strictEqual(getCell(terminal, urlRow, urlSeparatorCol).isDim(), 0); + assert.strictEqual(getCell(terminal, urlRow, urlBorderCol).isDim(), 0); + } + } finally { + tui.stop(); + } + } + } finally { + resetCapabilitiesCache(); + } + }); + + it("should restore the enclosing style after a wrapped table link", async () => { + const quoteColor = 0x123456; + const theme: MarkdownTheme = { + ...defaultMarkdownTheme, + // Use a basic wrapper that does not automatically reopen itself after nested resets. + quote: (text) => `\x1b[38;2;18;52;86m${text}\x1b[39m`, + link: (text) => `\x1b[38;2;129;162;190m${text}\x1b[39m`, + }; + const source = `> | Link | Plain | +> | --- | --- | +> | [one two three four five six](https://example.com) | normal text |`; + + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const terminal = new VirtualTerminal(28, 10); + const tui: TUI = new TuiMainScreen(terminal); + tui.addChild(new Markdown(source, 0, 0, theme)); + tui.start(); + + try { + await terminal.waitForRender(); + const viewport = terminal.getViewport(); + const row = viewport.findIndex((line) => line.includes("one") && line.includes("normal")); + assert.notStrictEqual(row, -1, `Missing wrapped blockquote table row: ${JSON.stringify(viewport)}`); + const line = viewport[row]; + const linkCol = line.indexOf("one"); + const separatorCol = line.indexOf("│", linkCol); + const plainCol = line.indexOf("normal"); + assert.ok(linkCol >= 0 && separatorCol > linkCol && plainCol > separatorCol); + + assert.notStrictEqual(getCell(terminal, row, linkCol).getFgColor(), quoteColor); + assert.strictEqual(getCell(terminal, row, separatorCol).getFgColor(), quoteColor); + assert.strictEqual(getCell(terminal, row, plainCol).getFgColor(), quoteColor); + + const finalRow = viewport.findIndex((line) => line.includes("five six")); + assert.notStrictEqual(finalRow, -1, `Missing final wrapped link row: ${JSON.stringify(viewport)}`); + const finalLine = viewport[finalRow]; + const finalLinkCol = finalLine.indexOf("five six"); + const finalSeparatorCol = finalLine.indexOf("│", finalLinkCol); + const finalBorderCol = finalLine.lastIndexOf("│"); + assert.ok(finalLinkCol >= 0 && finalSeparatorCol > finalLinkCol && finalBorderCol > finalSeparatorCol); + assert.strictEqual(getCell(terminal, finalRow, finalSeparatorCol).getFgColor(), quoteColor); + assert.strictEqual(getCell(terminal, finalRow, finalBorderCol).getFgColor(), quoteColor); + } finally { + tui.stop(); + resetCapabilitiesCache(); + } + }); + it("should wrap long cell content to multiple lines", () => { const markdown = new Markdown( `| Header | @@ -743,6 +832,27 @@ after`, assert.deepStrictEqual(lines, ["Before", "", " 0.1 lux", "E ≈ ────────", " 100 lm/W", "", "after"]); }); + it("aligns matrix rows with the opening delimiter", () => { + const markdown = new Markdown( + String.raw`Consider the matrix + +\[ +A= +\begin{pmatrix} +\pi & 0\\ +0 & \frac{1}{\pi} +\end{pmatrix}. +\]`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Consider the matrix", "", "A = ⎛ π │ 0 ⎞", " ⎝ 0 │ 1/π ⎠."]); + }); + it("renders lower limits beneath display operators", () => { const markdown = new Markdown( String.raw`\[ @@ -755,7 +865,7 @@ after`, const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); - assert.deepStrictEqual(lines, [" (sin x)/x-1", "lim ─────────── =0", "x→0 (eˣ-1)/x-1"]); + assert.deepStrictEqual(lines, [" (sin x)/x-1", "lim ─────────── = 0", "x→0 (eˣ-1)/x-1"]); }); it("renders math inside lists and tables", () => { @@ -967,7 +1077,7 @@ after`, assert.ok(component.markdownLineCount > 0); const inputRow = component.markdownLineCount; - assert.strictEqual(getCellItalic(terminal, inputRow, 0), 0); + assert.strictEqual(getCell(terminal, inputRow, 0).isItalic(), 0); tui.stop(); }); }); @@ -1411,7 +1521,11 @@ bar`, assert.ok(contentWidth > 0, "Should have visible heading content"); for (let col = contentWidth; col < 80; col++) { - assert.strictEqual(getCellUnderline(terminal, 0, col), 0, `Expected no underline in padding at col ${col}`); + assert.strictEqual( + getCell(terminal, 0, col).isUnderline(), + 0, + `Expected no underline in padding at col ${col}`, + ); } tui.stop(); diff --git a/packages/tui/test/native-module-path.test.ts b/packages/tui/test/native-module-path.test.ts new file mode 100644 index 00000000000..02245ef12f9 --- /dev/null +++ b/packages/tui/test/native-module-path.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert"; +import { dirname, join, resolve } from "node:path"; +import { describe, it } from "node:test"; +import { pathToFileURL } from "node:url"; +import { getNativeModuleCandidates } from "../src/native-module-path.ts"; + +describe("getNativeModuleCandidates", () => { + it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => { + const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui"); + const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js"); + const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node"); + + const candidates = getNativeModuleCandidates(nativePath, { + moduleUrl: pathToFileURL(bundledModule).href, + execPath: resolve("virtual", "node", "node.exe"), + resolvePackage: (specifier) => { + assert.equal(specifier, "@earendil-works/pi-tui"); + return join(packageRoot, "dist", "index.js"); + }, + }); + + assert.equal(candidates[0], join(packageRoot, nativePath)); + assert.ok(candidates.includes(join(dirname(bundledModule), "..", nativePath))); + }); + + it("keeps standalone binary fallbacks when the TUI package is unavailable", () => { + const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js"); + const execPath = resolve("virtual", "pi", "pi.exe"); + const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node"); + + const candidates = getNativeModuleCandidates(nativePath, { + moduleUrl: pathToFileURL(bundledModule).href, + execPath, + resolvePackage: () => { + throw new Error("not installed"); + }, + }); + + assert.deepEqual(candidates, [ + join(dirname(bundledModule), "..", nativePath), + join(dirname(bundledModule), nativePath), + join(dirname(execPath), nativePath), + ]); + }); +}); diff --git a/packages/tui/test/render-churn-bench.ts b/packages/tui/test/render-churn-bench.ts new file mode 100644 index 00000000000..103c9561bf7 --- /dev/null +++ b/packages/tui/test/render-churn-bench.ts @@ -0,0 +1,203 @@ +/** + * Alt-screen render churn benchmark. + * + * Measures cumulative JS allocation and wall time for repeated TuiAltScreen + * frames on a layout mirroring pi's fullscreen interactive mode: + * VStack [ ScrollView(transcript), dock VStack [status, editor, footer] ]. + * + * Two scenarios: + * - static: nothing changes between frames (pure recomposite churn) + * - editor: one character appended to the editor per frame (doc scenario + * "30 editor updates") + * + * Allocation is estimated with the V8 sampling heap profiler including + * objects collected by minor/major GC, i.e. it measures churn, not retention. + * + * Run from packages/tui: node test/render-churn-bench.ts + */ + +import { Session } from "node:inspector/promises"; +import { performance } from "node:perf_hooks"; +import { ScrollView } from "../src/components/scroll-view.ts"; +import { Text } from "../src/components/text.ts"; +import { VStack } from "../src/components/v-stack.ts"; +import type { Terminal } from "../src/terminal.ts"; +import { type Component, Container, CURSOR_MARKER } from "../src/tui.ts"; +import { TuiAltScreen } from "../src/tui-alt-screen.ts"; + +const COLUMNS = 100; +const ROWS = 30; +const WARMUP_FRAMES = 20; +const FRAMES = 300; +const SAMPLING_INTERVAL = 4096; + +/** Terminal that discards output; keeps xterm parsing out of the measurement. */ +class NullTerminal implements Terminal { + bytesWritten = 0; + start(_onInput: (data: string) => void, _onResize: () => void): void {} + stop(): void {} + async drainInput(): Promise {} + write(data: string): void { + this.bytesWritten += data.length; + } + get columns(): number { + return COLUMNS; + } + get rows(): number { + return ROWS; + } + get kittyProtocolActive(): boolean { + return false; + } + moveBy(_lines: number): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(_title: string): void {} + setProgress(_active: boolean): void {} +} + +/** Editor stand-in: caches lines per (text, width), re-renders when text changes. */ +class EditorSim implements Component { + private text = ""; + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + append(char: string): void { + this.text += char; + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + const border = `\x1b[90m${"─".repeat(Math.max(1, width - 2))}\x1b[39m`; + const lines = [border, ` > ${this.text}${CURSOR_MARKER}`, border]; + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } +} + +function buildTranscript(): Container { + const container = new Container(); + for (let i = 0; i < 150; i++) { + const styled = + i % 3 === 0 + ? `\x1b[1m\x1b[36muser ${i}\x1b[39m\x1b[22m message with some \x1b[33mstyled\x1b[39m content padding padding` + : `assistant ${i} plain response line with enough text to be representative of a transcript row`; + container.addChild(new Text(styled, 1, 0)); + } + return container; +} + +interface SamplingNode { + selfSize: number; + children: SamplingNode[]; +} + +function sumProfile(node: SamplingNode): number { + let total = node.selfSize; + for (const child of node.children) total += sumProfile(child); + return total; +} + +interface ScenarioResult { + allocatedBytes: number; + elapsedMs: number; + bytesWritten: number; +} + +async function runScenario( + session: Session, + terminal: NullTerminal, + tui: TuiAltScreen, + frame: (index: number) => void, +): Promise { + const writtenBefore = terminal.bytesWritten; + await session.post("HeapProfiler.startSampling", { + samplingInterval: SAMPLING_INTERVAL, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + const start = performance.now(); + for (let i = 0; i < FRAMES; i++) { + frame(i); + tui.renderNow(); + } + const elapsedMs = performance.now() - start; + const { profile } = await session.post("HeapProfiler.stopSampling"); + return { + allocatedBytes: sumProfile(profile.head as SamplingNode), + elapsedMs, + bytesWritten: terminal.bytesWritten - writtenBefore, + }; +} + +function report(name: string, result: ScenarioResult): void { + const perFrameKiB = result.allocatedBytes / FRAMES / 1024; + const totalMiB = result.allocatedBytes / 1024 / 1024; + const msPerFrame = result.elapsedMs / FRAMES; + console.log( + `${name.padEnd(8)} allocated ${totalMiB.toFixed(1).padStart(7)} MiB total ` + + `${perFrameKiB.toFixed(1).padStart(8)} KiB/frame ` + + `${msPerFrame.toFixed(3).padStart(7)} ms/frame ` + + `${(result.bytesWritten / FRAMES).toFixed(0).padStart(6)} written bytes/frame`, + ); +} + +async function main(): Promise { + const terminal = new NullTerminal(); + const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench"); + + const transcript = buildTranscript(); + const editor = new EditorSim(); + const scrollView = new ScrollView(transcript, { + follow: "end", + primary: true, + overscroll: "chain", + scrollbar: "auto", + }); + const status = new Text("\x1b[2mstatus: idle\x1b[22m", 1, 0); + const footer = new Text("\x1b[2m~/workspaces/pi main 100k tokens\x1b[22m", 1, 0); + const dock = new VStack([ + { component: status, shrink: 1, minSize: 0 }, + { component: editor, shrink: 1, minSize: 3 }, + { component: footer, shrink: 1, minSize: 1 }, + ]); + const root = new VStack([ + { component: scrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 }, + { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, + ]); + tui.setLayoutRoot(root); + tui.start(); + + for (let i = 0; i < WARMUP_FRAMES; i++) tui.renderNow(); + + const session = new Session(); + session.connect(); + + const staticResult = await runScenario(session, terminal, tui, () => {}); + const editorResult = await runScenario(session, terminal, tui, (i) => { + editor.append(String.fromCharCode(97 + (i % 26))); + }); + + session.disconnect(); + tui.stop(); + + console.log(`frames=${FRAMES} viewport=${COLUMNS}x${ROWS} transcript=${transcript.render(COLUMNS).length} lines`); + report("static", staticResult); + report("editor", editorResult); +} + +await main(); diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index e72c149665a..4ed8a09136a 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -7,6 +7,7 @@ import assert from "node:assert"; import { beforeEach, describe, it } from "node:test"; +import { matchesKey } from "../src/keys.ts"; import { StdinBuffer } from "../src/stdin-buffer.ts"; describe("StdinBuffer", () => { @@ -133,6 +134,62 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); + + it("should flush a lone ESC as Escape when CR arrives after the timeout", async () => { + // Legacy-mode Alt+Enter is ESC + CR; when the terminal/transport splits + // the bytes further apart than the timeout, ESC is flushed alone and the + // host sees Escape (interrupt) instead of Alt+Enter. This locks in the + // behavior so the configurable timeout in ProcessTerminal stays honest. + processInput("\x1b"); + await wait(20); // buffer timeout is 10ms in beforeEach + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); + }); + + it("should merge ESC + CR split across chunks within a larger timeout", async () => { + buffer = new StdinBuffer({ escapeTimeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); // > 10ms default escapeTimeout, < 100ms configured escapeTimeout + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); + }); + + it("does not apply the sequence timeout to a lone ESC", async () => { + buffer = new StdinBuffer({ timeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); + }); + + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { + const delayedBuffer = new StdinBuffer(); + const delayedSequences: string[] = []; + delayedBuffer.on("data", (sequence) => delayedSequences.push(sequence)); + + delayedBuffer.process("\x1b["); + await wait(20); + assert.deepStrictEqual(delayedSequences, []); + delayedBuffer.process("<65;48;39M"); + assert.deepStrictEqual(delayedSequences, ["\x1b[<65;48;39M"]); + delayedBuffer.destroy(); + }); }); describe("Mixed Content", () => { @@ -314,6 +371,17 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b"]); }); + it("flushes a lone escape promptly with the longer default sequence timeout", async () => { + const defaultBuffer = new StdinBuffer(); + const defaultSequences: string[] = []; + defaultBuffer.on("data", (sequence) => defaultSequences.push(sequence)); + + defaultBuffer.process("\x1b"); + await wait(20); + assert.deepStrictEqual(defaultSequences, ["\x1b"]); + defaultBuffer.destroy(); + }); + it("should handle lone escape character with explicit flush", () => { processInput("\x1b"); assert.deepStrictEqual(emittedSequences, []); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 06c751dbc5a..80eadd6264c 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,7 +1,35 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { normalizeAppleTerminalInput, normalizeNativeShiftEnterInput, ProcessTerminal } from "../src/terminal.ts"; +import { + normalizeAppleTerminalInput, + normalizeNativeShiftEnterInput, + ProcessTerminal, + resolveEscapeTimeoutMs, +} from "../src/terminal.ts"; + +describe("resolveEscapeTimeoutMs", () => { + it("uses PI_TUI_ESC_TIMEOUT when configured", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80" }), 80); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80", SSH_TTY: "/dev/pts/1" }), 80); + }); + + it("ignores invalid PI_TUI_ESC_TIMEOUT values", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "abc" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "0" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "-5" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "" }), 10); + }); + + it("defaults to 100ms over SSH", () => { + assert.equal(resolveEscapeTimeoutMs({ SSH_CONNECTION: "10.0.0.1 22" }), 100); + assert.equal(resolveEscapeTimeoutMs({ SSH_TTY: "/dev/pts/1" }), 100); + }); + + it("defaults to 10ms otherwise", () => { + assert.equal(resolveEscapeTimeoutMs({}), 10); + }); +}); describe("normalizeNativeShiftEnterInput", () => { it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => { @@ -195,7 +223,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { const harness = setupNegotiation(); try { harness.send("\x1b["); - mock.timers.tick(10); + mock.timers.tick(50); // StdinBuffer sequence timeout, not the lone-ESC timeout assert.equal(harness.getInput(), undefined); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 5b2dabf48c8..a681cfd8c80 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1,10 +1,12 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { findAltScreenSearchMatches } from "../src/alt-screen-search.ts"; import { HStack } from "../src/components/h-stack.ts"; import { Image } from "../src/components/image.ts"; import { ScrollView } from "../src/components/scroll-view.ts"; import { Text } from "../src/components/text.ts"; import { VStack } from "../src/components/v-stack.ts"; +import { getKeybindings, KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts"; import { encodeKitty, hyperlink, @@ -17,6 +19,21 @@ import { VirtualTerminal } from "./virtual-terminal.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; +class InputOverlay { + focused = false; + inputs: string[] = []; + + handleInput(data: string): void { + this.inputs.push(data); + } + + render(): string[] { + return ["overlay"]; + } + + invalidate(): void {} +} + class RecordingTerminal extends VirtualTerminal { readonly events: Array<{ type: "write"; data: string } | { type: "start" } | { type: "stop" }> = []; @@ -161,6 +178,88 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("uses button-motion tracking inside terminal multiplexers", () => { + const environmentKeys = ["TMUX", "ZELLIJ", "STY", "TERM"] as const; + const previousEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]])); + try { + for (const key of environmentKeys) delete process.env[key]; + process.env.TERM = "xterm-256color"; + const directTerminal = new RecordingTerminal(); + const directTui = new TuiAltScreen(directTerminal); + directTui.start(); + const directWrites = directTerminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(directWrites.includes("\x1b[?1003h")); + directTui.stop(); + + const multiplexers = [ + { name: "tmux environment", environment: { TMUX: "/tmp/tmux/default,1,0" } }, + { name: "tmux TERM", environment: { TERM: "tmux-256color" } }, + { name: "Zellij environment", environment: { ZELLIJ: "0" } }, + { name: "Screen environment", environment: { STY: "123.session" } }, + { name: "Screen TERM", environment: { TERM: "screen-256color" } }, + ]; + for (const { name, environment } of multiplexers) { + for (const key of environmentKeys) delete process.env[key]; + for (const [key, value] of Object.entries(environment)) process.env[key] = value; + const terminal = new RecordingTerminal(); + const tui = new TuiAltScreen(terminal); + tui.start(); + const writes = terminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(writes.includes("\x1b[?1002h"), `${name} should enable button-motion tracking`); + assert.ok(!writes.includes("\x1b[?1003h"), `${name} should not enable all-motion tracking`); + assert.ok(writes.includes("\x1b[?1006h"), `${name} should enable SGR mouse encoding`); + tui.stop(); + } + } finally { + for (const key of environmentKeys) { + const value = previousEnvironment.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + it("invokes the right-click paste handler only on Windows outside VS Code", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const termProgram = process.env.TERM_PROGRAM; + assert.ok(platformDescriptor); + const terminal = new VirtualTerminal(); + let pasteCount = 0; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + onRightClickPaste: () => { + pasteCount += 1; + }, + }); + try { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + delete process.env.TERM_PROGRAM; + tui.start(); + terminal.sendInput("\x1b[<2;1;1M"); + terminal.sendInput("\x1b[<2;1;1m"); + assert.strictEqual(pasteCount, 1); + + process.env.TERM_PROGRAM = "vscode"; + terminal.sendInput("\x1b[<2;1;1M"); + assert.strictEqual(pasteCount, 1); + + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + delete process.env.TERM_PROGRAM; + terminal.sendInput("\x1b[<2;1;1M"); + assert.strictEqual(pasteCount, 1); + } finally { + tui.stop(); + Object.defineProperty(process, "platform", platformDescriptor); + if (termProgram === undefined) delete process.env.TERM_PROGRAM; + else process.env.TERM_PROGRAM = termProgram; + } + }); + it("drags a visible scrollbar thumb and keeps it visible until release", async () => { const terminal = new RecordingTerminal(10, 5); const tui = new TuiAltScreen(terminal); @@ -214,9 +313,7 @@ describe("TuiAltScreen", () => { assert.strictEqual(scrollView.isScrollbarVisible, false); assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); - assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1003h"))); tui.stop(); - assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1003l"))); }); it("keeps the scrollbar column selectable while the thumb is hidden", async () => { @@ -307,6 +404,163 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("searches normalized rendered transcript text across rows", () => { + assert.deepStrictEqual(findAltScreenSearchMatches(["alpha QUICK", "brown fox"], "quick brown"), [ + { + segments: [ + { row: 0, startCol: 6, endCol: 11 }, + { row: 1, startCol: 0, endCol: 5 }, + ], + }, + ]); + }); + + it("uses configured styles for current and non-current search matches", async () => { + const terminal = new RecordingTerminal(60, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + searchMatchStyle: (text) => `\x1b[41m${text}\x1b[49m`, + searchCurrentMatchStyle: (text) => `\x1b[42m${text}\x1b[49m`, + }); + tui.addChild(new Text("needle first\nmiddle\nneedle second\nend", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[42mneedle\x1b[49m")), + ); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[41mneedle\x1b[49m")), + ); + tui.stop(); + }); + + it("searches the transcript with Ctrl+Shift+F and restores editor focus on close", async () => { + const terminal = new RecordingTerminal(60, 8); + const tui = new TuiAltScreen(terminal); + const transcriptText = new Text( + Array.from({ length: 12 }, (_, index) => { + if (index === 4) return "line 5 needle one"; + if (index === 9) return "line 10 needle two"; + return `line ${index + 1}`; + }).join("\n"), + 0, + 0, + ); + const transcript = new ScrollView(transcriptText, { follow: "end", primary: true }); + const editorInputs: string[] = []; + const editor = { + focused: false, + render: () => ["editor"], + invalidate: () => {}, + handleInput: (data: string) => editorInputs.push(data), + }; + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: editor, basis: 1, shrink: 0 }, + ]), + ); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isFollowingEnd, false); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + assert.deepStrictEqual(editorInputs, []); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[1;7mneedle\x1b[22;27m")), + ); + + for (let index = 0; index < 6; index++) terminal.sendInput("\x1b[<64;1;4M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 0); + assert.ok(terminal.getViewport().some((line) => line.includes("> needle"))); + + terminal.sendInput("\x07"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("1/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 5 needle one"))); + + terminal.sendInput("\x1b[103;6u"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + + terminal.sendInput("\x1b"); + terminal.sendInput("x"); + await terminal.waitForRender(); + assert.ok(!terminal.getViewport().some((line) => line.includes("Find transcript"))); + assert.deepStrictEqual(editorInputs, ["x"]); + + tui.stop(); + }); + + it("scrolls the transcript by half a page with custom bindings", async () => { + const originalKeybindings = getKeybindings(); + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.altScreen.halfPageUp": "ctrl+u", + "tui.altScreen.halfPageDown": "ctrl+d", + }), + ); + try { + tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + + terminal.sendInput("\x15"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 15); + + terminal.sendInput("\x04"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + } finally { + tui.stop(); + setKeybindings(originalKeybindings); + } + }); + + it("scrolls the transcript by one line with custom bindings", async () => { + const originalKeybindings = getKeybindings(); + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.altScreen.lineUp": "ctrl+y", + "tui.altScreen.lineDown": "ctrl+e", + }), + ); + try { + tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + + terminal.sendInput("\x19"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 19); + + terminal.sendInput("\x05"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + } finally { + tui.stop(); + setKeybindings(originalKeybindings); + } + }); + it("routes Ctrl-modified viewport navigation to the focused component", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); @@ -645,7 +899,7 @@ describe("TuiAltScreen", () => { } }); - it("opens an OSC 8 hyperlink on click but not on drag", async () => { + it("opens an OSC 8 hyperlink with specific or generic release codes, but not on drag", async () => { const terminal = new RecordingTerminal(20, 3); const openedUrls: string[] = []; const tui = new TuiAltScreen(terminal, undefined, undefined, { @@ -665,7 +919,7 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); terminal.sendInput("\x1b[<0;2;1M"); - terminal.sendInput("\x1b[<0;2;1m"); + terminal.sendInput("\x1b[<3;2;1m"); await terminal.waitForRender(); assert.deepStrictEqual(openedUrls, [url]); @@ -688,16 +942,16 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("selects visible text with the mouse and copies it with OSC 52", async () => { + it("selects visible text with the mouse and copies it with OSC 52 after a generic release", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); - tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.addChild(new Text("\x1b[1mal\x1b[0mpha\nbeta\ngamma\ndelta", 0, 0)); tui.start(); await terminal.waitForRender(); terminal.sendInput("\x1b[<0;1;1M"); terminal.sendInput("\x1b[<32;4;2M"); - terminal.sendInput("\x1b[<0;4;2m"); + terminal.sendInput("\x1b[<3;4;2m"); await terminal.waitForRender(); const expectedClipboardSequence = `\x1b]52;c;${Buffer.from("alpha\nbeta").toString("base64")}\x07`; @@ -710,24 +964,154 @@ describe("TuiAltScreen", () => { ); assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m"))); assert.ok( - terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m\x1b[0m\x1b[7m")), - "selection inverse must be reapplied after layout segment resets", + terminal.events.some((event) => event.type === "write" && event.data.includes("al\x1b[0m\x1b[7mpha")), + "selection inverse must be reapplied after a reset inside the selection", + ); + assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); + + tui.stop(); + }); + + it("uses an injected copySelection handler instead of OSC 52 and reports success", async () => { + const terminal = new RecordingTerminal(20, 4); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + assert.ok( + terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")), + "must not emit OSC 52 when a copySelection handler is provided", ); assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); tui.stop(); }); - it("ignores orphan selection events and cancels an active selection on focus loss", async () => { + it("flashes an error when the injected copySelection handler fails", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async () => false, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("Copy failed"))); + assert.ok( + terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")), + "must not emit OSC 52 when a copySelection handler is provided", + ); + + tui.stop(); + }); + + it("does not append whitespace to double-click word highlighting", async () => { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("foo bar", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;3;1M"); + await terminal.waitForRender(); + + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo\x1b[27m"))); + tui.stop(); + }); + + it("highlights a complete whitespace segment during a word drag", async () => { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("foo bar", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;2;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + await terminal.waitForRender(); + + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo \x1b[27m"))); + tui.stop(); + }); + + it("selects whole words on double click, extends word drags, and selects lines on triple click", async () => { + const terminal = new RecordingTerminal(20, 2); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("zero alpha beta\ngamma delta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + // The second click lands on a different character in alpha. + terminal.sendInput("\x1b[<0;6;1M"); + terminal.sendInput("\x1b[<0;6;1m"); + terminal.sendInput("\x1b[<0;10;1M"); + terminal.sendInput("\x1b[<0;10;1m"); + await terminal.waitForRender(); + const alpha = `\x1b]52;c;${Buffer.from("alpha").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(alpha))); + + // A double-click drag includes each word touched, rather than partial words. + terminal.sendInput("\x1b[<0;12;1M"); + terminal.sendInput("\x1b[<0;12;1m"); + terminal.sendInput("\x1b[<0;14;1M"); + terminal.sendInput("\x1b[<32;3;2M"); + terminal.sendInput("\x1b[<0;3;2m"); + await terminal.waitForRender(); + const words = `\x1b]52;c;${Buffer.from("beta\ngamma").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(words))); + + terminal.sendInput("\x1b[<0;7;2M"); + terminal.sendInput("\x1b[<0;7;2m"); + terminal.sendInput("\x1b[<0;9;2M"); + terminal.sendInput("\x1b[<0;9;2m"); + terminal.sendInput("\x1b[<0;11;2M"); + terminal.sendInput("\x1b[<0;11;2m"); + await terminal.waitForRender(); + const line = `\x1b]52;c;${Buffer.from("gamma delta").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(line))); + + tui.stop(); + }); + + it("does not repaint idle or zero-width selections on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); tui.start(); await terminal.waitForRender(); + const writeCount = () => terminal.events.filter((event) => event.type === "write").length; const clipboardWriteCount = () => terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;")).length; + const idleWriteCount = writeCount(); + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), idleWriteCount); + // A completed click leaves a zero-width anchor, but later orphaned drag/release events must not extend it. terminal.sendInput("\x1b[<0;1;1M"); terminal.sendInput("\x1b[<0;1;1m"); @@ -736,10 +1120,14 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); assert.strictEqual(clipboardWriteCount(), 0); - // Losing focus also cancels a press whose matching release never arrived. - terminal.sendInput("\x1b[<0;1;1M"); + // Losing focus after a press without a drag cancels the press without repainting. + terminal.sendInput("\x1b[<0;1;3M"); + await terminal.waitForRender(); + const pressedWriteCount = writeCount(); terminal.sendInput("\x1b[O"); terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), pressedWriteCount); terminal.sendInput("\x1b[<32;4;2M"); terminal.sendInput("\x1b[<0;4;2m"); await terminal.waitForRender(); @@ -750,6 +1138,66 @@ describe("TuiAltScreen", () => { assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004l"))); }); + it("clears an active visible selection on focus loss and ignores orphan events", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + await terminal.waitForRender(); + const focusLossEventCount = terminal.events.length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + const focusLossWrites = terminal.events + .slice(focusLossEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(focusLossWrites.includes("alpha")); + assert.ok(focusLossWrites.includes("beta")); + assert.ok(!focusLossWrites.includes("\x1b[7m")); + + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); + tui.stop(); + }); + + it("retains a completed visible selection across focus changes", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + const completedWriteCount = terminal.events.filter((event) => event.type === "write").length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, completedWriteCount); + + const redrawEventCount = terminal.events.length; + tui.renderNow(true); + const redrawWrites = terminal.events + .slice(redrawEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(redrawWrites.includes("alpha")); + assert.ok(redrawWrites.includes("beta")); + assert.ok(redrawWrites.includes("\x1b[7m")); + tui.stop(); + }); + it("stacks flash messages and collapses them as they expire", async () => { const terminal = new VirtualTerminal(20, 4); const tui = new TuiAltScreen(terminal); @@ -891,4 +1339,82 @@ describe("TuiAltScreen", () => { assert.ok(restoreEvent.data.indexOf("first") < restoreEvent.data.indexOf("sixth")); } }); + + it("gives wheel and viewport keys to a focused overlay", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + const overlay = new InputOverlay(); + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.viewportTop; + const handle = tui.showOverlay(overlay); + await terminal.waitForRender(); + assert.strictEqual(overlay.focused, true); + + const wheel = "\x1b[<64;10;3M"; + const keys = ["\x1b[5~", "\x1b[6~", "\x1bOH", "\x1bOF", wheel]; + for (const key of keys) terminal.sendInput(key); + await terminal.waitForRender(); + + assert.deepStrictEqual(overlay.inputs, keys); + assert.strictEqual(tui.viewportTop, topBefore); + + handle.hide(); + await terminal.waitForRender(); + terminal.sendInput("\x1b[5~"); + await terminal.waitForRender(); + assert.ok(tui.viewportTop < topBefore); + tui.stop(); + }); + + it("keeps viewport scrolling when an overlay is not focused", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + const editor = new InputOverlay(); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.viewportTop; + + const hidden = tui.showOverlay(new InputOverlay()); + hidden.setHidden(true); + const nonCapturing = new InputOverlay(); + tui.showOverlay(nonCapturing, { nonCapturing: true }); + const unfocused = new InputOverlay(); + const unfocusedHandle = tui.showOverlay(unfocused); + unfocusedHandle.unfocus(); + await terminal.waitForRender(); + assert.strictEqual(nonCapturing.focused, false); + assert.strictEqual(unfocused.focused, false); + + terminal.sendInput("\x1b[5~"); + terminal.sendInput("\x1b[<64;10;3M"); + await terminal.waitForRender(); + assert.ok(tui.viewportTop < topBefore); + assert.deepStrictEqual(nonCapturing.inputs, []); + assert.deepStrictEqual(unfocused.inputs, []); + tui.stop(); + }); + + it("keeps viewport scrolling while transcript search is focused", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.viewportTop; + + terminal.sendInput("\x1b[102;6u"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript"))); + + terminal.sendInput("\x1b[5~"); + terminal.sendInput("\x1b[<64;1;4M"); + await terminal.waitForRender(); + assert.ok(tui.viewportTop < topBefore); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript"))); + tui.stop(); + }); }); diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts deleted file mode 100644 index a90c176d921..00000000000 --- a/packages/tui/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["test/wrap-ansi.test.ts"], - }, -}); diff --git a/scripts/auto-pi.sh b/scripts/auto-pi.sh new file mode 100755 index 00000000000..2cab40be18b --- /dev/null +++ b/scripts/auto-pi.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Developer wrapper that runs pi from this checkout's latest `npm run build`. +# Development invocations use PI_EXPERIMENTAL=1 by default. Pass --stable to use +# the next pi executable on PATH; `pi update` also uses stable so self-update +# works. +# +# From the repository root, install with: +# mkdir -p "$HOME/.local/bin" +# ln -s "$PWD/scripts/auto-pi.sh" "$HOME/.local/bin/pi" +# +# ~/.local/bin must appear before the stable pi installation on PATH. + +# Resolve this script through symlinks so repo_dir points at the development +# checkout rather than the directory containing the `pi` symlink. +script_path="${BASH_SOURCE[0]}" +while [[ -L "$script_path" ]]; do + script_dir="$(cd -P "$(dirname "$script_path")" && pwd)" + link_target="$(readlink "$script_path")" + if [[ "$link_target" == /* ]]; then + script_path="$link_target" + else + script_path="$script_dir/$link_target" + fi +done +script_dir="$(cd -P "$(dirname "$script_path")" && pwd)" +repo_dir="$(cd "$script_dir/.." && pwd)" + +find_stable_pi() { + local path_entry candidate candidate_dir + local -a path_entries + IFS=: read -r -a path_entries <<< "${PATH:-}" + for path_entry in "${path_entries[@]}"; do + [[ -n "$path_entry" ]] || path_entry=. + candidate="$path_entry/pi" + [[ -x "$candidate" && ! -d "$candidate" ]] || continue + [[ "$candidate" -ef "$script_path" ]] && continue + candidate_dir="$(cd -P "$(dirname "$candidate")" && pwd)" || continue + printf '%s/%s\n' "$candidate_dir" "$(basename "$candidate")" + return 0 + done + return 1 +} + +use_stable=false +args=() +for arg in "$@"; do + if [[ "$arg" == "--stable" ]]; then + use_stable=true + else + args+=("$arg") + fi +done + +if [[ "${args[0]:-}" == "update" ]]; then + use_stable=true +fi + +if [[ "$use_stable" == true ]]; then + if ! stable_pi="$(find_stable_pi)"; then + echo "error: could not find a stable pi executable after the auto-pi wrapper on PATH" >&2 + exit 1 + fi + exec "$stable_pi" ${args[@]+"${args[@]}"} +fi + +dev_pi="$repo_dir/packages/coding-agent/dist/cli.js" +if [[ ! -x "$dev_pi" ]]; then + echo "error: development pi build not found; run \`npm run build\` in $repo_dir" >&2 + exit 1 +fi + +export PI_EXPERIMENTAL="${PI_EXPERIMENTAL:-1}" +exec "$dev_pi" ${args[@]+"${args[@]}"} diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 702f7d97cb6..bd13cf0f7ee 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -159,6 +159,35 @@ else PLATFORMS=(darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64 windows-arm64) fi +set_clipboard_target() { + case "$1" in + darwin-arm64) + clipboard_native_package="clipboard-darwin-arm64" + clipboard_native_file="clipboard.darwin-arm64.node" + ;; + darwin-x64) + clipboard_native_package="clipboard-darwin-x64" + clipboard_native_file="clipboard.darwin-x64.node" + ;; + linux-x64) + clipboard_native_package="clipboard-linux-x64-gnu" + clipboard_native_file="clipboard.linux-x64-gnu.node" + ;; + linux-arm64) + clipboard_native_package="clipboard-linux-arm64-gnu" + clipboard_native_file="clipboard.linux-arm64-gnu.node" + ;; + windows-x64) + clipboard_native_package="clipboard-win32-x64-msvc" + clipboard_native_file="clipboard.win32-x64-msvc.node" + ;; + windows-arm64) + clipboard_native_package="clipboard-win32-arm64-msvc" + clipboard_native_file="clipboard.win32-arm64-msvc.node" + ;; + esac +} + for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." bun_target="bun-$platform" @@ -169,10 +198,13 @@ for platform in "${PLATFORMS[@]}"; do # Bun compiled executables only embed worker scripts when they are passed as # explicit build entrypoints. The runtime can still use new URL(...), but the # worker must be present in the compiled executable. + # + # Disable cwd bunfig.toml autoload so project preload scripts cannot crash the + # standalone binary before pi starts (see #7684). if [[ "$platform" == windows-* ]]; then - bun build --compile --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe" + bun build --compile --no-compile-autoload-bunfig --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe" else - bun build --compile --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi" + bun build --compile --no-compile-autoload-bunfig --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi" fi done @@ -192,35 +224,9 @@ for platform in "${PLATFORMS[@]}"; do cp -r docs "$OUTPUT_DIR/$platform/" cp -r examples "$OUTPUT_DIR/$platform/" - case "$platform" in - darwin-arm64) - clipboard_native_package="clipboard-darwin-arm64" - clipboard_native_file="clipboard.darwin-arm64.node" - ;; - darwin-x64) - clipboard_native_package="clipboard-darwin-x64" - clipboard_native_file="clipboard.darwin-x64.node" - ;; - linux-x64) - clipboard_native_package="clipboard-linux-x64-gnu" - clipboard_native_file="clipboard.linux-x64-gnu.node" - ;; - linux-arm64) - clipboard_native_package="clipboard-linux-arm64-gnu" - clipboard_native_file="clipboard.linux-arm64-gnu.node" - ;; - windows-x64) - clipboard_native_package="clipboard-win32-x64-msvc" - clipboard_native_file="clipboard.win32-x64-msvc.node" - ;; - windows-arm64) - clipboard_native_package="clipboard-win32-arm64-msvc" - clipboard_native_file="clipboard.win32-arm64-msvc.node" - ;; - esac + set_clipboard_target "$platform" mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner" cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" - cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" cp "../../node_modules/@mariozechner/$clipboard_native_package/$clipboard_native_file" \ "$OUTPUT_DIR/$platform/node_modules/@mariozechner/clipboard/" diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs new file mode 100644 index 00000000000..1b6d233eb81 --- /dev/null +++ b/scripts/build-coding-agent-bundle.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); +const codingAgentDir = join(repoRoot, "packages", "coding-agent"); +const aiDistDir = join(repoRoot, "packages", "ai", "dist"); +const codingAgentDistDir = join(codingAgentDir, "dist"); +const bundleDir = join(codingAgentDistDir, "bundle"); +const banner = { + js: 'import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);', +}; +const allowedExternalPackages = new Set([ + "@silvia-odwyer/photon-node", + "jiti", + // Optional native accelerators. Their callers fall back to JavaScript when absent. + "bufferutil", + "utf-8-validate", + // Optional debug output coloring. + "supports-color", +]); + +const lazyJitiPlugin = { + name: "lazy-jiti-transform", + setup(build) { + build.onResolve({ filter: /^jiti\/static$/ }, () => ({ + namespace: "lazy-jiti", + path: "jiti/static", + })); + build.onLoad({ filter: /.*/, namespace: "lazy-jiti" }, () => ({ + contents: ` +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +let createJitiImpl; + +export function createJiti(...args) { + createJitiImpl ??= require("jiti").createJiti; + return createJitiImpl(...args); +} +`, + loader: "js", + })); + }, +}; + +function commonBuildOptions() { + return { + absWorkingDir: repoRoot, + banner, + bundle: true, + define: { PI_BUNDLED_NODE: "true" }, + external: ["@silvia-odwyer/photon-node"], + format: "esm", + legalComments: "none", + logLevel: "warning", + metafile: true, + minifySyntax: true, + minifyWhitespace: true, + platform: "node", + // The source uses jiti/static so Bun embeds its Babel transform. The Node + // package replaces it with a synchronous lazy require so jiti loads only + // when importing an extension; Babel remains deferred until a cache miss + // needs transformation. + plugins: [lazyJitiPlugin], + sourcemap: false, + target: "node22.19", + // Do not apply the monorepo's source-oriented path aliases while bundling + // compiled output. Release builds must resolve the same package entries as + // an installed npm package. + tsconfigRaw: { compilerOptions: {} }, + }; +} + +function validateExternalImports(metafiles) { + const unexpected = new Set(); + for (const metafile of metafiles) { + for (const input of Object.values(metafile.inputs)) { + for (const imported of input.imports) { + if (!imported.external || isBuiltin(imported.path) || allowedExternalPackages.has(imported.path)) { + continue; + } + unexpected.add(imported.path); + } + } + } + if (unexpected.size > 0) { + throw new Error(`Bundle left unexpected external imports: ${Array.from(unexpected).sort().join(", ")}`); + } +} + +function findContainingOutput(metafile, inputSuffix) { + const normalizedSuffix = inputSuffix.replaceAll("\\", "/"); + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + if (Object.keys(output.inputs).some((inputPath) => inputPath.replaceAll("\\", "/").endsWith(normalizedSuffix))) { + return resolve(repoRoot, outputPath); + } + } + throw new Error(`Could not locate bundled output containing ${inputSuffix}`); +} + +function outputBytes(metafiles) { + return metafiles.reduce( + (total, metafile) => total + Object.values(metafile.outputs).reduce((subtotal, output) => subtotal + output.bytes, 0), + 0, + ); +} + +for (const entry of [ + join(codingAgentDistDir, "cli.js"), + join(codingAgentDistDir, "index.js"), + join(codingAgentDistDir, "rpc-entry.js"), + join(codingAgentDistDir, "client", "index.js"), + join(codingAgentDistDir, "utils", "image-resize-worker.js"), + join(aiDistDir, "api", "bedrock-converse-stream.js"), + join(aiDistDir, "auth", "oauth", "anthropic.js"), +]) { + if (!existsSync(entry)) { + throw new Error(`Bundle input is missing: ${relative(repoRoot, entry)}. Build the workspace packages first.`); + } +} + +rmSync(bundleDir, { force: true, recursive: true }); +mkdirSync(bundleDir, { recursive: true }); + +const mainResult = await build({ + ...commonBuildOptions(), + entryNames: "[name]", + entryPoints: { + cli: join(codingAgentDistDir, "cli.js"), + client: join(codingAgentDistDir, "client", "index.js"), + index: join(codingAgentDistDir, "index.js"), + "rpc-entry": join(codingAgentDistDir, "rpc-entry.js"), + }, + outdir: bundleDir, + chunkNames: "chunks/[name]-[hash]", + splitting: true, +}); + +const bedrockLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/api/bedrock-converse-stream.lazy.js"); +const oauthLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/auth/oauth/load.js"); +const imageResizeOutput = findContainingOutput(mainResult.metafile, "packages/coding-agent/dist/utils/image-resize.js"); +if (dirname(bedrockLoaderOutput) !== dirname(oauthLoaderOutput)) { + throw new Error("Bedrock and OAuth lazy loaders were emitted into different directories"); +} + +// These implementations are reached through variable-specifier imports or a +// worker URL, so the main bundle cannot follow them. Emit one self-contained +// file per implementation beside the code that resolves it. +const lazyResult = await build({ + ...commonBuildOptions(), + entryNames: "[name]", + entryPoints: { + anthropic: join(aiDistDir, "auth", "oauth", "anthropic.js"), + "bedrock-converse-stream": join(aiDistDir, "api", "bedrock-converse-stream.js"), + "github-copilot": join(aiDistDir, "auth", "oauth", "github-copilot.js"), + "image-resize-worker": join(codingAgentDistDir, "utils", "image-resize-worker.js"), + "kimi-coding": join(aiDistDir, "auth", "oauth", "kimi-coding.js"), + "openai-codex": join(aiDistDir, "auth", "oauth", "openai-codex.js"), + openrouter: join(aiDistDir, "auth", "oauth", "openrouter.js"), + radius: join(aiDistDir, "auth", "oauth", "radius.js"), + xai: join(aiDistDir, "auth", "oauth", "xai.js"), + }, + outdir: dirname(bedrockLoaderOutput), + splitting: false, +}); + +const imageResizeWorkerOutput = resolve(dirname(bedrockLoaderOutput), "image-resize-worker.js"); +if (dirname(imageResizeOutput) !== dirname(imageResizeWorkerOutput)) { + throw new Error("Image resize implementation and worker were emitted into different directories"); +} + +validateExternalImports([mainResult.metafile, lazyResult.metafile]); +chmodSync(join(bundleDir, "cli.js"), 0o755); +chmodSync(join(bundleDir, "rpc-entry.js"), 0o755); + +const files = new Set([...Object.keys(mainResult.metafile.outputs), ...Object.keys(lazyResult.metafile.outputs)]).size; +const mib = outputBytes([mainResult.metafile, lazyResult.metafile]) / (1024 * 1024); +console.log(`Built ${relative(repoRoot, bundleDir)} (${files} files, ${mib.toFixed(1)} MiB)`); diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 7f0c0052d72..739bffd1570 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -13,6 +13,7 @@ const packages = [ { directory: "packages/protocol", name: "@earendil-works/pi-protocol" }, { directory: "packages/client", name: "@earendil-works/pi-client" }, { directory: "packages/session-backends/sqlite-node", name: "@earendil-works/pi-session-backend-sqlite-node" }, + { directory: "packages/server", name: "@earendil-works/pi-server" }, { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, ]; @@ -194,7 +195,9 @@ function packPackage(pkg, tarballDirectory) { capture: true, cwd: pkg.directory, }); - const packed = JSON.parse(output)[0]; + // npm <11.6 returns an array; newer npm returns an object keyed by package name. + const parsed = JSON.parse(output); + const packed = Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]; return join(tarballDirectory, packed.filename); } diff --git a/scripts/profile-coding-agent-node.mjs b/scripts/profile-coding-agent-node.mjs index 79d8d6a6bfc..3d2fb5b78f0 100644 --- a/scripts/profile-coding-agent-node.mjs +++ b/scripts/profile-coding-agent-node.mjs @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; @@ -9,6 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); const packageDir = join(repoRoot, "packages", "coding-agent"); const distCliPath = join(packageDir, "dist", "cli.js"); +const bundledDistCliPath = join(packageDir, "dist", "bundle", "cli.js"); const srcCliPath = join(packageDir, "src", "cli.ts"); const defaultNodeProfileDir = join(repoRoot, "profiles-node"); const defaultBunProfileDir = join(repoRoot, "profiles-bun"); @@ -35,8 +36,9 @@ Options: --runtime node, bun, or auto (default: auto) --agent-dir

Use a specific PI_CODING_AGENT_DIR for the benchmark run --isolated-agent-dir Use a fresh temporary agent dir instead of the normal one + --bundle Build and profile the bundled Node entrypoint instead of dist/cli.js --no-offline Do not force PI_OFFLINE=1 / PI_SKIP_VERSION_CHECK=1 - --skip-build Reuse the current dist/cli.js without rebuilding first (Node only) + --skip-build Reuse the selected build output without rebuilding first (Node only) --cpu-profile Write CPU profiles for benchmark runs --help Show this help @@ -73,6 +75,7 @@ function parseMode(value) { function parseArgs(argv) { const options = { mode: "tui", + bundle: false, runs: 1, warmup: 0, profileDir: undefined, @@ -103,6 +106,11 @@ function parseArgs(argv) { continue; } + if (arg === "--bundle") { + options.bundle = true; + continue; + } + if (arg === "--skip-build") { options.build = false; continue; @@ -221,7 +229,7 @@ function parseStartupTimings(stderr) { let inBlock = false; for (const line of lines) { - if (line.includes("--- Startup Timings ---")) { + if (/^--- Startup Timings(?:: [^-]+)? ---$/.test(line.trim())) { inBlock = true; continue; } @@ -278,59 +286,72 @@ async function waitForExit(child, errorPrefix) { }); } -async function runBuild() { - process.stdout.write("Building packages/tui, packages/telemetry, packages/ai, packages/agent, and packages/coding-agent...\n"); +async function runBuild(bundle) { + process.stdout.write( + `Building dependencies and the ${bundle ? "bundled" : "unbundled"} coding-agent Node entrypoint...\n`, + ); const startedAt = performance.now(); - const child = spawn( - "npm", - [ - "run", - "build", - "--workspace", - "packages/tui", - "--workspace", - "packages/telemetry", - "--workspace", - "packages/ai", - "--workspace", - "packages/agent", - "--workspace", - "packages/coding-agent", - ], + const commands = [ { + label: "Dependency build", + args: [ + "run", + "build", + "--workspace", + "packages/tui", + "--workspace", + "packages/telemetry", + "--workspace", + "packages/ai", + "--workspace", + "packages/agent", + "--workspace", + "packages/protocol", + "--workspace", + "packages/client", + ], + }, + { + label: "Coding-agent build", + args: ["run", bundle ? "build" : "build:unbundled", "--workspace", "packages/coding-agent"], + }, + ]; + + for (const command of commands) { + const child = spawn("npm", command.args, { cwd: repoRoot, env: process.env, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", - }, - ); + }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); - const exitCode = await waitForExit(child, "Build"); - if (exitCode !== 0) { - if (stdout.trim()) { - process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`); - } - if (stderr.trim()) { - process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`); + const exitCode = await waitForExit(child, command.label); + if (exitCode !== 0) { + if (stdout.trim()) { + process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`); + } + if (stderr.trim()) { + process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`); + } + throw new Error(`${command.label} failed with exit code ${exitCode}`); } - throw new Error(`Build failed with exit code ${exitCode}`); } process.stdout.write(`Build completed in ${formatMs(performance.now() - startedAt)}\n`); } -function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { +function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile, nodeEntryPath) { const benchmarkArgs = ["--no-session"]; if (mode === "rpc") { benchmarkArgs.push("--mode", "rpc"); @@ -352,7 +373,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { if (cpuProfile) { args.push("--cpu-prof", `--cpu-prof-dir=${profileDir}`, `--cpu-prof-name=${profileName}`); } - args.push(distCliPath, ...benchmarkArgs); + args.push(nodeEntryPath, ...benchmarkArgs); return { executable: process.execPath, args, @@ -360,7 +381,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { } function createBenchmarkEnv(options, isolatedAgentDir) { - const env = { ...process.env }; + const env = { ...process.env, PI_TIMING: "1" }; if (options.agentDir) { env[agentDirEnvName] = options.agentDir; } else if (isolatedAgentDir) { @@ -386,11 +407,12 @@ async function runTuiBenchmarkRun({ runtime, runIndex, measuredIndex, options, p mkdirSync(isolatedAgentDir, { recursive: true }); } - const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile); + const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath; + const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile, nodeEntryPath); const child = spawn(command.executable, command.args, { cwd: packageDir, env: createBenchmarkEnv(options, isolatedAgentDir), - stdio: ["inherit", "ignore", "pipe"], + stdio: ["inherit", "inherit", "pipe"], shell: process.platform === "win32" && runtime === "bun", }); @@ -445,7 +467,8 @@ async function runRpcBenchmarkRun({ runtime, runIndex, measuredIndex, options, p mkdirSync(isolatedAgentDir, { recursive: true }); } - const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile); + const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath; + const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile, nodeEntryPath); const child = spawn(command.executable, command.args, { cwd: packageDir, env: createBenchmarkEnv(options, isolatedAgentDir), @@ -547,11 +570,14 @@ async function main() { } const runtime = resolveRuntime(options.runtime); + if (options.bundle && runtime !== "node") { + throw new Error("--bundle only supports the Node runtime"); + } options.label = resolveLabel(options.mode, options.label); const profileDir = resolveProfileDir(runtime, options.profileDir); if (runtime === "node" && options.build) { - await runBuild(); + await runBuild(options.bundle); } if (runtime === "bun") { process.stdout.write( @@ -559,7 +585,16 @@ async function main() { ); } - const entryPath = runtime === "bun" ? srcCliPath : distCliPath; + const entryPath = runtime === "bun" ? srcCliPath : options.bundle ? bundledDistCliPath : distCliPath; + if ( + runtime === "node" && + !options.bundle && + !options.build && + existsSync(distCliPath) && + readFileSync(distCliPath, "utf8").includes('import "./bundle/cli.js";') + ) { + throw new Error("dist/cli.js is a bundled facade; rerun without --skip-build for an unbundled profile"); + } if (!existsSync(entryPath)) { throw new Error(`CLI entrypoint not found: ${entryPath}`); } @@ -597,7 +632,7 @@ async function main() { const maxElapsedRun = measuredRuns.reduce((slowest, run) => (run.elapsedMs > slowest.elapsedMs ? run : slowest)); if (measuredRuns.length === 1) { process.stdout.write("\nResult\n"); - process.stdout.write(` runtime: ${runtime}\n`); + process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`); process.stdout.write(` mode: ${options.mode}\n`); process.stdout.write(` elapsed: ${formatMs(measuredRuns[0].elapsedMs)}\n`); for (const [label, summary] of timingSummaries.entries()) { @@ -615,7 +650,7 @@ async function main() { } process.stdout.write("\nSummary\n"); - process.stdout.write(` runtime: ${runtime}\n`); + process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`); process.stdout.write(` mode: ${options.mode}\n`); process.stdout.write(` elapsed min: ${formatMs(elapsedSummary.min)}\n`); process.stdout.write(` elapsed median: ${formatMs(elapsedSummary.median)}\n`); diff --git a/scripts/publish-release-announcement.mjs b/scripts/publish-release-announcement.mjs new file mode 100644 index 00000000000..a0a090f2709 --- /dev/null +++ b/scripts/publish-release-announcement.mjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +const RELEASES_PREFIX = "releases/v1"; +const INSTALLER_PREFIX = "installer/v1"; +const INSTALLER_PACKAGE_NAME = "@earendil-works/pi-coding-agent-install"; +const REGISTRY_URL = "https://registry.npmjs.org"; +const RETRY_DELAY_MS = 5000; +const RETRY_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_POINTER_UPDATE_ATTEMPTS = 5; +const STABLE_SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +function parseArgs(args) { + const options = { + bucket: undefined, + endpoint: undefined, + installerPackageJson: undefined, + installerPackageLock: undefined, + sourceCommit: undefined, + version: undefined, + }; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if ( + arg !== "--bucket" && + arg !== "--endpoint" && + arg !== "--installer-package-json" && + arg !== "--installer-package-lock" && + arg !== "--source-commit" && + arg !== "--version" + ) { + throw new Error(`Unknown argument: ${arg}`); + } + const value = args[++index]; + if (!value) throw new Error(`${arg} requires a value`); + options[ + { + "--bucket": "bucket", + "--endpoint": "endpoint", + "--installer-package-json": "installerPackageJson", + "--installer-package-lock": "installerPackageLock", + "--source-commit": "sourceCommit", + "--version": "version", + }[arg] + ] = value; + } + + if (!options.bucket) throw new Error("--bucket is required"); + if (!options.endpoint) throw new Error("--endpoint is required"); + if (!options.version || !STABLE_SEMVER_RE.test(options.version)) { + throw new Error("--version must be a stable semver version"); + } + if (!options.installerPackageJson || !options.installerPackageLock) { + throw new Error("--installer-package-json and --installer-package-lock are required"); + } + return options; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function resolvePackageFromNpm(pkg) { + const packageUrl = `${REGISTRY_URL}/${encodeURIComponent(pkg.name)}/${pkg.version}`; + const response = await fetch(packageUrl, { headers: { accept: "application/json" } }); + if (!response.ok) { + throw new Error(`${pkg.name}@${pkg.version}: npm registry returned ${response.status}`); + } + + const data = await response.json(); + if ( + typeof data !== "object" || + data === null || + Array.isArray(data) || + data.version !== pkg.version || + typeof data.dist !== "object" || + data.dist === null || + Array.isArray(data.dist) || + typeof data.dist.tarball !== "string" || + typeof data.dist.integrity !== "string" + ) { + throw new Error(`${pkg.name}@${pkg.version}: npm registry returned invalid metadata`); + } + + const tarball = await fetch(data.dist.tarball, { method: "HEAD" }); + if (!tarball.ok) { + throw new Error(`${pkg.name}@${pkg.version}: tarball returned ${tarball.status}`); + } + + return { + name: pkg.name, + version: pkg.version, + tarball: data.dist.tarball, + integrity: data.dist.integrity, + }; +} + +async function verifyPackagesAreAvailable(packages) { + const deadline = Date.now() + RETRY_TIMEOUT_MS; + let attempt = 0; + let failures = []; + + do { + attempt++; + const results = await Promise.allSettled(packages.map(resolvePackageFromNpm)); + failures = results.flatMap((result, index) => + result.status === "rejected" ? [`${packages[index].name}: ${result.reason}`] : [], + ); + if (failures.length === 0) { + console.log(`All ${packages.length} Pi packages are available from npm (attempt ${attempt}).`); + return results.map((result) => result.value); + } + + console.log(`Waiting for ${failures.length} Pi package${failures.length === 1 ? "" : "s"} on npm (attempt ${attempt}):`); + for (const failure of failures) console.log(` ${failure}`); + if (Date.now() < deadline) await sleep(RETRY_DELAY_MS); + } while (Date.now() < deadline); + + throw new Error(`Timed out waiting for Pi packages to become available from npm:\n${failures.map((failure) => ` ${failure}`).join("\n")}`); +} + +function gitSourceCommit() { + return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); +} + +function runAws(args, { allowNotFound = false, allowPreconditionFailure = false } = {}) { + const result = spawnSync("aws", args, { + encoding: "utf8", + env: { + ...process.env, + AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION || "auto", + AWS_EC2_METADATA_DISABLED: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status === 0) return result.stdout; + + const message = `${result.stdout}\n${result.stderr}`.trim(); + if (allowNotFound && /(?:404|NoSuchKey|Not Found)/i.test(message)) return undefined; + if (allowPreconditionFailure && /(?:412|PreconditionFailed|ConditionalRequestConflict)/i.test(message)) { + return undefined; + } + throw new Error(`aws ${args.slice(0, 2).join(" ")} failed:\n${message}`); +} + +function readLatestRelease(bucket, endpoint, key, outputPath) { + const head = runAws( + [ + "s3api", + "head-object", + "--bucket", + bucket, + "--key", + key, + "--endpoint-url", + endpoint, + ], + { allowNotFound: true }, + ); + if (head === undefined) return undefined; + + const metadata = JSON.parse(head); + if (typeof metadata.ETag !== "string") { + throw new Error("Latest Pi release marker has no ETag."); + } + runAws([ + "s3api", + "get-object", + "--bucket", + bucket, + "--key", + key, + "--endpoint-url", + endpoint, + outputPath, + ]); + const release = JSON.parse(readFileSync(outputPath, "utf8")); + if ( + typeof release !== "object" || + release === null || + Array.isArray(release) || + typeof release.version !== "string" || + !STABLE_SEMVER_RE.test(release.version) + ) { + throw new Error("Latest Pi release marker has an invalid version."); + } + return { etag: metadata.ETag, version: release.version }; +} + +function putObject(bucket, endpoint, path, key, cacheControl, condition) { + const args = [ + "s3api", + "put-object", + "--bucket", + bucket, + "--key", + key, + "--body", + path, + "--endpoint-url", + endpoint, + "--content-type", + "application/json; charset=utf-8", + "--cache-control", + cacheControl, + ]; + if (condition?.etag) args.push("--if-match", condition.etag); + if (condition?.missing) args.push("--if-none-match", "*"); + return runAws(args, { allowPreconditionFailure: Boolean(condition) }) !== undefined; +} + +function putJson(bucket, endpoint, path, key, cacheControl, condition) { + return putObject(bucket, endpoint, path, key, cacheControl, condition); +} + +function validateInstallerArtifacts(packageJsonPath, packageLockPath, version) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const packageLock = JSON.parse(readFileSync(packageLockPath, "utf8")); + const root = packageLock.packages?.[""]; + + if (packageJson.name !== INSTALLER_PACKAGE_NAME || packageJson.version !== version) { + throw new Error(`Installer package.json must describe ${INSTALLER_PACKAGE_NAME}@${version}`); + } + if ( + packageLock.lockfileVersion !== 3 || + packageLock.version !== version || + root?.version !== version || + root.dependencies?.["@earendil-works/pi-coding-agent"] !== version + ) { + throw new Error(`Installer package-lock.json must describe Pi ${version}`); + } +} + +export function compareReleaseVersions(left, right) { + const leftMatch = STABLE_SEMVER_RE.exec(left); + const rightMatch = STABLE_SEMVER_RE.exec(right); + if (!leftMatch || !rightMatch) throw new Error("Release versions must be stable semver versions."); + + for (const index of [1, 2, 3]) { + const difference = Number(leftMatch[index]) - Number(rightMatch[index]); + if (difference !== 0) return difference; + } + return 0; +} + +export async function advanceLatestRelease(version, readLatest, writeLatest) { + for (let attempt = 0; attempt < MAX_POINTER_UPDATE_ATTEMPTS; attempt++) { + const current = await readLatest(); + if (current && compareReleaseVersions(version, current.version) <= 0) { + return { advanced: false, version: current.version }; + } + + const updated = await writeLatest(current ? { etag: current.etag } : { missing: true }); + if (updated) { + return { advanced: true, version }; + } + } + throw new Error(`Could not advance the Pi release marker to ${version} after ${MAX_POINTER_UPDATE_ATTEMPTS} attempts.`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const packages = getPublicWorkspacePackages(); + for (const pkg of packages) { + if (pkg.version !== options.version) { + throw new Error(`${pkg.name} is ${pkg.version}; expected ${options.version}`); + } + } + + const publishedPackages = await verifyPackagesAreAvailable(packages); + const release = { + schemaVersion: 1, + version: options.version, + sourceCommit: options.sourceCommit ?? gitSourceCommit(), + publishedAt: new Date().toISOString(), + packages: publishedPackages, + }; + const temporaryDirectory = mkdtempSync(join(tmpdir(), "pi-release-announcement-")); + try { + const releasePath = join(temporaryDirectory, "release.json"); + const latestPath = join(temporaryDirectory, "latest.json"); + writeFileSync(releasePath, `${JSON.stringify(release, null, "\t")}\n`); + if ( + !putJson( + options.bucket, + options.endpoint, + releasePath, + `${RELEASES_PREFIX}/releases/${options.version}.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ) + ) { + console.log(`Release record ${options.version} already exists.`); + } + + writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`); + validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); + const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; + putObject( + options.bucket, + options.endpoint, + options.installerPackageJson, + `${installerReleasePrefix}/package.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putObject( + options.bucket, + options.endpoint, + options.installerPackageLock, + `${installerReleasePrefix}/package-lock.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putJson( + options.bucket, + options.endpoint, + releasePath, + `${installerReleasePrefix}/metadata.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + const installerLatest = await advanceLatestRelease( + options.version, + () => + readLatestRelease( + options.bucket, + options.endpoint, + `${INSTALLER_PREFIX}/latest.json`, + join(temporaryDirectory, "installer-latest-current.json"), + ), + (condition) => + putJson( + options.bucket, + options.endpoint, + latestPath, + `${INSTALLER_PREFIX}/latest.json`, + "no-store", + condition, + ), + ); + console.log( + installerLatest.advanced + ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` + : `Pi ${installerLatest.version} is already the latest installer release.`, + ); + + const result = await advanceLatestRelease( + options.version, + () => + readLatestRelease( + options.bucket, + options.endpoint, + `${RELEASES_PREFIX}/latest.json`, + join(temporaryDirectory, "latest-current.json"), + ), + (condition) => + putJson( + options.bucket, + options.endpoint, + latestPath, + `${RELEASES_PREFIX}/latest.json`, + "no-store", + condition, + ), + ); + console.log( + result.advanced + ? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json` + : `Pi ${result.version} is already the latest announced release.`, + ); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/publish-release-announcement.test.mjs b/scripts/publish-release-announcement.test.mjs new file mode 100644 index 00000000000..1a51146dd7d --- /dev/null +++ b/scripts/publish-release-announcement.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { advanceLatestRelease, compareReleaseVersions } from "./publish-release-announcement.mjs"; + +test("compares stable release versions numerically", () => { + assert.ok(compareReleaseVersions("0.85.0", "0.84.9") > 0); + assert.ok(compareReleaseVersions("0.84.10", "0.84.9") > 0); + assert.equal(compareReleaseVersions("0.84.0", "0.84.0"), 0); + assert.throws(() => compareReleaseVersions("0.85.0-beta.1", "0.84.0")); +}); + +test("does not regress an existing newer release marker", async () => { + let writeCount = 0; + const result = await advanceLatestRelease( + "0.84.0", + async () => ({ etag: '"newer"', version: "0.85.0" }), + async () => { + writeCount++; + return true; + }, + ); + + assert.deepEqual(result, { advanced: false, version: "0.85.0" }); + assert.equal(writeCount, 0); +}); + +test("retries a lost conditional update and preserves a racing newer marker", async () => { + let readCount = 0; + let writeCount = 0; + const result = await advanceLatestRelease( + "0.84.0", + async () => { + readCount++; + return readCount === 1 + ? { etag: '"previous"', version: "0.83.0" } + : { etag: '"newer"', version: "0.85.0" }; + }, + async (condition) => { + writeCount++; + assert.deepEqual(condition, { etag: '"previous"' }); + return false; + }, + ); + + assert.deepEqual(result, { advanced: false, version: "0.85.0" }); + assert.equal(writeCount, 1); +}); + +test("creates a missing marker with an if-none-match condition", async () => { + let condition; + const result = await advanceLatestRelease( + "0.84.0", + async () => undefined, + async (value) => { + condition = value; + return true; + }, + ); + + assert.deepEqual(result, { advanced: true, version: "0.84.0" }); + assert.deepEqual(condition, { missing: true }); +}); diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 3ad86961bb5..0ce30944ec6 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -1,19 +1,11 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { join } from "node:path"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; -const packages = [ - { directory: "packages/telemetry", name: "@earendil-works/pi-telemetry" }, - { directory: "packages/ai", name: "@earendil-works/pi-ai" }, - { directory: "packages/agent", name: "@earendil-works/pi-agent-core" }, - { directory: "packages/protocol", name: "@earendil-works/pi-protocol" }, - { directory: "packages/client", name: "@earendil-works/pi-client" }, - { directory: "packages/session-backends/sqlite-node", name: "@earendil-works/pi-session-backend-sqlite-node" }, - { directory: "packages/tui", name: "@earendil-works/pi-tui" }, - { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, -]; +const packages = getPublicWorkspacePackages(); const dryRun = process.argv.includes("--dry-run"); const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run"); @@ -43,10 +35,6 @@ function run(command, args, options = {}) { return result; } -function readPackageJson(directory) { - return JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); -} - function assertBuildOutputExists(directory) { if (!existsSync(join(directory, "dist"))) { throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`); @@ -77,14 +65,7 @@ function isPublished(name, version) { throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`); } -const packageVersions = new Map(); -for (const pkg of packages) { - const packageJson = readPackageJson(pkg.directory); - if (packageJson.name !== pkg.name) { - throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`); - } - packageVersions.set(pkg.name, packageJson.version); -} +const packageVersions = new Map(packages.map((pkg) => [pkg.name, pkg.version])); const versions = [...new Set(packageVersions.values())]; if (versions.length !== 1) { diff --git a/scripts/release-packages.mjs b/scripts/release-packages.mjs new file mode 100644 index 00000000000..fadc4fc0828 --- /dev/null +++ b/scripts/release-packages.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { findPackageDirectories } from "./package-workspaces.mjs"; + +export function getPublicWorkspacePackages() { + return findPackageDirectories() + .map((directory) => ({ + directory, + ...JSON.parse(readFileSync(join(directory, "package.json"), "utf8")), + })) + .filter((pkg) => pkg.private !== true) + .map(({ directory, name, version }) => ({ directory, name, version })); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 217561cf12d..02e1fd0fc54 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -8,20 +8,22 @@ * * Steps: * 1. Check for uncommitted changes - * 2. Bump version via npm run version:xxx or set an explicit version - * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 4. Regenerate release artifacts - * 5. Run checks and tests - * 6. Commit and tag the release - * 7. Add new [Unreleased] section to changelogs - * 8. Commit next-cycle changelog updates - * 9. Push main and the tag to trigger CI publishing + * 2. Verify every public workspace package is registered on npm + * 3. Bump version via npm run version:xxx or set an explicit version + * 4. Update CHANGELOG.md files: [Unreleased] -> [version] - date + * 5. Regenerate release artifacts + * 6. Run checks and tests + * 7. Commit and tag the release + * 8. Add new [Unreleased] section to changelogs + * 9. Commit next-cycle changelog updates + * 10. Push main and the tag to trigger CI publication and verified pi.dev announcement */ -import { execSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { findPackageDirectories } from "./package-workspaces.mjs"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; const RELEASE_TARGET = process.argv[2]; const BUMP_TYPES = new Set(["major", "minor", "patch"]); @@ -50,6 +52,38 @@ function getVersion() { return pkg.version; } +function assertPackagesAreRegisteredWithNpm() { + const packageNames = getPublicWorkspacePackages().map((pkg) => pkg.name); + const unregisteredPackages = []; + + console.log("Checking npm package registration..."); + for (const packageName of packageNames) { + const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["view", packageName, "version", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status === 0 && result.stdout.trim()) { + console.log(` ${packageName}`); + continue; + } + + const output = [result.stdout, result.stderr, result.error?.message].filter(Boolean).join("\n"); + if (output.includes("E404") || output.includes("404 Not Found")) { + unregisteredPackages.push(packageName); + continue; + } + + throw new Error(output ? `Failed to query npm registration for ${packageName}\n${output}` : `Failed to query npm registration for ${packageName}`); + } + + if (unregisteredPackages.length > 0) { + throw new Error(`The following public workspace packages are not registered on npm:\n${unregisteredPackages.map((packageName) => ` ${packageName}`).join("\n")}\nRegister them before running a release.`); + } + + console.log(" All public workspace packages are registered on npm\n"); +} + function compareVersions(a, b) { const aParts = a.split(".").map(Number); const bParts = b.split(".").map(Number); @@ -70,10 +104,7 @@ function shellQuote(value) { function removeStaleWorkspaceLockEntries() { const workspaceVersions = new Map( - findPackageDirectories() - .map((directory) => JSON.parse(readFileSync(join(directory, "package.json"), "utf8"))) - .filter((pkg) => pkg.private !== true) - .map((pkg) => [pkg.name, pkg.version]), + getPublicWorkspacePackages().map((pkg) => [pkg.name, pkg.version]), ); const lockPath = "package-lock.json"; const lock = JSON.parse(readFileSync(lockPath, "utf8")); @@ -190,16 +221,19 @@ if (status && status.trim()) { } console.log(" Working directory clean\n"); -// 2. Bump or set version +// 2. Verify npm package registration before modifying the worktree. +assertPackagesAreRegisteredWithNpm(); + +// 3. Bump or set version const version = bumpOrSetVersion(RELEASE_TARGET); console.log(` New version: ${version}\n`); -// 3. Update changelogs +// 4. Update changelogs console.log("Updating CHANGELOG.md files..."); updateChangelogsForRelease(version); console.log(); -// 4. Regenerate release artifacts +// 5. Regenerate release artifacts console.log("Regenerating release artifacts..."); run("npm run generate:models"); run("npm run check:model-data"); @@ -207,7 +241,7 @@ run("npm run shrinkwrap:coding-agent"); run("npm run install-lock:coding-agent"); console.log(); -// 5. Run checks and tests +// 6. Run checks and tests console.log("Running checks..."); run("npm run check"); console.log(); @@ -220,28 +254,28 @@ console.log("Running tests..."); run("./test.sh"); console.log(); -// 6. Commit and tag +// 7. Commit and tag console.log("Committing and tagging..."); stageChangedFiles(); run(`git commit -m "Release v${version}"`); run(`git tag v${version}`); console.log(); -// 7. Add new [Unreleased] sections +// 8. Add new [Unreleased] sections console.log("Adding [Unreleased] sections for next cycle..."); addUnreleasedSection(); console.log(); -// 8. Commit +// 9. Commit console.log("Committing changelog updates..."); stageChangedFiles(); run(`git commit -m "Add [Unreleased] section for next cycle"`); console.log(); -// 9. Push +// 10. Push console.log("Pushing to remote..."); run("git push origin main"); run(`git push origin v${version}`); console.log(); -console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`); +console.log(`=== Prepared release v${version}; CI publication and pi.dev announcement start after the tag push ===`);