diff --git a/.bazelrc b/.bazelrc index df4bdbbd3..dd55c65b0 100644 --- a/.bazelrc +++ b/.bazelrc @@ -10,6 +10,19 @@ common --enable_platform_specific_config # PATH through crate annotations in MODULE.bazel. build --incompatible_strict_action_env +# Third-party crate resolution (rules_rust crate_universe) shells out to +# `cargo-bazel splice`, which runs `cargo metadata` + fetches every git/registry +# dep. Two knobs matter on cold caches: +# 1. CARGO_BAZEL_ISOLATED=0 — reuse the host's ~/.cargo registry/index instead +# of building a throwaway cargo home per invocation. Without this, every +# splice re-clones the entire crates.io index (~1 GiB, minutes on cold DNS). +# 2. CARGO_BAZEL_TIMEOUT=1800 — cargo-bazel honors this to raise Bazel's +# default `repository_ctx.execute` 600s cap. Fresh machines / ephemeral +# runners with git-hosted forks (brush, uutils) blow past 600s and abort +# with `Timed out` (rules_rust common_utils.bzl:61) before we can help. +common --repo_env=CARGO_BAZEL_ISOLATED=0 +common --repo_env=CARGO_BAZEL_TIMEOUT=1800 + # NOTE: rust pipelined_compilation stays OFF: handing dependents rmeta-only # crates breaks `rust_test(crate = ...)` harness compiles whose deps export # macro_rules! ("can't find crate" at macro expansion). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f76f45d4d..65b7cde66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,7 +319,7 @@ jobs: # Not `test:scripts`: scripts/musl-release.test.ts fails on main # (its install.sh smoke-check executes a fake binary), so running # the whole group here would red this job on an unrelated break. - bun test scripts/release.test.ts + bun test scripts/ci-test-ts.test.ts scripts/release.test.ts test_coding_agent_singleton: name: Test coding-agent singleton/global-state (TS) diff --git a/Cargo.lock b/Cargo.lock index da2b7365c..296196d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2029,9 +2029,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "event-listener" @@ -2146,9 +2146,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixed_decimal" @@ -3261,9 +3261,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -3590,9 +3590,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -4880,7 +4880,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "17.3.4" +version = "17.3.5" dependencies = [ "anyhow", "ast-grep-core", @@ -4949,7 +4949,7 @@ dependencies = [ [[package]] name = "pi-builtins" -version = "17.3.4" +version = "17.3.5" dependencies = [ "ansi-width", "anyhow", @@ -5034,7 +5034,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "17.3.4" +version = "17.3.5" dependencies = [ "async-trait", "libc", @@ -5046,7 +5046,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "17.3.4" +version = "17.3.5" dependencies = [ "anyhow", "arboard", @@ -5118,7 +5118,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "17.3.4" +version = "17.3.5" dependencies = [ "anyhow", "brush-core", @@ -5146,7 +5146,7 @@ dependencies = [ [[package]] name = "pi-voice" -version = "17.3.4" +version = "17.3.5" dependencies = [ "audiopus_sys", "bytes", @@ -5161,7 +5161,7 @@ dependencies = [ [[package]] name = "pi-walker" -version = "17.3.4" +version = "17.3.5" dependencies = [ "dashmap", "globset", @@ -5937,9 +5937,9 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safe_arch" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" dependencies = [ "bytemuck", ] @@ -7673,9 +7673,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7819,9 +7819,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", diff --git a/Cargo.toml b/Cargo.toml index 78aa9113f..7da5d4b32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ resolver = "3" [workspace.package] -version = "17.3.4" +version = "17.3.5" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/bun.lock b/bun.lock index e1fbf1345..d0f4353b9 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "17.3.4", + "version": "17.3.5", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -40,7 +40,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "17.3.4", + "version": "17.3.5", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -63,7 +63,7 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "17.3.4", + "version": "17.3.5", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -76,7 +76,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "17.3.4", + "version": "17.3.5", "bin": { "omp": "src/cli.ts", }, @@ -133,7 +133,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "17.3.4", + "version": "17.3.5", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -177,7 +177,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "17.3.4", + "version": "17.3.5", "bin": { "mnemopi": "src/cli.ts", }, @@ -203,7 +203,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "17.3.4", + "version": "17.3.5", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -211,7 +211,7 @@ }, "packages/omptype": { "name": "@oh-my-pi/omptype", - "version": "17.3.4", + "version": "17.3.5", "devDependencies": { "@ark/attest": "0.56.3", "@ark/schema": "0.56.2", @@ -224,7 +224,7 @@ }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "17.3.4", + "version": "17.3.5", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -238,7 +238,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "17.3.4", + "version": "17.3.5", "bin": { "omp-stats": "./src/index.ts", }, @@ -263,7 +263,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "17.3.4", + "version": "17.3.5", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -299,7 +299,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "17.3.4", + "version": "17.3.5", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", }, @@ -309,7 +309,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "17.3.4", + "version": "17.3.5", "devDependencies": { "@types/bun": "catalog:", }, @@ -347,19 +347,19 @@ "@bufbuild/protoc-gen-es": "^2.12.1", "@huggingface/transformers": "^4.2.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.3.4", - "@oh-my-pi/omp-stats": "17.3.4", - "@oh-my-pi/omptype": "17.3.4", - "@oh-my-pi/pi-agent-core": "17.3.4", - "@oh-my-pi/pi-ai": "17.3.4", - "@oh-my-pi/pi-catalog": "17.3.4", - "@oh-my-pi/pi-coding-agent": "17.3.4", - "@oh-my-pi/pi-mnemopi": "17.3.4", - "@oh-my-pi/pi-natives": "17.3.4", - "@oh-my-pi/pi-tui": "17.3.4", - "@oh-my-pi/pi-utils": "17.3.4", - "@oh-my-pi/pi-wire": "17.3.4", - "@oh-my-pi/snapcompact": "17.3.4", + "@oh-my-pi/hashline": "17.3.5", + "@oh-my-pi/omp-stats": "17.3.5", + "@oh-my-pi/omptype": "17.3.5", + "@oh-my-pi/pi-agent-core": "17.3.5", + "@oh-my-pi/pi-ai": "17.3.5", + "@oh-my-pi/pi-catalog": "17.3.5", + "@oh-my-pi/pi-coding-agent": "17.3.5", + "@oh-my-pi/pi-mnemopi": "17.3.5", + "@oh-my-pi/pi-natives": "17.3.5", + "@oh-my-pi/pi-tui": "17.3.5", + "@oh-my-pi/pi-utils": "17.3.5", + "@oh-my-pi/pi-wire": "17.3.5", + "@oh-my-pi/snapcompact": "17.3.5", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -489,7 +489,7 @@ "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], - "@huggingface/tasks": ["@huggingface/tasks@0.21.33", "", {}, "sha512-efQa8g+WjPwzlwk7Wl4eDgbM1Jq+WooUw/b1+eKl2LVUdPMhnbwsFNMJJxhEywaLJvJBy93+jjna6855U9GePw=="], + "@huggingface/tasks": ["@huggingface/tasks@0.21.34", "", {}, "sha512-NRc1vw2Q/nZQPKjlzEkXxi0YNXstI1hXoaVruvJbgeznpXtEmPD0pey/ujIWTnhmsBQIVexr/QJJ5pyIJBPrMw=="], "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], @@ -667,7 +667,7 @@ "@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], "@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.1.0", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", "@napi-rs/wasm-tools-android-arm64": "1.1.0", "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", "@napi-rs/wasm-tools-darwin-x64": "1.1.0", "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" } }, "sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w=="], @@ -789,7 +789,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], + "@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], "@prettier/sync": ["@prettier/sync@0.6.1", "", { "dependencies": { "make-synchronized": "^0.8.0" }, "peerDependencies": { "prettier": "*" } }, "sha512-yF9G8vK/LYUTF3Cijd7VC9La3b20F20/J/fgoR4H0B8JGOWnZVZX6+I6+vODPosjmMcpdlUV+gUqJQZp3kLOcw=="], @@ -813,33 +813,33 @@ "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], @@ -965,7 +965,7 @@ "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1061,7 +1061,7 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.402", "", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.405", "", {}, "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew=="], "emnapi": ["emnapi@1.11.3", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-+/ZS90YK/rYfVOHtGLHkGffVsnmD/MAKaBHio+Y4XAtg75RLr4cveV/w0jTkUdLM1CcAlaRgG76mpIemWAlk0A=="], @@ -1273,7 +1273,7 @@ "robomp-web": ["robomp-web@workspace:python/robomp/web"], - "rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="], + "rolldown": ["rolldown@1.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1459,7 +1459,7 @@ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" }, "bundled": true }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], diff --git a/bunfig.toml b/bunfig.toml index 8800cf685..8f72f95f5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -15,6 +15,11 @@ saveTextLockfile = true [test] # bun test does NOT honor .gitignore; prune robomp's repo clones and # scratch dirs so a root-level `bun test` doesn't walk into them. +# +# `bazel-*` are Bazel's convenience symlinks, and `bazel-oh-my-pi` points at +# the workspace root itself: without this the scanner recurses through that +# loop, exhausts file descriptors, and every test that pipes a child process's +# stdio fails with empty output or EPIPE. pathIgnorePatterns = [ "**/node_modules/**", "**/.git/**", @@ -26,6 +31,8 @@ pathIgnorePatterns = [ "python/robomp/data/**", ".wt/**", ".worktrees/**", + "bazel-*/**", + "**/bazel-out/**", ] [run] diff --git a/crates/pi-builtins/src/xargs.rs b/crates/pi-builtins/src/xargs.rs index ebd9c8d06..976dc7cba 100644 --- a/crates/pi-builtins/src/xargs.rs +++ b/crates/pi-builtins/src/xargs.rs @@ -741,7 +741,7 @@ fn process_input( return Ok(result); } - if !options.no_run_if_empty || have_pending_command { + if have_pending_command || (!options.no_run_if_empty && builder_options.replace.is_none()) { result.combine(current_builder.execute(host)?); } @@ -1468,6 +1468,12 @@ mod tests { assert_eq!(out, "\n"); } + #[test] + fn replace_mode_skips_empty_input_without_r() { + let result = run_simple(&["-I", "{}", "echo", "{}"], ""); + assert_eq!(result, (0, String::new(), String::new())); + } + #[test] fn verbose_echoes_command_line_to_stderr() { let (code, out, err) = run_simple(&["-t", "echo", "a"], "b\n"); diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 0fd19aa41..182de7a34 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -257,7 +257,7 @@ fn create_windows_napi_tokio_runtime() -> Option { /// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in /// `packages/natives/native/index.js` (which derives the name from /// `package.json#version`). -#[napi(js_name = "__piNativesV17_3_4")] +#[napi(js_name = "__piNativesV17_3_5")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index cfcf8a185..8d0f52e99 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -1000,7 +1000,19 @@ fn split_into_tokens_with_ansi(line: &[u16]) -> SmallVec<[Vec; 4]> { if line[i] == ESC && let Some(seq_len) = ansi_seq_len_u16(line, i) { - pending_ansi.extend_from_slice(&line[i..i + seq_len]); + let seq = &line[i..i + seq_len]; + // A sequence that follows visible content closes it (color reset, + // underline off) and must ride along with that token so the closer + // cannot migrate into whitespace discarded at a soft wrap (#8582). + // A sequence after whitespace opens the *next* token's style, so it + // waits for it: gluing it to the whitespace token would keep that + // space alive past the wrap point and open the style on the line + // being broken instead of the one carrying the styled word. + if current.is_empty() || in_whitespace { + pending_ansi.extend_from_slice(seq); + } else { + current.extend_from_slice(seq); + } i += seq_len; continue; } @@ -2003,6 +2015,34 @@ mod tests { assert!(second.contains("world")); } + #[test] + fn test_wrap_text_with_ansi_keeps_trailing_color_reset_before_soft_wrap() { + let data = to_u16("plain \x1b[33mcode\x1b[39m next"); + let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH); + let actual: Vec = lines + .iter() + .map(|line| String::from_utf16_lossy(line)) + .collect(); + + assert_eq!(actual, ["plain \x1b[33mcode\x1b[39m", "next"]); + } + + #[test] + fn test_wrap_text_with_ansi_defers_style_open_after_trailing_space() { + let data = to_u16("read this thread \x1b[4mhttps://example.com/very/long/path\x1b[24m"); + let lines = wrap_text_with_ansi_impl(&data, 40, DEFAULT_TAB_WIDTH); + let actual: Vec = lines + .iter() + .map(|line| String::from_utf16_lossy(line)) + .collect(); + + // The underline opens on the wrapped line that carries the URL: neither + // the discarded space nor a stray `4m`/`24m` pair stays on the head line. + assert_eq!(actual[0], "read this thread"); + assert!(actual[1].starts_with("\x1b[4m")); + assert!(actual[1].contains("https://")); + } + #[test] fn test_wrap_text_with_ansi_resets_strike_without_resetting_colors() { let data = diff --git a/docs/mcp-runtime-lifecycle.md b/docs/mcp-runtime-lifecycle.md index 45b74dc42..e4005d296 100644 --- a/docs/mcp-runtime-lifecycle.md +++ b/docs/mcp-runtime-lifecycle.md @@ -42,7 +42,7 @@ Filtering behavior: - `enableProjectConfig: false` removes project-level entries (`_source.level === "project"`). - `enabled: false` entries are suppressed unless the active-profile user `enabledServers` allowlist names them; the user `disabledServers` denylist always suppresses a same-named entry. -- Exa servers are filtered out by default and API keys are extracted for native Exa tool integration; browser automation MCP servers are filtered when `filterBrowser` is true. +- Exa servers are filtered out by default and API keys are extracted for native Exa tool integration, unless the config explicitly requests Exa tools the native integration does not provide (`web_fetch_exa`, `web_search_advanced_exa`); browser automation MCP servers are filtered when `filterBrowser` is true. Result includes both `configs` and `sources` (metadata used later for provider labeling). diff --git a/docs/non-compaction-retry-policy.md b/docs/non-compaction-retry-policy.md index 8c1142f60..a6098ab4c 100644 --- a/docs/non-compaction-retry-policy.md +++ b/docs/non-compaction-retry-policy.md @@ -54,7 +54,7 @@ Current retryable categories include: The normalized classifier recognizes the transient categories above from structured flags/status and provider-aware text patterns. Classifier refusals remain a separate typed `stopDetails` decision. -Beyond `isRetryableError(...)`, empty generic aborts may enter the same retry engine when no user, dispose, or streaming-edit-guard abort is in progress. An interrupted turn whose tool calls already have matching results can also be continued safely: the failed assistant/tool-result sequence is preserved so completed side effects are not replayed. Resolved stream stalls use the same preserve-and-continue path. +Beyond `isRetryableError(...)`, empty generic aborts may enter the same retry engine when no user, dispose, or streaming-edit-guard abort is in progress. An interrupted turn whose tool calls already have matching results can also be continued safely: the failed assistant/tool-result sequence is preserved so completed side effects are not replayed. Resolved stream stalls and HTTP/2 stream resets (`NGHTTP2_INTERNAL_ERROR`, `NGHTTP2_REFUSED_STREAM`, `HTTP2StreamReset`) use the same preserve-and-continue path. Cursor idle-stall recovery still requires the exec-resolved marker (the Connect stream may still be open); an HTTP/2 RST does not, because the stream is already dead. Retry state is owned by `TurnRecovery`: diff --git a/docs/provider-endpoint-constraints.md b/docs/provider-endpoint-constraints.md index 0193702a2..44dbabe05 100644 --- a/docs/provider-endpoint-constraints.md +++ b/docs/provider-endpoint-constraints.md @@ -235,13 +235,16 @@ Reasoning fields are not interchangeable. - Compat needs both policies: disable reasoning for any tool choice, and disable reasoning only for forced tool choice. -### xAI Grok through Responses/SuperGrok +### xAI Grok through Responses (`xai` and `xai-oauth`) -Keep these independent: +Both the paid API-key provider (`xai` / `XAI_API_KEY`) and SuperGrok OAuth +(`xai-oauth`) chat over `https://api.x.ai/v1/responses`. Keep these independent: -- omit `reasoning.effort` -- include or drop encrypted reasoning replay -- filter reasoning-history wrappers +- omit `reasoning.effort` unless the model is on the Grok effort-capable allowlist +- omit `reasoning.summary` (the host rejects it; do not fall back to `"auto"`) +- omit presence/frequency penalties (`/v1/responses` rejects them for every Grok model) +- include `reasoning.encrypted_content` on the request +- replay encrypted reasoning items on later turns Some models reject only one of those fields; do not collapse them into one "Grok mode" branch. diff --git a/docs/provider-quirks.md b/docs/provider-quirks.md index 294394a8d..674342249 100644 --- a/docs/provider-quirks.md +++ b/docs/provider-quirks.md @@ -152,7 +152,7 @@ The Anthropic provider (`packages/ai/src/providers/anthropic.ts`) implements the - **Claude Code Fingerprint Headers & Betas**: Default headers include `anthropic-version: 2023-06-01`, `anthropic-dangerous-direct-browser-access: true`, `x-app: cli`, and `User-Agent: claude-cli/2.1.220 (external, claude-desktop)` (`coworkUserAgent`). Active beta flags (`buildCoworkBetas`) include `claude-code-20250219`, `interleaved-thinking-2025-05-14`, `thinking-token-count-2026-05-13`, `context-management-2025-06-27`, `prompt-caching-scope-2026-01-05`, `mid-conversation-system-2026-04-07`, `advanced-tool-use-2025-11-20`, `effort-2025-11-24`, and `fallback-credit-2026-06-01` (`context-1m-2025-08-07` is omitted to avoid 429 credit errors on subscription tokens, #7238). Fingerprint metadata (`generateClaudeCloakingUserId`, `deriveClaudeDeviceId`, `generateClaudeJsonUserId`) generates device/session IDs. Billing attestation headers (`createClaudeBillingHeader`, `wrapFetchForCch`, `patchCch`) embed `cch=00000` XXHash64 hashes into `system[0]`. - **System-Prompt Injection**: `buildAnthropicSystemBlocks` (`packages/ai/src/providers/anthropic.ts`) automatically prepends `claudeCodeSystemInstruction` ("You are a Claude agent, built on Anthropic's Claude Agent SDK.") as `system[0]` for OAuth credentials. Mid-conversation system messages in turn history are enabled for Opus 4.8+ / Sonnet 5+ via `mid-conversation-system-2026-04-07`. - **Thinking Signatures & Redacted Thinking**: Replaying modified or unsigned thinking blocks causes Anthropic API errors (`invalid signature in thinking block`). `convertAnthropicMessages` converts `ThinkingContent` and `RedactedThinkingContent` (`type: "redacted_thinking"`, `data`). `maybeAddReplayUnsignedThinkingHint` attaches recovery hints on signature errors, while `unwrapAnthropicThinkingEnvelope` strips legacy `` XML wrappers. -- **Tool Use Replay & Prefixes**: `encodeAnthropicToolName` / `decodeAnthropicToolName` (`packages/ai/src/providers/anthropic.ts`) prefixes custom tool names with `_` (`claudeToolPrefix`) when using OAuth to prevent collisions with built-in tools (`web_search`, `code_execution`, `text_editor`, `computer`). Server-executed web searches (`ServerToolUseBlockParam`, `WebSearchToolResultBlockParam` in `anthropic-wire.ts`) are detected via `isAnthropicWebSearchHistoryBlock` for turn replay. Empty tool errors are filled by `ensureErrorToolResultWireContent`. +- **Tool Use Replay & Prefixes**: `encodeAnthropicToolName` / `decodeAnthropicToolName` (`packages/ai/src/providers/anthropic.ts`) prefixes custom tool names with `_` (`claudeToolPrefix`) when using OAuth to prevent collisions with built-in tools (`web_search`, `code_execution`, `text_editor`, `computer`). Server-executed web searches and tool searches (`AnthropicServerToolHistoryBlockParam` in `anthropic-wire.ts`) are detected via `isAnthropicServerToolHistoryBlock` for turn replay. Empty tool errors are filled by `ensureErrorToolResultWireContent`. - **Strict-Tool Schema Normalization & Fallback**: `normalizeAnthropicToolSchema` and `normalizeAnthropicStrictSchema` strip unsupported JSON schema keywords (e.g. `minItems`/`maxItems` on objects) for the `structured-outputs-2025-12-15` beta. If a strict tool schema causes HTTP 400, `streamAnthropicOnce` calls `dropAnthropicStrictTools` and automatically retries without strict mode. - **Adaptive vs Budget Thinking**: `ThinkingConfigParam` (`anthropic-wire.ts`) supports budget thinking (`{ type: "enabled", budget_tokens: N }` enforced by `ensureMaxTokensForThinking`) and adaptive thinking (`{ type: "adaptive" }` paired with `output_config: { effort: level }` via `effort-2025-11-24` beta). Forced tool choices (`disableThinkingIfToolChoiceForced`) automatically disable thinking. - **Prompt Cache Breakpoints**: `applyPromptCaching` (`packages/ai/src/providers/anthropic.ts`) attaches `{ type: "ephemeral", scope: "global" }` breakpoints to system prompts (`cacheSystemPrefixBreakpoints`), tool definitions, and historical user turns. `enforceCacheControlLimit` caps total breakpoints to 4 per request. @@ -1456,7 +1456,7 @@ Umans AI Coding Plan is a proxy service for AI coding models, operating via the ### Auth & usage - **Auth**: Uses `UMANS_AI_CODING_PLAN_API_KEY` environment variable or `/login umans` key prompt (`packages/ai/src/registry/umans.ts`, `packages/ai/src/registry/registry.ts`). Key validation executes a lightweight Anthropic messages call (`max_tokens: 1`) to `https://api.code.umans.ai/v1/messages`. - **Usage endpoint**: Fetches quota and rate limit status from `GET /v1/usage` (`packages/ai/src/usage/umans.ts`) using `Authorization: Bearer `. -- **Limits surfaced**: Returns a rolling 5-hour request limit (`umans:requests`) and an instantaneous session concurrency limit (`umans:concurrency`). Also surfaces low-priority status notes when rate-limit bursts occur. +- **Limits surfaced**: Returns a rolling 5-hour request split into a model-weighted soft cap (`umans:requests:soft`, the "effective requests" contract) and a raw burst ceiling (`umans:requests:hard`, `hard_cap`), plus an instantaneous session concurrency limit (`umans:concurrency`). The soft cap only ever warns — `exhausted` is reserved for the burst ceiling, where throttling actually starts. Payloads without a reported burst ceiling (`hard_cap`) collapse to a single weighted `umans:requests` row that can exhaust at the effective-request limit, so request exhaustion is never unreportable; legacy payloads without weighted counters fall back to a single raw `umans:requests` row. In both single-row shapes the weighted counter (when present) stays authoritative — raw burst traffic above the limit never fabricates an exhausted state. Also surfaces low-priority status notes when rate-limit bursts occur. ### Catalog model handling - **Descriptor & discovery**: Registered as `umans` with default model `umans-coder` (`packages/catalog/src/provider-models/descriptors.ts`). Dynamic discovery fetches model details from `GET /v1/models/info` (`packages/catalog/src/provider-models/openai-compat.ts`). diff --git a/docs/system-prompt-customization.md b/docs/system-prompt-customization.md index 3fd66ede7..eb366635d 100644 --- a/docs/system-prompt-customization.md +++ b/docs/system-prompt-customization.md @@ -47,7 +47,9 @@ The custom template keeps these generated surfaces: - always-apply rules and the rulebook listing; - secret-redaction guidance when enabled. -The separate project/environment footer remains and carries workstation data, deeper-directory context pointers, optional workspace information, current date/cwd, and the final completion requirements. Optional extra system blocks, such as computer-tool safety and active nested-repository context, also remain when applicable. +The separate project/environment footer remains and carries workstation data, deeper-directory context pointers, optional workspace information, and the final completion requirements. Optional extra system blocks, such as computer-tool safety and active nested-repository context, also remain when applicable. + +The current date and working directory no longer live in the footer: they are emitted as a `` block on the first user turn of each provider request (`date-cwd-reminder.md`). Keeping per-request bytes out of the system prompt lets open-weight providers (DeepSeek, Qwen, GLM, …) that render tool schemas after the system content keep their prefix cache, and lets a session crossing midnight refresh the date without rebuilding the prompt (#7404). What disappears is the content unique to the default instruction template: its built-in role/personality text, tool inventory and general tool policy, internal-URL catalog, exploration/delegation/workflow rules, and `xd://` protocol guidance. Generated skills and rules are **not** lost; the custom template renders them explicitly. @@ -79,7 +81,7 @@ on {{#if hasMemoryRoot}}Memory enabled.{{/if}} ``` -those characters reach the model literally. Internal values such as `cwd`, `date`, `skills`, `rules`, and `toolRefs` are private template implementation details, not a user templating API. +those characters reach the model literally. Internal values such as `cwd`, `skills`, `rules`, and `toolRefs` are private template implementation details, not a user templating API. The calendar date is deliberately not exposed as a template value anymore — it rides the per-request first-turn reminder instead (see above). ## Recipes diff --git a/docs/tools/browser.md b/docs/tools/browser.md index b8254c889..e1c7d1cfc 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -65,8 +65,8 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `all` | `boolean` | No | Close every known tab. Omitted closes only `name`. | -| `kill` | `boolean` | No | When a tab release drops a spawned-app browser handle to refcount 0, also terminate its process tree. Has no effect on headless shutdown and only disconnects connected CDP browsers. | +| `all` | `boolean` | No | Release every known managed tab. Omitted releases only `name`. Tool-owned headless pages and owned cmux surfaces close; spawned, connected, and relay pages remain open unless `kill: true` terminates a spawned browser. | +| `kill` | `boolean` | No | When a tab release drops a spawned-app browser handle to refcount 0, also terminate its process tree. Has no effect on headless shutdown; connected and relay browsers are only disconnected. | ### `action: "run"` @@ -160,7 +160,7 @@ The tool returns one result per call; no streaming partial output is emitted fro 18. `tab.click()` uses a custom retry loop for `text/...` selectors to find an actionable visible match; other selectors use `page.locator(...).click()`. Interactive actions (`click`/`fill`/`type`/`press`/`scroll`/`drag`/`scrollIntoView`/`select`/`uploadFile`) and the `waitFor*` helpers run under a per-op deadline (`min(cellBudget − slack, ceiling)`) threaded into both the puppeteer `signal` and `.setTimeout()`, so a stalled helper aborts the CDP action and rejects with a named `tab. timed out after ms` that leaves cell budget — never the opaque whole-cell timeout. `goto`/`evaluate` stay uncapped. 19. `tab.screenshot()` captures the page or selected element as PNG, resizes a model copy, saves under `browser.screenshotDir` or the OS temp directory, returns that path, records metadata, and optionally emits text plus image content. 20. `display()` calls accumulate in an array. After code finishes, the worker posts `{ displays, returnValue, screenshots }`; `BrowserTool.#run()` appends the return value as trailing text content when not `undefined`. -21. `close` releases one tab or all tabs via `releaseTab()` / `releaseAllTabs()`. Each tab aborts pending runs, asks the worker to close, waits up to `750` ms for a `closed` ack, terminates the worker, decrements browser refcount, and disposes the browser handle when refcount reaches zero. +21. `close` releases one managed tab handle or all handles via `releaseTab()` / `releaseAllTabs()`. Each tab aborts pending runs, asks the worker to clean up, waits up to `750` ms for a `closed` ack, terminates the worker, decrements browser refcount, and disposes the browser handle when refcount reaches zero. Headless workers close their tool-owned page; attach workers disconnect without closing spawned, connected, or relay pages. ## Modes / Variants - **Action dispatch** @@ -251,14 +251,14 @@ The tool returns one result per call; no streaming partial output is emitted fro - Use `read` for static URLs; use `browser` when JavaScript execution, authentication, or interaction is required. A tab must be opened before `run`, and named tabs persist until closed. - `run` code has full Node/Bun and session-tool access; it is not sandboxed. - `loadPuppeteer()` and `loadPuppeteerInWorker()` temporarily redirect `cwd` to a safe Puppeteer directory before importing `puppeteer-core`, because Puppeteer probes the current working directory during module load. -- Headless launch prefers a detected system Chrome/Chromium, then `PUPPETEER_EXECUTABLE_PATH`, and only then downloads Chromium. +- Headless launch resolves its executable in this order: `PUPPETEER_EXECUTABLE_PATH` always wins; otherwise, on macOS the isolated Chrome for Testing binary (`com.google.chrome.for.testing`) is preferred over a detected system Chrome and downloaded on first use, falling back to system Chrome only when Chrome for Testing cannot be obtained (a headless daemon launched from a system `Google Chrome.app` bundle shares its `com.google.Chrome` LaunchServices identity, so macOS can route the user's link clicks to the daemon — #8673). On other platforms a detected system Chrome/Chromium is preferred, then a downloaded Chrome for Testing. - Headless launch always passes `--no-sandbox`, `--disable-setuid-sandbox`, `--disable-blink-features=AutomationControlled`, and a `--window-size=...` matching the initial viewport. It also ignores Puppeteer default args `--disable-extensions`, `--disable-default-apps`, and `--disable-component-extensions-with-background-pages`. - Proxy-related env vars only affect headless launch argv (shared and local): `PUPPETEER_PROXY`, `PUPPETEER_PROXY_BYPASS_LOOPBACK`, and `PUPPETEER_PROXY_IGNORE_CERT_ERRORS`. For the shared daemon they are baked in at first launch and take effect again after the daemon's next cold start. - Stealth patches are applied only in headless mode. Spawned or externally connected browsers are intentionally left untouched. - Relay mode drives an existing user browser and receives no stealth patches. Anything that can reach the relay endpoint can drive logged-in tabs; the built-in server binds loopback, and an optional shared token gates the extension connection. - `applyStealthPatches()` also strips Puppeteer's `//# sourceURL=__puppeteer_evaluation_script__` suffix from CDP `Runtime.evaluate` / `Runtime.callFunctionOn` payloads. - `tab.extract()` reads `page.content()`, runs Readability first, then falls back to the first non-empty of `[data-pagefind-body]`/`main article`/`article`/`main`/`[role='main']`/`body`, and returns `null` if neither extraction path yields content. -- `close(all: true, kill: false)` disconnects from spawned, connected, and relay browsers when the last tab closes but leaves spawned app processes and the user's Chrome running. +- `close(all: true, kill: false)` disconnects from spawned, connected, and relay browsers when the last managed tab is released but leaves their pages, spawned app processes, and the user's Chrome running. `kill: true` additionally terminates spawned-app processes; it never closes or kills connected or relay browsers. - Headless orphan cleanup is best-effort: if a worker dies before closing its page, the supervisor searches browser targets by `targetId` and closes that page. - Console methods inside `run` do not appear in tool output; they are forwarded as debug/warn/error logs through the worker transport. - Raw page request interception is run-scoped. At run end the worker removes user `request` handlers, disables interception, and releases held requests; cleanup failure marks the tab for recovery. \ No newline at end of file diff --git a/docs/tools/lsp.md b/docs/tools/lsp.md index 5bf1f5f22..6ed09cb7c 100644 --- a/docs/tools/lsp.md +++ b/docs/tools/lsp.md @@ -211,7 +211,7 @@ Uses the same location normalization and output shape as `definition`, but sends **Execution** - Workspace mode first invalidates the per-cwd configuration cache, reloads configuration from disk, and then reloads every newly configured non-custom LSP server. - Single-file mode keeps the cached configuration and reloads the primary server for that file. -- Both modes clear matching recent initialization failures before starting a server. `reloadServer()` then tries the `rust-analyzer/reloadWorkspace` request, falls back to a `workspace/didChangeConfiguration` notification with `{ settings: {} }`, and finally tears down the client so the next request cold-starts it. For a shared-mux client, teardown first sends the mux restart notification so the shared server—not only this session's link—is replaced. +- Both modes clear matching recent initialization failures before starting a server. For rust-analyzer servers, `reloadServer()` first tries the `rust-analyzer/reloadWorkspace` request (only rust-analyzer implements it; sending it to other servers such as Roslyn can crash them, so it is gated on the server binary/name). Every server then falls back to a `workspace/didChangeConfiguration` notification with `{ settings: {} }`, and finally tears down the client so the next request cold-starts it. For a shared-mux client, teardown first sends the mux restart notification so the shared server—not only this session's link—is replaced. **Output text** - One line per server: `Reloaded `, `Restarted `, or `Failed to reload : ...`. diff --git a/nix/bun.nix b/nix/bun.nix index e902a15ba..de70e28b3 100644 --- a/nix/bun.nix +++ b/nix/bun.nix @@ -217,9 +217,9 @@ url = "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz"; hash = "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="; }; - "@huggingface/tasks@0.21.33" = fetchurl { - url = "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.21.33.tgz"; - hash = "sha512-efQa8g+WjPwzlwk7Wl4eDgbM1Jq+WooUw/b1+eKl2LVUdPMhnbwsFNMJJxhEywaLJvJBy93+jjna6855U9GePw=="; + "@huggingface/tasks@0.21.34" = fetchurl { + url = "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.21.34.tgz"; + hash = "sha512-NRc1vw2Q/nZQPKjlzEkXxi0YNXstI1hXoaVruvJbgeznpXtEmPD0pey/ujIWTnhmsBQIVexr/QJJ5pyIJBPrMw=="; }; "@huggingface/tokenizers@0.1.3" = fetchurl { url = "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz"; @@ -573,9 +573,9 @@ url = "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.1.tgz"; hash = "sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA=="; }; - "@napi-rs/wasm-runtime@1.2.2" = fetchurl { - url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz"; - hash = "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="; + "@napi-rs/wasm-runtime@1.2.3" = fetchurl { + url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz"; + hash = "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="; }; "@napi-rs/wasm-tools-android-arm-eabi@1.1.0" = fetchurl { url = "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.1.0.tgz"; @@ -790,9 +790,9 @@ url = "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz"; hash = "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="; }; - "@oxc-project/types@0.143.0" = fetchurl { - url = "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz"; - hash = "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="; + "@oxc-project/types@0.144.0" = fetchurl { + url = "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz"; + hash = "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="; }; "@prettier/sync@0.6.1" = fetchurl { url = "https://registry.npmjs.org/@prettier/sync/-/sync-0.6.1.tgz"; @@ -838,61 +838,61 @@ url = "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz"; hash = "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="; }; - "@rolldown/binding-android-arm64@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz"; - hash = "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="; + "@rolldown/binding-android-arm64@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz"; + hash = "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="; }; - "@rolldown/binding-darwin-arm64@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz"; - hash = "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="; + "@rolldown/binding-darwin-arm64@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz"; + hash = "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="; }; - "@rolldown/binding-darwin-x64@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz"; - hash = "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="; + "@rolldown/binding-darwin-x64@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz"; + hash = "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="; }; - "@rolldown/binding-freebsd-x64@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz"; - hash = "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="; + "@rolldown/binding-freebsd-x64@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz"; + hash = "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="; }; - "@rolldown/binding-linux-arm-gnueabihf@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz"; - hash = "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="; + "@rolldown/binding-linux-arm-gnueabihf@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz"; + hash = "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="; }; - "@rolldown/binding-linux-arm64-gnu@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz"; - hash = "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="; + "@rolldown/binding-linux-arm64-gnu@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz"; + hash = "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="; }; - "@rolldown/binding-linux-arm64-musl@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz"; - hash = "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="; + "@rolldown/binding-linux-arm64-musl@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz"; + hash = "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="; }; - "@rolldown/binding-linux-ppc64-gnu@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz"; - hash = "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="; + "@rolldown/binding-linux-ppc64-gnu@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz"; + hash = "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="; }; - "@rolldown/binding-linux-s390x-gnu@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz"; - hash = "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="; + "@rolldown/binding-linux-s390x-gnu@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz"; + hash = "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="; }; - "@rolldown/binding-linux-x64-gnu@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz"; - hash = "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="; + "@rolldown/binding-linux-x64-gnu@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz"; + hash = "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="; }; - "@rolldown/binding-linux-x64-musl@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz"; - hash = "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="; + "@rolldown/binding-linux-x64-musl@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz"; + hash = "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="; }; - "@rolldown/binding-openharmony-arm64@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz"; - hash = "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="; + "@rolldown/binding-openharmony-arm64@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz"; + hash = "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="; }; - "@rolldown/binding-win32-arm64-msvc@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz"; - hash = "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="; + "@rolldown/binding-win32-arm64-msvc@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz"; + hash = "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="; }; - "@rolldown/binding-win32-x64-msvc@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz"; - hash = "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="; + "@rolldown/binding-win32-x64-msvc@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz"; + hash = "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="; }; "@rolldown/pluginutils@1.0.1" = fetchurl { url = "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz"; @@ -1150,9 +1150,9 @@ url = "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz"; hash = "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="; }; - "ansi-regex@6.2.2" = fetchurl { - url = "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz"; - hash = "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="; + "ansi-regex@6.3.0" = fetchurl { + url = "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz"; + hash = "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="; }; "ansi-styles@4.3.0" = fetchurl { url = "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"; @@ -1354,9 +1354,9 @@ url = "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz"; hash = "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="; }; - "electron-to-chromium@1.5.402" = fetchurl { - url = "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz"; - hash = "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="; + "electron-to-chromium@1.5.405" = fetchurl { + url = "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz"; + hash = "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew=="; }; "emnapi@1.11.3" = fetchurl { url = "https://registry.npmjs.org/emnapi/-/emnapi-1.11.3.tgz"; @@ -1871,9 +1871,9 @@ hash = "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="; }; "robomp-web" = copyPathToStore ../python/robomp/web; - "rolldown@1.2.3" = fetchurl { - url = "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz"; - hash = "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="; + "rolldown@1.2.4" = fetchurl { + url = "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz"; + hash = "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="; }; "safe-buffer@5.2.1" = fetchurl { url = "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"; diff --git a/nix/package.nix b/nix/package.nix index 2c0a1be0d..6c76f9256 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -11,6 +11,7 @@ ninja, pipewire, pkg-config, + removeReferencesTo, rustPlatform, rustToolchain, source, @@ -85,6 +86,7 @@ stdenv.mkDerivation { cmake ninja pkg-config + removeReferencesTo rustPlatform.bindgenHook rustPlatform.cargoSetupHook rustToolchain @@ -186,6 +188,15 @@ stdenv.mkDerivation { runHook postInstall ''; + # Bun serializes the build interpreter path into the bundled entrypoint's + # inert shebang. Remove its hash before Nix scans output references; this + # runs before Darwin's binary-signing fixup hook. + preFixup = '' + remove-references-to -t ${bun} "$out/bin/omp" + ''; + + disallowedReferences = [ bun ]; + doInstallCheck = true; installCheckPhase = '' runHook preInstallCheck diff --git a/package.json b/package.json index 4426ef6a0..6d4b9a45b 100644 --- a/package.json +++ b/package.json @@ -23,19 +23,19 @@ "@bufbuild/protoc-gen-es": "^2.12.1", "@huggingface/transformers": "^4.2.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.3.4", - "@oh-my-pi/omp-stats": "17.3.4", - "@oh-my-pi/omptype": "17.3.4", - "@oh-my-pi/pi-agent-core": "17.3.4", - "@oh-my-pi/pi-ai": "17.3.4", - "@oh-my-pi/pi-catalog": "17.3.4", - "@oh-my-pi/pi-coding-agent": "17.3.4", - "@oh-my-pi/pi-mnemopi": "17.3.4", - "@oh-my-pi/pi-natives": "17.3.4", - "@oh-my-pi/pi-tui": "17.3.4", - "@oh-my-pi/pi-utils": "17.3.4", - "@oh-my-pi/pi-wire": "17.3.4", - "@oh-my-pi/snapcompact": "17.3.4", + "@oh-my-pi/hashline": "17.3.5", + "@oh-my-pi/omp-stats": "17.3.5", + "@oh-my-pi/omptype": "17.3.5", + "@oh-my-pi/pi-agent-core": "17.3.5", + "@oh-my-pi/pi-ai": "17.3.5", + "@oh-my-pi/pi-catalog": "17.3.5", + "@oh-my-pi/pi-coding-agent": "17.3.5", + "@oh-my-pi/pi-mnemopi": "17.3.5", + "@oh-my-pi/pi-natives": "17.3.5", + "@oh-my-pi/pi-tui": "17.3.5", + "@oh-my-pi/pi-utils": "17.3.5", + "@oh-my-pi/pi-wire": "17.3.5", + "@oh-my-pi/snapcompact": "17.3.5", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -81,7 +81,7 @@ "@ark/schema": "0.56.2" }, "scripts": { - "setup": "bun install && bun run build:native && bun --cwd=packages/coding-agent link && sh scripts/link-omp.sh", + "setup": "bun scripts/setup.ts", "dev": "bun --cwd=packages/coding-agent src/cli.ts", "dev:timing": "PI_TIMING=x bun --cwd=packages/coding-agent --preload ../utils/src/module-timer.ts src/cli.ts", "stats": "bun --cwd=packages/coding-agent src/cli.ts stats", @@ -95,7 +95,7 @@ "build:native": "bun --cwd=packages/natives run build", "test": "bun scripts/ci-test-ts.ts local", "test:ts": "bun scripts/ci-test-ts.ts local-ts", - "test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts scripts/ci-release-publish.test.ts scripts/release.test.ts", + "test:scripts": "bun test scripts/ci-test-ts.test.ts scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts scripts/ci-release-publish.test.ts scripts/release.test.ts", "test:rs": "bun scripts/run-rs-task.ts test:rs", "check": "bun run --parallel check:ts check:rs", "check:ts": "bun run check:tools && bun run --workspaces --if-present check", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index c81cf5284..f47be5dbb 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [17.3.5] - 2026-08-16 + +### Added + +- Added automatic retry support for transient provider failures during one-shot completions, allowing callers such as compaction to opt in to resilient request handling. + +### Fixed + +- Fixed /handoff, branch summarization, and manual /compact failing outright on transient provider errors (e.g. Anthropic overloaded/429/529 responses); these operations now retry automatically instead of leaving the user's context full. + ## [17.3.4] - 2026-08-14 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index 039f5d22e..875878cfc 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "17.3.4", + "version": "17.3.5", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/agent/src/compaction/branch-summarization.ts b/packages/agent/src/compaction/branch-summarization.ts index 341d31ec7..d5f8d14cf 100644 --- a/packages/agent/src/compaction/branch-summarization.ts +++ b/packages/agent/src/compaction/branch-summarization.ts @@ -334,12 +334,18 @@ export async function generateBranchSummary( ]; // Call LLM for summarization - const response = await instrumentedCompleteSimple( - model, - { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages }, - { apiKey, signal, maxTokens: 2048, metadata }, - { telemetry: options.telemetry, oneshotKind: "branch_summary", completeImpl: options.completeImpl }, - ); + let response: AssistantMessage; + try { + response = await instrumentedCompleteSimple( + model, + { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages }, + { apiKey, signal, maxTokens: 2048, metadata }, + { telemetry: options.telemetry, oneshotKind: "branch_summary", completeImpl: options.completeImpl, retry: {} }, + ); + } catch (error) { + if (signal.aborted) return { aborted: true }; + throw error; + } // Check if aborted or errored if (response.stopReason === "aborted") { diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index c9c64b09a..26dabbf58 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -16,6 +16,7 @@ import { type Message, type MessageAttribution, type Model, + type OneshotRetryOptions, type ProviderSessionState, type SimpleStreamOptions, type Tool, @@ -470,8 +471,8 @@ function computeMessageTokens(message: AgentMessage, options?: { excludeEncrypte if (!options?.excludeEncryptedReasoning) fragments.push(block.data); } else if (block.type === "anthropicServerTool") { // Native Anthropic server-tool call/result replayed verbatim on the - // wire (server_tool_use input, web_search_tool_result - // encrypted_content). Opaque provider-replay state the provider still + // wire (server_tool_use input and opaque result content). This opaque + // provider-replay state the provider still // bills for on same-provider replay; excluded from the compaction // floor like other encrypted reasoning because its local byte size // diverges from provider billing. @@ -831,6 +832,31 @@ export interface SummaryOptions { ctx: Context, options: SimpleStreamOptions, ) => Promise; + /** + * Transient-failure retry for the summarization oneshots (`generateSummary`, + * `generateShortSummary`, `generateTurnPrefixSummary`). + * + * Defaults to enabled, which is what a one-shot caller such as manual + * `/compact` needs: a single Anthropic `overloaded_error` / 429 / 529 should + * not abort compaction and leave the context full. + * + * Pass `false` when the CALLER already owns a retry loop around the whole + * compaction attempt — auto-compaction does — otherwise the two budgets + * multiply (10 outer attempts x 3 inner = 30 requests) and each outer wait + * stacks on top of the inner backoff. + */ + oneshotRetry?: OneshotRetryOptions | false; +} + +/** + * Resolve the oneshot retry policy for a summarization call. Enabled by default + * so a lone transient blip cannot abort compaction; `false` opts out for callers + * that already retry the whole attempt (see `SummaryOptions.oneshotRetry`). + */ +function summaryOneshotRetry(options: SummaryOptions | undefined): OneshotRetryOptions | undefined { + const configured = options?.oneshotRetry; + if (configured === false) return undefined; + return configured ?? {}; } function localCodexCompaction(options: SummaryOptions | undefined) { @@ -935,7 +961,12 @@ export async function generateSummary( providerSessionState: options?.providerSessionState, codexCompaction: localCodexCompaction(options), }, - { telemetry: options?.telemetry, oneshotKind: "compaction_summary", completeImpl: options?.completeImpl }, + { + telemetry: options?.telemetry, + oneshotKind: "compaction_summary", + completeImpl: options?.completeImpl, + retry: summaryOneshotRetry(options), + }, ); if (response.stopReason === "error") { @@ -1034,13 +1065,14 @@ export async function generateHandoffFromContext( telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl, + retry: {}, }); if (response.stopReason === "error" && shouldRetryHandoffWithAutoToolChoice(response)) { response = await instrumentedCompleteSimple( model, context, { ...requestOptions, toolChoice: "auto" }, - { telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl }, + { telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl, retry: {} }, ); } @@ -1143,7 +1175,12 @@ async function generateShortSummary( providerSessionState: options?.providerSessionState, codexCompaction: localCodexCompaction(options), }, - { telemetry: options?.telemetry, oneshotKind: "compaction_short_summary", completeImpl: options?.completeImpl }, + { + telemetry: options?.telemetry, + oneshotKind: "compaction_short_summary", + completeImpl: options?.completeImpl, + retry: summaryOneshotRetry(options), + }, ); if (response.stopReason === "error") { @@ -1719,7 +1756,12 @@ async function generateTurnPrefixSummary( providerSessionState: options?.providerSessionState, codexCompaction: localCodexCompaction(options), }, - { telemetry: options?.telemetry, oneshotKind: "compaction_turn_prefix", completeImpl: options?.completeImpl }, + { + telemetry: options?.telemetry, + oneshotKind: "compaction_turn_prefix", + completeImpl: options?.completeImpl, + retry: summaryOneshotRetry(options), + }, ); if (response.stopReason === "error") { diff --git a/packages/agent/src/telemetry.ts b/packages/agent/src/telemetry.ts index e5a468c8b..94960dcba 100644 --- a/packages/agent/src/telemetry.ts +++ b/packages/agent/src/telemetry.ts @@ -30,6 +30,8 @@ import { completeSimple, type Message, type Model, + type OneshotRetryOptions, + retryTransientCompletion, type ServiceTier, type SimpleStreamOptions, type StopReason, @@ -1663,6 +1665,23 @@ export interface InstrumentedChatSpanOptions { ctx: Context, options: SimpleStreamOptions, ) => Promise; + /** + * Opt in to transient-failure retry for this oneshot (Anthropic + * `overloaded_error`, `rate_limit_error`, 429/500/502/503/529). Omitted or + * `undefined` means **no retry** — the failure is surfaced exactly as before. + * + * Deliberately opt-in rather than default-on: `oneshotKind` is free-form and + * callers may pass arbitrary `ctx.tools` / `options.toolChoice`, so this + * funnel cannot itself prove a given request is replay-safe. Re-issuing is + * only safe when the call performs no side effect and nothing consumed + * partial output — true for summaries, titles, handoffs and image + * descriptions, which parse a complete response after it resolves. Enable it + * per call site, as a reviewed decision. + * + * Pass `{}` to accept the {@link retryTransientCompletion} defaults + * (3 attempts, 500ms base backoff, `retry-after` honored). + */ + readonly retry?: OneshotRetryOptions; } /** @@ -1723,10 +1742,26 @@ export async function instrumentedCompleteSimple( try { return await runInActiveSpan(chatSpan, async () => { const complete = span.completeImpl ?? completeSimple; - const message = await complete(model, ctx, { - ...options, - onResponse: captureOnResponse, - }); + // Opt-in only (see `retry` on InstrumentedChatSpanOptions): each attempt + // re-issues the whole request, which is safe only for replay-safe + // oneshots. `getResponseHeaders` hands the failed attempt's headers to + // the retry layer — an AssistantMessage carries none, so this is what + // makes `retry-after` on a real 429/529 actually honored. + const runOnce = () => { + // Clear first so a previous attempt's `retry-after` can never be + // reused for a later failure that arrived without headers. + capturedHeaders = undefined; + return complete(model, ctx, { ...options, onResponse: captureOnResponse }); + }; + const message = span.retry + ? await retryTransientCompletion(runOnce, { + ...span.retry, + // Framework-owned: the caller must not be able to detach the + // abort signal or the header source by passing them itself. + signal: options.signal, + getResponseHeaders: () => capturedHeaders, + }) + : await runOnce(); await finishChatSpan(telemetry, chatSpan, message, { stepNumber, serviceTier: options.serviceTier, diff --git a/packages/agent/test/branch-summarization.test.ts b/packages/agent/test/branch-summarization.test.ts index 1cbde0fa8..116b01923 100644 --- a/packages/agent/test/branch-summarization.test.ts +++ b/packages/agent/test/branch-summarization.test.ts @@ -230,4 +230,59 @@ describe("branch summarization", () => { expect(messages.some(m => m.role === "toolResult")).toBe(true); }); + + test("returns an aborted result when cancelled during transient retry backoff", async () => { + const reason = new Error("user cancelled branch summary"); + let aborted = false; + const signal = { + get aborted() { + return aborted; + }, + get reason() { + return aborted ? reason : undefined; + }, + addEventListener(type: string, listener: ((event: Event) => void) | { handleEvent(event: Event): void }) { + if (type !== "abort") return; + aborted = true; + const event = new Event("abort"); + if (typeof listener === "function") listener(event); + else listener.handleEvent(event); + }, + removeEventListener() {}, + } as unknown as AbortSignal; + const entries: SessionEntry[] = [ + { + type: "message", + id: "user-1", + parentId: null, + timestamp: new Date(0).toISOString(), + message: { role: "user", content: "Summarize this branch.", timestamp: 0 }, + }, + ]; + let calls = 0; + + const result = await generateBranchSummary(entries, { + model: MODEL, + apiKey: "test-api-key", + signal, + completeImpl: async () => { + calls += 1; + return { + role: "assistant", + content: [], + api: "mock", + provider: "mock", + model: "mock-model", + usage: ZERO_USAGE, + stopReason: "error", + errorStatus: 529, + errorMessage: "overloaded_error: Overloaded", + timestamp: 1, + }; + }, + }); + + expect(calls).toBe(1); + expect(result).toEqual({ aborted: true }); + }); }); diff --git a/packages/agent/test/compaction-oneshot-retry.test.ts b/packages/agent/test/compaction-oneshot-retry.test.ts new file mode 100644 index 000000000..48fa3d9a4 --- /dev/null +++ b/packages/agent/test/compaction-oneshot-retry.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "bun:test"; +import { generateSummary } from "@oh-my-pi/pi-agent-core/compaction"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core/types"; +import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai/types"; + +/** + * Defends `SummaryOptions.oneshotRetry`, the split that lets manual `/compact` + * survive a transient provider blip without inflating auto-compaction's budget. + * + * Both paths call the same `generateSummary`, so the policy cannot be a constant + * inside it. Auto-compaction wraps the whole attempt in its own retry loop + * (`session-maintenance.ts`), and a nested inner loop would multiply requests + * (10 outer x 3 inner) while stacking each outer wait on an inner backoff. + * Manual `/compact` has no outer loop: without retry, one `overloaded_error` + * aborts compaction and leaves the user's context full. + * + * A transient failure arrives as a **resolved** `AssistantMessage` with + * `stopReason: "error"`, which is why `completeImpl` returning that value — + * rather than throwing — is the realistic stub here. + */ + +const emptyUsage = (): Usage => + ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }) as unknown as Usage; + +const model = { + id: "claude-sonnet-4-6", + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + maxTokens: 8192, +} as unknown as Model; + +// `ApiKey` is `string | ApiKeyResolver`; the stubbed `completeImpl` never uses it. +const apiKey = "test-key"; + +const messages = [{ role: "user", content: "summarize this session", timestamp: 0 }] as unknown as AgentMessage[]; + +function overloaded(): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: emptyUsage(), + stopReason: "error", + errorMessage: "overloaded_error: Overloaded", + errorStatus: 529, + timestamp: 0, + } as unknown as AssistantMessage; +} + +function summary(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: emptyUsage(), + stopReason: "stop", + timestamp: 0, + } as unknown as AssistantMessage; +} + +describe("SummaryOptions.oneshotRetry", () => { + it("retries a transient failure by default, so manual /compact survives a blip", async () => { + let calls = 0; + const text = await generateSummary(messages, model, 10_000, apiKey, undefined, undefined, undefined, { + // No `oneshotRetry`: the manual `/compact` shape. The one real backoff + // wait (default 500ms) is the price of asserting the DEFAULT rather than + // a value this test picked for itself. + completeImpl: () => { + calls += 1; + return Promise.resolve(calls === 1 ? overloaded() : summary("recovered summary")); + }, + }); + + expect(calls).toBe(2); + expect(text).toContain("recovered summary"); + }); + + it("makes exactly one attempt when the caller owns the retry loop", async () => { + let calls = 0; + const attempt = generateSummary(messages, model, 10_000, apiKey, undefined, undefined, undefined, { + // What auto-compaction passes: its own loop re-runs the whole attempt. + oneshotRetry: false, + completeImpl: () => { + calls += 1; + return Promise.resolve(overloaded()); + }, + }); + + // The failure must surface for the outer loop to classify and retry. + await expect(attempt).rejects.toThrow(); + expect(calls).toBe(1); + }); +}); diff --git a/packages/agent/test/instrumented-oneshot-retry.test.ts b/packages/agent/test/instrumented-oneshot-retry.test.ts new file mode 100644 index 000000000..868184fd9 --- /dev/null +++ b/packages/agent/test/instrumented-oneshot-retry.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "bun:test"; +import { instrumentedCompleteSimple } from "@oh-my-pi/pi-agent-core/telemetry"; +import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@oh-my-pi/pi-ai/types"; + +/** + * Defends the opt-in contract of the oneshot retry funnel. + * + * `instrumentedCompleteSimple` is the single entry point for every oneshot LLM + * call in the agent (compaction summaries, handoff, branch summary, image + * inspection). A transient Anthropic failure surfaces as a **resolved** + * `AssistantMessage` with `stopReason: "error"`, so retry has to be decided + * here rather than by a try/catch anywhere above. + * + * Retry is deliberately opt-in: `oneshotKind` is free-form and callers may pass + * arbitrary `ctx.tools`, so the funnel cannot prove an arbitrary request is + * replay-safe. These tests pin both halves of that contract — omitted means one + * attempt, `{}` means the transient failure is re-issued — plus the + * attempt-local header reset that keeps a stale `retry-after` from leaking into + * a later attempt. + */ + +const emptyUsage = (): Usage => + ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }) as unknown as Usage; + +const model = { + id: "claude-sonnet-4-6", + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", +} as unknown as Model; + +const ctx = { systemPrompt: "s", messages: [] } as unknown as Context; + +function reply(overrides: Partial = {}): AssistantMessage { + return { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: emptyUsage(), + stopReason: "stop", + timestamp: 0, + ...overrides, + } as AssistantMessage; +} + +const overloaded = (): AssistantMessage => + reply({ + stopReason: "error", + errorStatus: 529, + errorMessage: "Anthropic stream error (overloaded_error): Overloaded", + }); + +describe("instrumentedCompleteSimple transient retry", () => { + it("does not retry when `retry` is omitted", async () => { + let calls = 0; + const result = await instrumentedCompleteSimple(model, ctx, {} as SimpleStreamOptions, { + telemetry: undefined, + oneshotKind: "test_no_retry", + completeImpl: () => { + calls += 1; + return Promise.resolve(overloaded()); + }, + }); + + expect(calls).toBe(1); + expect(result.stopReason).toBe("error"); + }); + + it("re-issues a transient failure when `retry: {}` is set", async () => { + let calls = 0; + const result = await instrumentedCompleteSimple(model, ctx, {} as SimpleStreamOptions, { + telemetry: undefined, + oneshotKind: "test_retry", + retry: { baseDelayMs: 1 }, + completeImpl: () => { + calls += 1; + return Promise.resolve(calls === 1 ? overloaded() : reply()); + }, + }); + + expect(calls).toBe(2); + expect(result.stopReason).toBe("stop"); + }); + + it("keeps the caller's failure contract once attempts are exhausted", async () => { + let calls = 0; + const result = await instrumentedCompleteSimple(model, ctx, {} as SimpleStreamOptions, { + telemetry: undefined, + oneshotKind: "test_exhausted", + retry: { baseDelayMs: 1, maxAttempts: 2 }, + completeImpl: () => { + calls += 1; + return Promise.resolve(overloaded()); + }, + }); + + expect(calls).toBe(2); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("overloaded_error"); + }); + + it("does not reuse a previous attempt's retry-after header", async () => { + // Attempt 1 fails WITH a retry-after header; attempt 2 fails WITHOUT one. + // If headers were not cleared per attempt, the stale hint would be applied + // again and the second wait would jump from ~1ms backoff back to 300ms. + const waits: number[] = []; + let calls = 0; + await instrumentedCompleteSimple(model, ctx, {} as SimpleStreamOptions, { + telemetry: undefined, + oneshotKind: "test_header_reset", + retry: { baseDelayMs: 1, maxAttempts: 3, onRetry: info => waits.push(info.delayMs) }, + completeImpl: (_model, _ctx, options) => { + calls += 1; + if (calls === 1) { + options.onResponse?.({ status: 429, headers: { "retry-after-ms": "300" } }, undefined as never); + } + return Promise.resolve(calls < 3 ? overloaded() : reply()); + }, + }); + + expect(calls).toBe(3); + expect(waits).toHaveLength(2); + expect(waits[0]).toBe(300); + // Second failure carried no header, so it must fall back to plain backoff. + expect(waits[1]).toBeLessThan(50); + }); +}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 08c36e86c..86d99d599 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +## [17.3.5] - 2026-08-16 + +### Added + +- Added retryable oneshot completion support (`retryTransientCompletion`) so non-agent LLM calls correctly retry on transient provider failures (Anthropic overload/rate-limit errors, HTTP 429/500/502/503/529), honoring provider-supplied retry-after timing before giving up. + +### Fixed + +- Fixed xAI availability detection so paid-key-only setups correctly default to `xai/grok-4.5` instead of the free SuperGrok catalog; explicit `xai-oauth/…` selectors still work as before. +- Fixed xAI Responses requests sending unsupported parameters (reasoning summary, presence/frequency penalties) that some models rejected. +- Fixed Umans usage reporting incorrectly marking quota as exhausted based on raw request counts instead of actual weighted usage, and improved the usage display to show both a soft-cap warning and a hard exhaustion limit with an accurate countdown to reset. +- Fixed `omp usage invalidate` to fully clear stale usage data and force a fresh refresh, so upgraded subscriptions no longer show outdated quota information. +- Improved session recovery to correctly treat certain Cursor HTTP/2 connection errors as transient instead of ending the session. +- Fixed OpenAI-compatible streams (e.g. DeepSeek) that are cut off mid-generation being silently treated as a completed response instead of being retried. +- Fixed DeepSeek resource-exhaustion interruptions not being automatically retried. +- Fixed tool-call IDs being lost during same-model replay, which could break correlation with custom gateways. +- Fixed Kimi Code multi-account routing to prefer accounts with more available quota, respect usage-limit cooldowns, and keep consistent usage history across token refreshes. +- Fixed Anthropic custom signing-proxy conversations losing tool-search results and thinking content during replay. +- Fixed rare runaway response loops across model providers so they now fail gracefully instead of repeating indefinitely. +- Fixed xAI rejecting entire turns due to certain MCP tool schema shapes, restoring compatibility while isolating any remaining incompatible tools rather than failing the whole request. +- Fixed Alibaba DashScope/Bailian transient per-minute rate limits being misclassified as full quota exhaustion, causing unnecessary long backoffs instead of quick retries. +- Fixed Anthropic-compatible streams dropping thinking content, which broke replay of prior reasoning. +- Updated the Alibaba Coding Plan China login flow to point to the current Bailian API-key management console. + ## [17.3.4] - 2026-08-14 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index 07109b331..0c487719b 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "17.3.4", + "version": "17.3.5", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 625f6b6d4..82441706b 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -53,7 +53,7 @@ import { cursorUsageProvider } from "./usage/cursor"; import { googleGeminiCliUsageProvider } from "./usage/gemini"; import { githubCopilotUsageProvider } from "./usage/github-copilot"; import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity"; -import { kimiUsageProvider } from "./usage/kimi"; +import { kimiRankingStrategy, kimiUsageProvider } from "./usage/kimi"; import { minimaxCodeUsageProvider } from "./usage/minimax-code"; import { ollamaCloudUsageProvider, ollamaUsageProvider } from "./usage/ollama"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; @@ -1082,6 +1082,7 @@ const DEFAULT_RANKING_STRATEGIES = new Map( ["openai-codex", codexRankingStrategy], ["anthropic", claudeRankingStrategy], ["google-antigravity", antigravityRankingStrategy], + ["kimi-code", kimiRankingStrategy], ["zai", zaiRankingStrategy], ["opencode-go", opencodeGoRankingStrategy], ]); @@ -2680,18 +2681,32 @@ export class AuthStorage { } /** - * Check if any form of auth is configured for a provider. - * Unlike getApiKey(), this doesn't refresh OAuth tokens. + * Dedicated auth for default-model availability (picker / `getAvailable`). + * Unlike {@link getApiKey}, this does not refresh OAuth tokens, and unlike + * {@link hasResolvableAuth} it ignores cross-provider env aliases so + * `XAI_API_KEY` does not auto-select SuperGrok (`xai-oauth`). */ hasAuth(provider: string): boolean { if (this.#runtimeOverrides.has(provider)) return true; if (this.#configOverrides.has(provider)) return true; if (this.#getCredentialsForProvider(provider).length > 0) return true; - if (getEnvApiKey(provider)) return true; + if (this.#hasDedicatedEnvAuth(provider)) return true; if (this.#fallbackResolver?.(provider)) return true; return false; } + /** + * Whether a request could resolve a key for this provider, including + * cross-provider env aliases (`xai-oauth` borrowing `XAI_API_KEY`). + * Use this for explicit model preflight (`xai-oauth/grok-4.5`); use + * {@link hasAuth} for auto-availability so the default picker stays on + * paid `xai` when only `XAI_API_KEY` is set. + */ + hasResolvableAuth(provider: string): boolean { + if (this.hasAuth(provider)) return true; + return Boolean(getEnvApiKey(provider)); + } + /** * True iff a dedicated, non-env credential source is configured for this * provider — i.e. anything in the cascade EXCEPT `getEnvApiKey(provider)`. @@ -2711,6 +2726,22 @@ export class AuthStorage { return false; } + /** + * Env auth that belongs to this provider, not a cross-provider alias. + * + * `getEnvApiKey("xai-oauth")` also accepts `XAI_API_KEY` so an explicit + * `xai-oauth/…` stream can still borrow the paid key. Availability and + * origin must not: otherwise an API-key-only setup marks SuperGrok as + * signed in and `pickDefaultAvailableModel` prefers `xai-oauth/grok-4.5` + * over paid `xai/grok-4.5`. + */ + #hasDedicatedEnvAuth(provider: string): boolean { + if (provider === "xai-oauth") { + return Boolean($env.XAI_OAUTH_TOKEN?.trim()); + } + return Boolean(getEnvApiKey(provider)); + } + /** * Classify where a provider's auth comes from, following the same precedence * as {@link AuthStorage.getApiKey}: runtime override → config override → @@ -2727,7 +2758,7 @@ export class AuthStorage { if (stored.some(credential => credential.type === "api_key" && credential.source === "login")) { return { kind: "api_key" }; } - if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) }; + if (this.#hasDedicatedEnvAuth(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) }; if (stored.some(credential => credential.type === "api_key")) return { kind: "api_key" }; if (this.#fallbackResolver?.(provider)) return { kind: "fallback" }; return undefined; diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 6167e6f5a..5a786e2d6 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -9,6 +9,7 @@ import { } from "./classes"; import { isAccountScopedCapText, + isDashScopeTokenLimitText, isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, @@ -106,7 +107,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i; const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i; export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i; export const TRANSIENT_TRANSPORT_PATTERN = - /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i; + /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i; const AUTH_FAILURE_PATTERN = /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i; const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i; @@ -441,7 +442,10 @@ export function classify(error: unknown, api?: Api): number { } else if (link instanceof ProviderHttpError) { let linkKinds = 0; const { status: codeStatus, code } = link; - if (code === "usage_limit_reached" || code === "insufficient_quota") { + if ( + code === "usage_limit_reached" || + (code === "insufficient_quota" && !isDashScopeTokenLimitText(link.message)) + ) { linkKinds |= Flag.UsageLimit; } if (code === "overloaded_error" || code === "rate_limit_error") { diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index fa1dbf53a..4a302ade4 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -69,6 +69,26 @@ const CN_TRANSIENT_CAP_PATTERN = // isOpaqueStatusBody so CN transients stay in the provider backoff lane instead // of rotating through the opaque-429 fallback. const CN_THROTTLE_PATTERN = /速率(?:限制|过快)|频率(?:过高|过快)|过于频繁|稍后[重再]试/; +// DashScope / Bailian (Alibaba Model Studio) reports its per-minute token +// throttle (429 Throttling.AllocationQuota, type `insufficient_quota`) with +// OpenAI-compatible billing wording — "You exceeded your current quota, +// please check your plan and billing details. … (type=insufficient_quota +// param=insufficient_quota)" — and links the error-code doc's `token-limit` +// anchor. Per that doc section the error is a transient TPM/TPS cap that +// clears within the minute window, not an account-local quota exhaustion. +// The same doc anchor also covers permanent errors such as "Free allocated +// quota exceeded", so require both the anchor and the exact throttle wording. +// The identical wording WITHOUT the anchor stays quota-exhausted (OpenAI's +// real account-quota error uses the same sentence). +const DASHSCOPE_TOKEN_LIMIT_DOC_PATTERN = /error-code[^()\s]*#token-limit/i; +const DASHSCOPE_TOKEN_LIMIT_MESSAGE_PATTERN = + /\byou exceeded your current quota, please check your plan and billing details\b/i; +/** True for DashScope/Bailian's documented OpenAI-compatible TPM/TPS throttle. */ +export function isDashScopeTokenLimitText(errorMessage: string): boolean { + return ( + DASHSCOPE_TOKEN_LIMIT_DOC_PATTERN.test(errorMessage) && DASHSCOPE_TOKEN_LIMIT_MESSAGE_PATTERN.test(errorMessage) + ); +} const GOOGLE_RPC_ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo"; const LONG_RATE_LIMIT_DELAY_MS = 5 * 60 * 1000; @@ -133,8 +153,9 @@ function isQuotaExhaustedReason(reason: RateLimitReason): boolean { /** * Classify a rate-limit error message into a reason category. * Priority order: explicit details in a resource-exhausted error > QUOTA - * (Antigravity "quota will reset") > CONCURRENT_LIMIT > MODEL_CAPACITY > - * QUOTA (account) > RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN. + * (Antigravity "quota will reset") > CN quota > DASHSCOPE_TOKEN_LIMIT (TPM/TPS + * throttle) > CONCURRENT_LIMIT > MODEL_CAPACITY > QUOTA (account) > RATE_LIMIT > + * QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN. * * Bare "resource exhausted" / "resource_exhausted" maps to MODEL_CAPACITY (transient, short wait). * Explicit details such as "quota exceeded" retain their normal classification. @@ -162,6 +183,13 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason { return "QUOTA_EXHAUSTED"; } + // DashScope/Bailian TPM/TPS throttle: billing-worded like OpenAI's account + // quota, but the doc anchor marks it a per-minute token cap that clears on + // its own — short backoff on the same credential, never rotation/block. + if (isDashScopeTokenLimitText(errorMessage)) { + return "RATE_LIMIT_EXCEEDED"; + } + if (CONCURRENT_LIMIT_PATTERN.test(errorMessage)) { return "CONCURRENT_LIMIT"; } @@ -342,6 +370,7 @@ export function isOpaqueStatusBody(message: string): boolean { export function matchesUsageLimitText(errorMessage: string): boolean { const structuredReason = parseGoogleRpcRateLimitReason(errorMessage); if (structuredReason !== undefined) return isQuotaExhaustedReason(structuredReason); + if (isDashScopeTokenLimitText(errorMessage)) return false; return ( USAGE_LIMIT_PATTERN.test(errorMessage) || (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) || diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index ec472a32c..d2f9a9a00 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -6,6 +6,7 @@ export * from "./auth-gateway/types"; export * from "./auth-retry"; export * from "./auth-storage"; export * from "./error/rate-limit"; +export * from "./oneshot-retry"; export * from "./provider-details"; export * from "./providers/anthropic"; export * from "./providers/anthropic-client"; diff --git a/packages/ai/src/oneshot-retry.ts b/packages/ai/src/oneshot-retry.ts new file mode 100644 index 000000000..e04d0d09c --- /dev/null +++ b/packages/ai/src/oneshot-retry.ts @@ -0,0 +1,230 @@ +import { extractRetryHint } from "@oh-my-pi/pi-utils"; +import * as AIError from "./error"; +import type { AssistantMessage } from "./types"; +import { getHeadersFromError, getRetryAfterMsFromHeaders, type HeadersLike } from "./utils/retry-after"; + +/** + * Transient-failure retry for **oneshot** (non-agent-loop) completions. + * + * Why this exists: `streamSimple`/`completeSimple` retry *auth* failures + * (credential rotation) but deliberately surface *transient* provider failures + * — Anthropic `overloaded_error`, `rate_limit_error`, HTTP 429/500/502/503/529 + * — as a **resolved** `AssistantMessage` with `stopReason: "error"`. For the + * main agent turn that is correct: `TurnRecovery` owns recovery there, and it + * must refuse to replay once tool calls or visible text have streamed. + * + * Oneshots have no such hazard. A summary, title, handoff, or image + * description produces no side effects, so re-issuing the whole request is + * safe and is almost always what the caller wants. Before this helper every + * oneshot call site had to re-implement that decision, and most did not — + * failing on the first blip, or swallowing it into `null` so a transient + * overload was indistinguishable from a legitimate empty result. + * + * Classification reuses the existing provider predicates (`AIError`), so the + * set of retryable Anthropic failures stays defined in exactly one place. + * Usage limits are included: unlike the provider loop — which excludes them so + * credential rotation can own them — a oneshot has no rotation layer above it, + * and the retry hint the provider supplies (`retry-after`, "try again in ~5m") + * is honored, so waiting is the correct response. + */ +export interface OneshotRetryOptions { + /** Total attempts, including the first. Default 3. Values < 1 are treated as 1. */ + maxAttempts?: number; + /** First backoff step in ms; doubles per attempt. Default 500. */ + baseDelayMs?: number; + /** + * Upper bound for a single wait. Default 30_000. A provider retry hint + * longer than this aborts the retry instead of parking the caller — the + * error surfaces so higher-level recovery (or the user) can decide. + */ + maxDelayMs?: number; + /** + * Stops further attempts. Two distinct paths, both preserving the caller's + * intent: an abort already visible when an attempt settles surfaces that + * attempt's own result (`completeSimple` reports `stopReason: "aborted"`), + * while an abort that lands during the backoff wait rejects with the abort + * reason — a user cancel stays a cancel and is never relabelled as the + * provider failure we happened to be waiting on. + * + * This helper does NOT pass the signal into `run` — cancelling the in-flight + * request is the closure's job, because a per-attempt deadline must be + * rebuilt on every attempt. Construct it inside `run` + * (`signal: AbortSignal.timeout(MS)`, or `AbortSignal.any([outer, perAttempt])`); + * a deadline captured outside would fire once and then abort every retry, + * silently turning this helper into a single attempt. + */ + signal?: AbortSignal; + /** + * Headers of the attempt that just failed, used to honor `retry-after`. + * + * Load-bearing: a transient Anthropic failure arrives as a **resolved** + * `AssistantMessage`, and `AssistantMessage` carries no headers — so without + * this the real `retry-after` / `x-ratelimit-reset` values on a 429/529 are + * invisible and only the (usually hint-free) error text is available. + * Callers that already capture headers via `SimpleStreamOptions.onResponse` + * should return the latest capture here; it is read once per failed attempt. + * Thrown errors need no wiring — headers are recovered from the error itself. + */ + getResponseHeaders?: () => HeadersLike; + /** Observability hook. Fires immediately before sleeping. */ + onRetry?: (info: OneshotRetryInfo) => void; +} + +export interface OneshotRetryInfo { + /** 1-based index of the attempt that just failed. */ + attempt: number; + maxAttempts: number; + delayMs: number; + /** True when `delayMs` came from a provider retry hint rather than backoff. */ + fromRetryHint: boolean; + errorMessage: string; + /** `AIError` classification bits of the failure. */ + errorId: number; +} + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_BASE_DELAY_MS = 500; +const DEFAULT_MAX_DELAY_MS = 30_000; +/** Cap on pure backoff growth. A provider hint may still exceed this, up to `maxDelayMs`. */ +const BACKOFF_CEILING_MS = 8_000; +const RETRY_AFTER_MS_SUFFIX = /(?:^|\s)retry-after-ms=([0-9]+(?:\.[0-9]+)?)(?=\s|$)/i; + +function backoffDelayMs(attempt: number, baseDelayMs: number): number { + const growth = Math.min(baseDelayMs * 2 ** (attempt - 1), BACKOFF_CEILING_MS); + // 75-100% jitter, matching the provider loop and TurnRecovery, so a fleet of + // concurrent oneshots does not re-converge on the same instant. + return Math.round(growth * (0.75 + Math.random() * 0.25)); +} + +/** Retryable when the provider says transient, or when it says "wait, then retry". */ +function isRetryableOneshotFailure(errorId: number, errorStatus: number | undefined, errorMessage: string): boolean { + // llama.cpp reports deterministic tool-call JSON parse failures as HTTP 500. + // Replaying the same prompt produces the same malformed output. + if (AIError.LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(errorMessage)) return false; + if (AIError.is(errorId, AIError.Flag.ContentBlocked)) return false; + return ( + AIError.isTransientStatus(errorStatus) || + AIError.is(errorId, AIError.Flag.Transient) || + AIError.is(errorId, AIError.Flag.UsageLimit) || + AIError.retriable(errorId) + ); +} + +function sleep(delayMs: number, signal?: AbortSignal): Promise { + if (delayMs <= 0) return Promise.resolve(); + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new AIError.AbortError("oneshot retry aborted")); + }; + if (signal) { + if (signal.aborted) { + clearTimeout(timer); + return Promise.reject(signal.reason ?? new AIError.AbortError("oneshot retry aborted")); + } + signal.addEventListener("abort", onAbort, { once: true }); + } + return promise; +} + +/** + * Run a oneshot completion, retrying transient provider failures. + * + * Handles both failure shapes: a resolved `AssistantMessage` carrying + * `stopReason: "error"` (what `completeSimple` produces) and a thrown error + * (what the raw HTTP helpers produce). A non-retryable failure is returned or + * rethrown unchanged, so existing caller error handling keeps working — this + * only removes the *first-blip* failure mode. + */ +export async function retryTransientCompletion( + run: (attempt: number) => Promise, + options?: OneshotRetryOptions, +): Promise { + const maxAttempts = Math.max(1, options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS); + const baseDelayMs = options?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS; + const maxDelayMs = options?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS; + const signal = options?.signal; + + for (let attempt = 1; ; attempt++) { + let message: AssistantMessage | undefined; + let thrown: unknown; + try { + message = await run(attempt); + if (message.stopReason !== "error") return message; + } catch (error) { + thrown = error; + } + // A caller abort is never a transient failure — surface it immediately so + // cancellation stays responsive. + if (signal?.aborted) { + if (thrown !== undefined) throw thrown; + return message as AssistantMessage; + } + + const errorId = + thrown !== undefined ? AIError.classify(thrown) : AIError.classifyMessage(message as AssistantMessage); + if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) { + if (thrown !== undefined) throw thrown; + return message as AssistantMessage; + } + const errorMessage = + thrown !== undefined + ? thrown instanceof Error + ? thrown.message + : String(thrown) + : ((message as AssistantMessage).errorMessage ?? "unknown error"); + const errorStatus = thrown !== undefined ? AIError.status(thrown) : (message as AssistantMessage).errorStatus; + const lastAttempt = attempt >= maxAttempts; + if (lastAttempt || !isRetryableOneshotFailure(errorId, errorStatus, errorMessage)) { + if (thrown !== undefined) throw thrown; + return message as AssistantMessage; + } + + // Headers first: a real Anthropic 429/529 carries `retry-after` / + // `x-ratelimit-reset*` in the response, and the resolved AssistantMessage + // has none — the caller supplies them via `getResponseHeaders`. Thrown + // errors (e.g. AnthropicApiError) carry their own headers. + const headers: HeadersLike = thrown !== undefined ? getHeadersFromError(thrown) : options?.getResponseHeaders?.(); + const headerHintMs = getRetryAfterMsFromHeaders(headers); + const extractedTextHintMs = extractRetryHint(undefined, errorMessage); + const suffixValue = RETRY_AFTER_MS_SUFFIX.exec(errorMessage)?.[1]; + const parsedSuffixMs = suffixValue === undefined ? undefined : Number(suffixValue); + const suffixHintMs = + parsedSuffixMs !== undefined && Number.isFinite(parsedSuffixMs) && parsedSuffixMs > 0 + ? Math.ceil(parsedSuffixMs) + : undefined; + const textHintMs = + extractedTextHintMs === undefined && suffixHintMs === undefined + ? undefined + : Math.max(extractedTextHintMs ?? 0, suffixHintMs ?? 0); + const hintMs = + headerHintMs === undefined && textHintMs === undefined + ? undefined + : Math.max(headerHintMs ?? 0, textHintMs ?? 0); + // An over-cap hint means "come back much later"; parking a oneshot that + // long is worse than surfacing the failure to the caller. + if (hintMs !== undefined && hintMs > maxDelayMs) { + if (thrown !== undefined) throw thrown; + return message as AssistantMessage; + } + const backoff = backoffDelayMs(attempt, baseDelayMs); + const delayMs = Math.min(Math.max(hintMs ?? 0, backoff), maxDelayMs); + + options?.onRetry?.({ + attempt, + maxAttempts, + delayMs, + fromRetryHint: hintMs !== undefined && hintMs >= backoff, + errorMessage, + errorId, + }); + // Aborting mid-backoff rejects with the caller's abort reason: a user + // cancel must stay a cancel, not get relabelled as the provider failure we + // happened to be waiting on. + await sleep(delayMs, signal); + } +} diff --git a/packages/ai/src/providers/anthropic-messages-server.ts b/packages/ai/src/providers/anthropic-messages-server.ts index 6bb79d992..dc2e74a38 100644 --- a/packages/ai/src/providers/anthropic-messages-server.ts +++ b/packages/ai/src/providers/anthropic-messages-server.ts @@ -27,7 +27,7 @@ import { type AnthropicUserContentBlock, anthropicMessagesRequestSchema, } from "./anthropic-messages-server-schema"; -import { isAnthropicWebSearchHistoryBlock } from "./anthropic-wire"; +import { isAnthropicServerToolHistoryBlock } from "./anthropic-wire"; /** * Anthropic Messages API (https://docs.anthropic.com/en/api/messages) ↔ pi-ai @@ -216,10 +216,10 @@ function walkAssistantContent( break; case "server_tool_use": case "web_search_tool_result": - if (isAnthropicWebSearchHistoryBlock(block)) { - // Native web-search call/result. Anthropic requires these - // replayed verbatim (encrypted_content included), so retain - // the block instead of flattening it to text. + case "tool_search_tool_result": + if (isAnthropicServerToolHistoryBlock(block)) { + // Anthropic requires supported server-tool call/results replayed + // verbatim, so retain each opaque block instead of flattening it. out.push({ type: "anthropicServerTool", block: { ...block } }); } else { // Other server tools use distinct result block types that omp diff --git a/packages/ai/src/providers/anthropic-wire.ts b/packages/ai/src/providers/anthropic-wire.ts index 11665e0d5..bca3c24e7 100644 --- a/packages/ai/src/providers/anthropic-wire.ts +++ b/packages/ai/src/providers/anthropic-wire.ts @@ -77,6 +77,11 @@ export type ServerToolUseBlockParam = { /** Web-search server-tool call whose matching result is replayable by omp. */ export type WebSearchServerToolUseBlockParam = ServerToolUseBlockParam & { name: "web_search" }; +/** Tool-search server-tool call whose matching result is replayable by omp. */ +export type ToolSearchServerToolUseBlockParam = ServerToolUseBlockParam & { + name: "tool_search_tool_regex" | "tool_search_tool_bm25"; +}; + /** Native web-search result replayed inside an assistant turn. */ export type WebSearchToolResultBlockParam = { type: "web_search_tool_result"; @@ -85,18 +90,37 @@ export type WebSearchToolResultBlockParam = { [key: string]: unknown; }; -/** True for the complete native web-search history variants omp can replay. */ -export function isAnthropicWebSearchHistoryBlock(block: { +/** Native tool-search result replayed inside an assistant turn. */ +export type ToolSearchToolResultBlockParam = { + type: "tool_search_tool_result"; + tool_use_id: string; + content: unknown; + [key: string]: unknown; +}; + +/** Anthropic server-tool history variants omp can replay atomically. */ +export type AnthropicServerToolHistoryBlockParam = + | WebSearchServerToolUseBlockParam + | WebSearchToolResultBlockParam + | ToolSearchServerToolUseBlockParam + | ToolSearchToolResultBlockParam; + +/** True when a block is complete Anthropic server-tool history omp can replay. */ +export function isAnthropicServerToolHistoryBlock(block: { type: string; name?: unknown; id?: unknown; tool_use_id?: unknown; content?: unknown; -}): block is WebSearchServerToolUseBlockParam | WebSearchToolResultBlockParam { +}): block is AnthropicServerToolHistoryBlockParam { if (block.type === "server_tool_use") { - return block.name === "web_search" && typeof block.id === "string" && block.id.length > 0; + const supportedName = + block.name === "web_search" || + block.name === "tool_search_tool_regex" || + block.name === "tool_search_tool_bm25"; + return supportedName && typeof block.id === "string" && block.id.length > 0; } - if (block.type === "web_search_tool_result") { + if (block.type === "web_search_tool_result" || block.type === "tool_search_tool_result") { return typeof block.tool_use_id === "string" && block.tool_use_id.length > 0 && Object.hasOwn(block, "content"); } return false; @@ -132,6 +156,7 @@ export type ContentBlockParam = | ToolResultBlockParam | ServerToolUseBlockParam | WebSearchToolResultBlockParam + | ToolSearchToolResultBlockParam | ThinkingBlockParam | RedactedThinkingBlockParam | FallbackBlockParam; @@ -319,6 +344,7 @@ export type ResponseContentBlock = | { type: "tool_use"; id: string; name: string; input?: Record | null } | ServerToolUseBlockParam | WebSearchToolResultBlockParam + | ToolSearchToolResultBlockParam | { type: "fallback"; from: { model: string }; to: { model: string } }; export type ContentBlockDelta = diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 2d3a49170..d8c631308 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -79,7 +79,7 @@ import { type Usage as AnthropicWireUsage, type ContentBlockParam, type FallbackParam, - isAnthropicWebSearchHistoryBlock, + isAnthropicServerToolHistoryBlock, type MessageCreateParams, type MessageCreateParamsStreaming, type MessageParam, @@ -2404,7 +2404,7 @@ const streamAnthropicOnce = ( streamedReplayUnsafeContent = true; const block: Block = { type: "thinking", - thinking: "", + thinking: event.content_block.thinking ?? "", thinkingSignature: "", [kStreamingBlockIndex]: event.index, }; @@ -2416,6 +2416,14 @@ const streamAnthropicOnce = ( contentIndex, partial: output, }); + if (block.thinking) { + stream.push({ + type: "thinking_delta", + contentIndex, + delta: block.thinking, + partial: output, + }); + } } else if (event.content_block.type === "redacted_thinking") { streamedReplayUnsafeContent = true; const block: Block = { @@ -2429,8 +2437,11 @@ const streamAnthropicOnce = ( kind: "redactedThinking", }); } else if ( - isAnthropicWebSearchHistoryBlock(event.content_block) && - umansGatewayWebSearchHeader === undefined + isAnthropicServerToolHistoryBlock(event.content_block) && + (umansGatewayWebSearchHeader === undefined || + (event.content_block.type === "server_tool_use" + ? event.content_block.name !== "web_search" + : event.content_block.type !== "web_search_tool_result")) ) { streamedReplayUnsafeContent = true; const block: Block = { diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index ca44cdc12..de00062c8 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -293,11 +293,23 @@ const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000; * model reads it and should route around the capability, not retry the call. */ const NOT_IMPLEMENTED_SUFFIX = "not implemented by this client"; +/** Bare gRPC `resource_exhausted` end-streams (also inside a Connect error message). */ +const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/i; const NOT_IMPLEMENTED = `Not implemented by this client`; const conversationStateCache = new Map(); const conversationBlobStores = new Map>(); const warnedCursorKimiK3ReplayMessages = new Set(); +/** + * Base conversation id → rotated wire id (#8345). Cursor's backend can pin a + * per-conversation rejection (bare `resource_exhausted`, zero tokens) to one + * conversationId forever; the session is then unusable until /fork mints a + * new id. On the first such failure the id is rotated once and the cached + * state migrates, so the retry loop's next attempt starts a fresh + * conversation — the same recovery /fork performs. Keyed by the base id the + * caller derived, so a failed rotation is never repeated. + */ +const rotatedConversationIds = new Map(); export interface CursorOptions extends StreamOptions { customSystemPrompt?: string; @@ -587,13 +599,19 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( h2Completion.resolve(); }; + // Hoisted out of the try block: the #8345 rotation in the catch path + // needs both ids, and the catch block cannot see try-scoped consts. + let baseConversationId: string | undefined; + let conversationId: string | undefined; + let usageState: UsageState | undefined; try { const apiKey = options?.apiKey; if (!apiKey) { throw new AIError.MissingApiKeyError(undefined, "Cursor API key (access token) is required"); } - const conversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID(); + baseConversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID(); + conversationId = rotatedConversationIds.get(baseConversationId) ?? baseConversationId; const blobStore = conversationBlobStores.get(conversationId) ?? new Map(); conversationBlobStores.set(conversationId, blobStore); const cachedState = conversationStateCache.get(conversationId); @@ -667,7 +685,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null; let currentToolCall: ToolCallState | null = null; const resolvedMcpToolCallIds = new Set(); - const usageState: UsageState = { sawTokenDelta: false }; + usageState = { sawTokenDelta: false }; const state: BlockState = { get currentTextBlock() { @@ -702,7 +720,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( openBlockState = state; const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => { - conversationStateCache.set(conversationId, checkpoint); + conversationStateCache.set(conversationId!, checkpoint); }; h2Request.on("response", headers => { @@ -759,7 +777,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( h2Request!, options?.execHandlers, options?.onToolResult, - usageState, + usageState!, requestContextTools, onConversationCheckpoint, ).catch(error => { @@ -873,6 +891,28 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( flushOpenToolCalls(output, stream, openBlockState); } const result = await AIError.finalize(error, { api: model.api, signal: options?.signal }); + // #8345: a server-side per-conversation rejection surfaces as a bare + // resource_exhausted with zero tokens — the conversation is poisoned, + // not the account (sibling conversations keep working). Rotate the + // wire id once and migrate the cached state so the next attempt (the + // caller's retry loop) starts a fresh conversation, exactly like + // /fork. Only the first failure rotates; repeated failures keep the + // rotated id so a genuine account-level exhaustion is not hidden. + if ( + conversationId !== undefined && + baseConversationId !== undefined && + usageState !== undefined && + !usageState.sawTokenDelta && + RESOURCE_EXHAUSTED_PATTERN.test(result.message) && + !rotatedConversationIds.has(baseConversationId) + ) { + const rotated = crypto.randomUUID(); + rotatedConversationIds.set(baseConversationId, rotated); + const state = conversationStateCache.get(conversationId); + if (state) conversationStateCache.set(rotated, state); + const blobs = conversationBlobStores.get(conversationId); + if (blobs) conversationBlobStores.set(rotated, blobs); + } output.stopReason = result.stopReason; output.errorStatus = result.status; output.errorId = result.id; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index d60c1004c..cbbdfd226 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -3,7 +3,7 @@ import { isKimiModelId } from "@oh-my-pi/pi-catalog/identity"; import { resolveWireModelId } from "@oh-my-pi/pi-catalog/model-thinking"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types"; -import { $env, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils"; +import { $env, logger, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils"; import { renderDemotedThinking } from "../dialect/demotion"; import * as AIError from "../error"; import { getKimiCommonHeaders } from "../registry/oauth/kimi"; @@ -44,6 +44,8 @@ import { notifyProviderResponse } from "../utils/provider-response"; import { callWithCopilotModelRetry } from "../utils/retry"; import { adaptSchemaForStrict, + findStrictToolSchemaViolation, + flattenExclusiveRequiredRootUnion, NO_STRICT, normalizeSchemaForMoonshot, sanitizeSchemaForGrammar, @@ -1299,6 +1301,16 @@ const streamOpenAICompletionsOnce = ( flushDeepseekStripBuffer(true); } + // Detect premature stream closure before the normal block-finalization + // sweep. Throwing after that sweep would make the error handler emit a + // second text_end/thinking_end for the same partial block. + if (streamFinishedAt === undefined && output.content.length > 0) { + throw new AIError.ProviderResponseError( + "OpenAI completions stream closed before a finish_reason was received", + { provider: model.provider, kind: "incomplete-stream" }, + ); + } + if (currentBlock?.type === "toolCall") { finishPendingToolCallBlocks(); } else { @@ -1575,24 +1587,26 @@ function buildParams( if (options?.minP !== undefined) { params.min_p = options.minP; } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.repetitionPenalty !== undefined) { - params.repetition_penalty = options.repetitionPenalty; - } - if (options?.frequencyPenalty !== undefined) { - params.frequency_penalty = options.frequencyPenalty; + if (initialCompat.supportsPenaltyAndStopParams) { + if (options?.presencePenalty !== undefined) { + params.presence_penalty = options.presencePenalty; + } + if (options?.repetitionPenalty !== undefined) { + params.repetition_penalty = options.repetitionPenalty; + } + if (options?.frequencyPenalty !== undefined) { + params.frequency_penalty = options.frequencyPenalty; + } } } - if (options?.stopSequences?.length) { + if (options?.stopSequences?.length && initialCompat.supportsPenaltyAndStopParams) { const seqs = options.stopSequences; params.stop = seqs.length === 1 ? seqs[0] : seqs.slice(0, 4); } applyOpenAIServiceTier(params, options?.serviceTier, model); if (context.tools?.length) { - const builtTools = convertTools(context.tools, initialCompat, toolStrictModeOverride); + const builtTools = convertTools(context.tools, initialCompat, toolStrictModeOverride, model.provider); params.tools = builtTools.tools; toolStrictMode = builtTools.toolStrictMode; strictToolsApplied = builtTools.strictToolsApplied; @@ -1653,7 +1667,10 @@ function buildParams( params.tool_choice = "auto"; } - if (params.tool_choice === "none" && (!Array.isArray(params.tools) || params.tools.length === 0)) { + if ( + (!Array.isArray(params.tools) || params.tools.length === 0) && + (params.tool_choice === "none" || isForcedToolChoice(params.tool_choice)) + ) { // `tool_choice: "none"` with no tools to gate is redundant and also // trips LiteLLM → Bedrock: the proxy serializes the directive into a // `toolConfig` block, and Bedrock requires `toolConfig.tools` to be @@ -1662,6 +1679,8 @@ function buildParams( // Side-channel turns hit this: `/btw` and IRC background replies route // through `AgentSession.runEphemeralTurn`, which sets `context.tools = []` // and `toolChoice: "none"` (see packages/coding-agent/src/session/agent-session.ts). + // The same empty-tools case applies after leftover-union quarantine: a + // leftover `"required"` / named force would 400 just like the bad schema. delete params.tool_choice; } @@ -1808,16 +1827,19 @@ export function convertMessages( ? 40 : undefined; const duplicateToolCallIdSuffixPrefix = compat.requiresMistralToolIds ? "dup" : undefined; - const normalizeToolCallId = (id: string): string => { + const normalizeToolCallId = (id: string, source?: AssistantMessage): string => { if (compat.requiresMistralToolIds) return normalizeMistralToolId(id, true); - // Handle pipe-separated IDs from OpenAI Responses API - // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =) - // These come from providers like github-copilot, openai-codex, opencode - // Extract just the call_id part and normalize it - if (id.includes("|")) { + const isSameModelSource = + source !== undefined && + source.provider === model.provider && + source.api === model.api && + source.model === model.id; + // Cross-model replay converts OpenAI Responses composite IDs from + // `{call_id}|{item_id}` to the Chat Completions `call_id`. Same-model + // Chat Completions IDs are provider-issued opaque correlation tokens. + if (!isSameModelSource && id.includes("|")) { const [callId] = id.split("|"); - // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit) return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40); } @@ -1827,7 +1849,7 @@ export function convertMessages( const transformedMessages = transformMessages( context.messages, model, - id => normalizeToolCallId(id), + (id, _target, source) => normalizeToolCallId(id, source), maxNormalizedToolCallIdLength, duplicateToolCallIdSuffixPrefix, compat, @@ -1859,8 +1881,8 @@ export function convertMessages( return nextId; }; - const ensureToolCallId = (rawId: string, seed: string): string => { - const normalized = normalizeToolCallId(rawId); + const ensureToolCallId = (rawId: string, seed: string, source?: AssistantMessage): string => { + const normalized = normalizeToolCallId(rawId, source); if (normalized.trim().length > 0) return normalized; return generateFallbackToolCallId(seed); }; @@ -2142,7 +2164,7 @@ export function convertMessages( } if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls.map((tc, toolCallIndex) => { - const toolCallId = ensureToolCallId(tc.id, `${i}:${toolCallIndex}:${tc.name}`); + const toolCallId = ensureToolCallId(tc.id, `${i}:${toolCallIndex}:${tc.name}`, msg); rememberToolCallId(tc.id, toolCallId); return { id: normalizeMistralToolId(toolCallId, compat.requiresMistralToolIds), @@ -2285,10 +2307,14 @@ function convertTools( tools: Tool[], compat: ResolvedOpenAICompat, toolStrictModeOverride?: ToolStrictModeOverride, + provider?: string, ): BuiltOpenAICompletionTools { + const rejectXaiRootObjectUnion = provider === "xai" || provider === "xai-oauth"; const adaptedTools = tools.map(tool => { const strict = !NO_STRICT && compat.supportsStrictMode !== false && tool.strict !== false; - const baseParameters = toolWireSchema(tool); + const baseParameters = rejectXaiRootObjectUnion + ? flattenExclusiveRequiredRootUnion(toolWireSchema(tool)) + : toolWireSchema(tool); const adapted = adaptSchemaForStrict(baseParameters, strict); return { tool, @@ -2308,47 +2334,55 @@ function convertTools( : "none" : "mixed"; + const wireTools: ChatCompletionTool[] = []; + let anyStrictEmitted = false; + for (const { tool, baseParameters, parameters, strict } of adaptedTools) { + const includeStrict = toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && strict); + // `strict: false` is semantically distinct from omitted `strict` on some + // backends: with it absent, optional properties may be over-filled with + // placeholder values (#4336). Preserve the author's explicit `false`, + // but only in "mixed" mode against a provider that understands the + // field — the `all_strict → none` collapse and `supportsStrictMode: + // false` paths deliberately keep the wire flag uniformly absent. + const includeExplicitFalse = + !includeStrict && tool.strict === false && toolStrictMode === "mixed" && compat.supportsStrictMode !== false; + const wireParameters = includeStrict ? parameters : baseParameters; + // Moonshot/Kimi native hosts validate against the stricter MFJS subset + // (const→enum, typed enums, no validators) and 400 otherwise. + // Grammar-constrained local backends (llama.cpp, LM Studio, vLLM) + // build a GBNF grammar from the schema and 400 with + // `Unrecognized schema: true` on the bare boolean subschema + // `toolWireSchema` emits for open fields (issue #5914). + const emittedParameters = + compat.toolSchemaFlavor === "moonshot-mfjs" + ? (normalizeSchemaForMoonshot(wireParameters) as Record) + : compat.toolSchemaFlavor === "grammar" + ? sanitizeSchemaForGrammar(wireParameters) + : wireParameters; + const violation = findStrictToolSchemaViolation(emittedParameters, "#", { rejectXaiRootObjectUnion }); + if (violation) { + logger.warn( + `Tool "${tool.name}" omitted from the openai-completions request: its parameter schema is invalid for this provider at ${violation} (an enum/const value cannot match its declared type, or leftover xAI object-root union). Other tools are unaffected.`, + ); + continue; + } + if (includeStrict) anyStrictEmitted = true; + wireTools.push({ + type: "function", + function: { + name: tool.name, + description: tool.description || "", + parameters: emittedParameters, + // Only include strict if provider supports it. Some reject unknown fields. + ...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}), + }, + }); + } + return { - tools: adaptedTools.map(({ tool, baseParameters, parameters, strict }) => { - const includeStrict = toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && strict); - // `strict: false` is semantically distinct from omitted `strict` on some - // backends: with it absent, optional properties may be over-filled with - // placeholder values (#4336). Preserve the author's explicit `false`, - // but only in "mixed" mode against a provider that understands the - // field — the `all_strict → none` collapse and `supportsStrictMode: - // false` paths deliberately keep the wire flag uniformly absent. - const includeExplicitFalse = - !includeStrict && - tool.strict === false && - toolStrictMode === "mixed" && - compat.supportsStrictMode !== false; - const wireParameters = includeStrict ? parameters : baseParameters; - return { - type: "function", - function: { - name: tool.name, - description: tool.description || "", - // Moonshot/Kimi native hosts validate against the stricter MFJS subset - // (const→enum, typed enums, no validators) and 400 otherwise. - // Grammar-constrained local backends (llama.cpp, LM Studio, vLLM) - // build a GBNF grammar from the schema and 400 with - // `Unrecognized schema: true` on the bare boolean subschema - // `toolWireSchema` emits for open fields (issue #5914). - parameters: - compat.toolSchemaFlavor === "moonshot-mfjs" - ? (normalizeSchemaForMoonshot(wireParameters) as Record) - : compat.toolSchemaFlavor === "grammar" - ? sanitizeSchemaForGrammar(wireParameters) - : wireParameters, - // Only include strict if provider supports it. Some reject unknown fields. - ...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}), - }, - }; - }), + tools: wireTools, toolStrictMode, - strictToolsApplied: - tools.length > 0 && - (toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && adaptedTools.some(tool => tool.strict))), + strictToolsApplied: wireTools.length > 0 && anyStrictEmitted, }; } @@ -2380,6 +2414,16 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | str // the message to match the session retry classifier's transient-transport // pattern (`provider.?returned.?error`) and get the turn auto-retried. return { stopReason: "error", errorMessage: "Provider returned error finish_reason" }; + case "insufficient_system_resource": + // DeepSeek kills the generation mid-stream when its inference system runs + // out of resources (docs: "the request is interrupted due to insufficient + // resource of the inference system"). Server-side capacity failure — like + // the bare `error` case, word the message to match the transient-transport + // retry pattern so the turn is auto-retried instead of pinned as an error. + return { + stopReason: "error", + errorMessage: "Provider returned error finish_reason: insufficient_system_resource", + }; default: return { stopReason: "error", diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 21b587e7e..873f3b2ec 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -39,6 +39,7 @@ import { callWithCopilotModelRetry } from "../utils/retry"; import { adaptSchemaForStrict, findStrictToolSchemaViolation, + flattenExclusiveRequiredRootUnion, NO_STRICT, normalizeSchemaForMoonshot, sanitizeSchemaForOpenAIResponses, @@ -1285,12 +1286,11 @@ export function buildParams( filterReasoningHistory: options?.filterReasoningHistory, omitReasoningEffort: options?.omitReasoningEffort, }); - const reasoningSummary = - model.provider === "xai-oauth" - ? options?.reasoning === undefined - ? undefined - : null - : options?.reasoningSummary; + const reasoningSummary = model.compat.supportsReasoningSummary + ? options?.reasoningSummary + : options?.reasoning === undefined + ? undefined + : null; applyResponsesCompatPolicy(params, reasoningPolicy, { reasoningSummary, forceReasoningOff: options?.forceReasoningOff, @@ -1388,6 +1388,7 @@ export function convertTools( ), ): OpenAITool[] { const allowFreeform = supportsFreeformApplyPatch(model); + const rejectXaiRootObjectUnion = model.provider === "xai" || model.provider === "xai-oauth"; const out: OpenAITool[] = []; for (const tool of tools) { if (tool.native?.type === "computer" && model.supportsComputerUse === true) { @@ -1420,16 +1421,18 @@ export function convertTools( // subschemas ("property schema … must be an object"), so the Moonshot // pass re-coerces them last. const sanitized = sanitizeSchemaForOpenAIResponses(baseParameters); + const providerParameters = rejectXaiRootObjectUnion ? flattenExclusiveRequiredRootUnion(sanitized) : sanitized; const responseParameters = model.compat.toolSchemaFlavor === "moonshot-mfjs" - ? (normalizeSchemaForMoonshot(sanitized) as Record) - : sanitized; + ? (normalizeSchemaForMoonshot(providerParameters) as Record) + : providerParameters; const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict); // Quarantine a tool whose emitted schema carries a provider-rejecting // enum/const-vs-type contradiction: dropping just that tool keeps the rest // of the request valid instead of letting one bad MCP schema 400 the whole - // turn (#2652). Other tools and built-ins are unaffected. - const violation = findStrictToolSchemaViolation(parameters); + // turn (#2652). Other tools and built-ins are unaffected. Leftover + // object-root unions are an xAI-only 400; OpenAI/Azure/Codex keep them. + const violation = findStrictToolSchemaViolation(parameters, "#", { rejectXaiRootObjectUnion }); if (violation) { onQuarantine(tool.name, violation); continue; diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 9d4f87861..f7b16f00e 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -3308,7 +3308,7 @@ export function applyCommonResponsesSamplingParams

& { - compat: Pick; + compat: Pick; }, ): void { if (options?.maxTokens && !model.omitMaxOutputTokens) { @@ -3325,8 +3325,10 @@ export function applyCommonResponsesSamplingParams

( } } -/** Thinking-loop re-samples spent before {@link resolveWithThinkingLoopCook} cooks. */ -const THINKING_LOOP_MAX_ABORTS = 3; +/** Maximum guarded attempts for a detected thinking loop. */ +const THINKING_LOOP_MAX_ATTEMPTS = 3; const THINKING_LOOP_RETRY_BASE_DELAY_MS = 500; const THINKING_LOOP_RETRY_MAX_DELAY_MS = 8_000; +function isRetryableThinkingLoop(message: AssistantMessage): boolean { + return ( + message.stopReason === "error" && + message.content.length === 0 && + AIError.is(message.errorId, AIError.Flag.ThinkingLoop) + ); +} + /** - * Resolve a completion, re-sampling a thinking-loop stall up to - * {@link THINKING_LOOP_MAX_ABORTS} times before letting it cook. The loop guard - * raises an empty `stopReason: "error"` stall on each guarded attempt; this - * result-path consumer re-dispatches a fresh request per stall and, once the abort - * budget is spent, runs one final pass with the guard disabled so a stubborn loop - * returns the model's raw output instead of a fatal stall. Non-stall results — - * including genuine errors — return immediately; a caller abort during backoff - * propagates so cancellation surfaces as an abort, never a stale stall result. + * Resolve a completion, re-sampling a thinking-loop stall for at most + * {@link THINKING_LOOP_MAX_ATTEMPTS} guarded attempts. The loop guard raises an + * empty `stopReason: "error"` stall; after the budget is spent that error is + * returned unchanged. Detection is never disabled as a fallback, because an + * unguarded retry can consume the remaining output budget and persist runaway + * content. Non-stall results, including genuine errors, return immediately. A + * caller abort during backoff propagates so cancellation surfaces as an abort, + * never a stale stall result. */ -async function resolveWithThinkingLoopCook( +async function resolveWithThinkingLoopRetries( signal: AbortSignal | undefined, dispatch: () => AssistantMessageEventStream, - cook: () => AssistantMessageEventStream, ): Promise { let message = await dispatch().result(); - let thinkingLoopRetry = AIError.is(message.errorId, AIError.Flag.ThinkingLoop); - for (let attempt = 0; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ABORTS - 1; attempt += 1) { + let thinkingLoopRetry = isRetryableThinkingLoop(message); + for (let attempt = 1; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ATTEMPTS; attempt += 1) { // A caller abort surfaces as a thrown abort (never the stall, which would // misclassify as a 502): throwIfAborted before backoff, and scheduler.wait // rejects if the abort lands mid-delay. signal?.throwIfAborted(); - const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** attempt, THINKING_LOOP_RETRY_MAX_DELAY_MS); + const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), THINKING_LOOP_RETRY_MAX_DELAY_MS); await scheduler.wait(delay, { signal }); message = await dispatch().result(); - thinkingLoopRetry = - message.stopReason === "error" && - message.content.length === 0 && - AIError.is(message.errorId, AIError.Flag.ThinkingLoop); + thinkingLoopRetry = isRetryableThinkingLoop(message); } - if (!thinkingLoopRetry) return message; - signal?.throwIfAborted(); - // Abort budget spent and still looping: let it cook with the guard disabled. - return cook().result(); + if (thinkingLoopRetry) signal?.throwIfAborted(); + return message; } export async function complete( @@ -1068,11 +1070,7 @@ export async function complete( context: Context, options?: OptionsForApi, ): Promise { - return resolveWithThinkingLoopCook( - options?.signal, - () => stream(model, context, options), - () => stream(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }), - ); + return resolveWithThinkingLoopRetries(options?.signal, () => stream(model, context, options)); } type AuthRetryFailure = { @@ -1578,23 +1576,27 @@ function streamSimpleRequest( // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens if (isGitLabDuoModel(model)) { - return withProviderInFlightLimit(model, requestOptions, () => - streamGitLabDuo(model, context, { - ...requestOptions, - apiKey, - }), + return withThinkingLoopGuard(model, requestOptions, opts => + withProviderInFlightLimit(model, opts, () => + streamGitLabDuo(model, context, { + ...opts, + apiKey, + }), + ), ); } // GitLab Duo Workflow - IDE workflow protocol + WebSocket action bridge if (model.api === "gitlab-duo-agent") { // Does not route through withProviderInFlightLimit, so heal explicitly. - return healLeakedThinking( - model, - streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, { - ...requestOptions, - apiKey, - }), + return withThinkingLoopGuard(model, requestOptions, opts => + healLeakedThinking( + model, + streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, { + ...opts, + apiKey, + }), + ), ); } @@ -1606,24 +1608,28 @@ function streamSimpleRequest( // thinking, so clamp disabled requests to the lowest supported effort // (mirrors the mapOptionsForApi path every other provider takes). const kimiOptions = normalizeMandatoryReasoningOptions(model, requestOptions); - return withProviderInFlightLimit(model, kimiOptions, () => - streamKimi(model as Model<"openai-completions">, context, { - ...kimiOptions, - apiKey, - format: kimiOptions?.kimiApiFormat, - }), + return withThinkingLoopGuard(model, kimiOptions, opts => + withProviderInFlightLimit(model, opts, () => + streamKimi(model as Model<"openai-completions">, context, { + ...opts, + apiKey, + format: opts?.kimiApiFormat, + }), + ), ); } // Synthetic - route to dedicated handler that wraps OpenAI or Anthropic API if (isSyntheticModel(model)) { - // Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally - return withProviderInFlightLimit(model, requestOptions, () => - streamSynthetic(model as Model<"openai-completions">, context, { - ...requestOptions, - apiKey, - format: requestOptions?.syntheticApiFormat ?? "openai", // Default to OpenAI format - }), + // Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally. + return withThinkingLoopGuard(model, requestOptions, opts => + withProviderInFlightLimit(model, opts, () => + streamSynthetic(model as Model<"openai-completions">, context, { + ...opts, + apiKey, + format: opts?.syntheticApiFormat ?? "openai", + }), + ), ); } const providerOptions = mapOptionsForApi(model, requestOptions, apiKey); @@ -1635,11 +1641,7 @@ export async function completeSimple( context: Context, options?: SimpleStreamOptions, ): Promise { - return resolveWithThinkingLoopCook( - options?.signal, - () => streamSimple(model, context, options), - () => streamSimple(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }), - ); + return resolveWithThinkingLoopRetries(options?.signal, () => streamSimple(model, context, options)); } const MIN_OUTPUT_TOKENS = 1024; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 43ae36965..48452b5c8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -716,8 +716,9 @@ export interface AnthropicFallbackContent { } /** - * Verbatim Anthropic web-search call/result retained for same-provider - * history replay. Other providers discard it in `transformMessages`. + * Verbatim Anthropic web-search or tool-search call/result retained for + * same-provider history replay. Other providers discard it in + * `transformMessages`. */ export interface AnthropicServerToolContent { type: "anthropicServerTool"; @@ -725,12 +726,12 @@ export interface AnthropicServerToolContent { | { type: "server_tool_use"; id: string; - name: "web_search"; + name: "web_search" | "tool_search_tool_regex" | "tool_search_tool_bm25"; input?: Record | null; [key: string]: unknown; } | { - type: "web_search_tool_result"; + type: "web_search_tool_result" | "tool_search_tool_result"; tool_use_id: string; content: unknown; [key: string]: unknown; diff --git a/packages/ai/src/usage/kimi.ts b/packages/ai/src/usage/kimi.ts index 94d2a4bd8..0e5670d7f 100644 --- a/packages/ai/src/usage/kimi.ts +++ b/packages/ai/src/usage/kimi.ts @@ -2,6 +2,7 @@ import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import { $env } from "@oh-my-pi/pi-utils"; import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import type { + CredentialRankingStrategy, UsageAmount, UsageFetchContext, UsageFetchParams, @@ -285,6 +286,7 @@ export const kimiUsageProvider: UsageProvider = { fetchedAt: nowMs, limits, metadata: { + accountId: credential.accountId, endpoint: url, }, raw: parsed.raw, @@ -293,3 +295,16 @@ export const kimiUsageProvider: UsageProvider = { return report; }, }; + +/** Ranks Kimi OAuth accounts by the canonical 5-hour and 7-day quota windows. */ +export const kimiRankingStrategy: CredentialRankingStrategy = { + findWindowLimits: report => ({ + primary: report.limits.find(limit => limit.window?.id === "5h"), + secondary: report.limits.find(limit => limit.window?.id === "7d"), + }), + scopeLimits: report => report.limits.filter(limit => limit.window?.id === "5h" || limit.window?.id === "7d"), + windowDefaults: { + primaryMs: 5 * HOUR_MS, + secondaryMs: 7 * DAY_MS, + }, +}; diff --git a/packages/ai/src/usage/umans.ts b/packages/ai/src/usage/umans.ts index a171731c8..61a1e64ee 100644 --- a/packages/ai/src/usage/umans.ts +++ b/packages/ai/src/usage/umans.ts @@ -23,9 +23,14 @@ interface UmansUsagePayload { requests?: { limit?: number; hard_cap?: number | null; window_seconds?: number }; concurrency?: { limit?: number; hard_cap?: number | null }; }; + /** Rolling 5h window metadata; `resets_at` anchors the status-line countdown. */ + window?: { started_at?: string; resets_at?: string; remaining_minutes?: number }; usage?: { requests_in_window?: number; remaining_requests?: number; + /** Model-weighted "effective requests" (umans-flash counts 0.5). */ + weighted_in_window?: number; + weighted_remaining_requests?: number; concurrent_sessions?: number; tokens_in?: number; tokens_out?: number; @@ -55,6 +60,18 @@ function resolveStatus(usedFraction: number | undefined): UsageStatus | undefine return "ok"; } +/** + * Soft-cap status never reaches `exhausted`: hitting the effective-request + * limit only means burst headroom is being consumed — Umans throttles (429) + * only near the burst ceiling, which the hard row tracks. `exhausted` must + * stay off this row or the usage-aware fallback demotes a healthy account. + */ +function softCapStatus(usedFraction: number | undefined): UsageStatus | undefined { + if (usedFraction === undefined) return undefined; + if (usedFraction >= 0.9) return "warning"; + return "ok"; +} + function buildAmount(args: { used: number | undefined; limit: number | undefined; @@ -75,30 +92,88 @@ function buildAmount(args: { }; } -function buildRequestsLimit(payload: UmansUsagePayload, provider: string): UsageLimit | null { +function buildRequestsLimits(payload: UmansUsagePayload, provider: string): UsageLimit[] { const limit = toFiniteNumber(payload.limits?.requests?.limit); + const hardCap = toFiniteNumber(payload.limits?.requests?.hard_cap); const windowSeconds = toFiniteNumber(payload.limits?.requests?.window_seconds); - const used = toFiniteNumber(payload.usage?.requests_in_window); - const remaining = toFiniteNumber(payload.usage?.remaining_requests); - if (limit === undefined && used === undefined) return null; - const amount = buildAmount({ used, limit, remaining, unit: "requests" }); - // Rolling window: each request ages out `window_seconds` after it fired, so - // there is no single reset timestamp. Surface the window size + label only. - // `window.id` is `"5h"` to match the status-line usage segment's window-id - // contract (it only recognizes `"5h"`/`"7d"`); `label` stays human-readable. + const rawUsed = toFiniteNumber(payload.usage?.requests_in_window); + const rawRemaining = toFiniteNumber(payload.usage?.remaining_requests); + const weightedUsed = toFiniteNumber(payload.usage?.weighted_in_window); + const weightedRemaining = toFiniteNumber(payload.usage?.weighted_remaining_requests); + if (limit === undefined && rawUsed === undefined && weightedUsed === undefined) return []; + + // The 5h window is rolling (FIFO: each request ages out five hours after it + // fired), but the payload still reports an absolute `resets_at` for the + // current window epoch — surface it as an incremental countdown (`tick`) + // rather than a hard reset. `window.id` is `"5h"` to match the status-line + // usage segment's window-id contract (it only recognizes `"5h"`/`"7d"`). + let resetsAt: number | undefined; + if (payload.window?.resets_at) { + const parsed = Date.parse(payload.window.resets_at); + resetsAt = Number.isNaN(parsed) ? undefined : parsed; + } const window: UsageWindow = { id: "5h", label: "rolling 5h", durationMs: windowSeconds ? windowSeconds * 1000 : 5 * HOUR_MS, + ...(resetsAt !== undefined ? { resetsAt, resetLabel: "tick" } : {}), }; - return { - id: "umans:requests", - label: "Requests (rolling 5h)", - scope: { provider, windowId: window.id, shared: true }, - window, - amount, - status: resolveStatus(amount.usedFraction), - }; + + // Single row: either payloads without weighted counters (legacy) or payloads + // that report weighted usage but no burst ceiling (`hard_cap`). Without a + // burst ceiling there is no hard row to defer exhaustion to, so the + // authoritative counter — weighted when available, else raw — drives the + // single row and CAN exhaust at the limit. Raw burst traffic above the + // limit still never drives exhaustion on its own: weighted headroom stays + // decisive (https://github.com/can1357/oh-my-pi/issues/7858). + if (weightedUsed === undefined || hardCap === undefined) { + const amount = buildAmount({ + used: weightedUsed ?? rawUsed, + limit, + remaining: weightedUsed !== undefined ? weightedRemaining : rawRemaining, + unit: "requests", + }); + return [ + { + id: "umans:requests", + label: "Requests (rolling 5h)", + scope: { provider, windowId: window.id, shared: true }, + window, + amount, + status: resolveStatus(amount.usedFraction), + }, + ]; + } + + // Umans weights requests by model ("effective requests": umans-flash counts + // 0.5), so the weighted counters are the authoritative utilization against + // the soft `limit`; the raw counters include burst/superseded traffic and + // read as exhausted mid-window while the account still has weighted + // headroom (https://github.com/can1357/oh-my-pi/issues/7858). Soft cap hits + // warn; only the burst ceiling (`hard_cap`, raw counts) can exhaust. + const softAmount = buildAmount({ used: weightedUsed, limit, remaining: weightedRemaining, unit: "requests" }); + const limits: UsageLimit[] = [ + { + id: "umans:requests:soft", + label: "Requests (soft cap)", + scope: { provider, windowId: window.id, shared: true }, + window, + amount: softAmount, + status: softCapStatus(softAmount.usedFraction), + }, + ]; + if (hardCap !== undefined && rawUsed !== undefined) { + const hardAmount = buildAmount({ used: rawUsed, limit: hardCap, remaining: undefined, unit: "requests" }); + limits.push({ + id: "umans:requests:hard", + label: "Requests (burst ceiling)", + scope: { provider, windowId: window.id, shared: true }, + window, + amount: hardAmount, + status: resolveStatus(hardAmount.usedFraction), + }); + } + return limits; } function buildConcurrencyLimit(payload: UmansUsagePayload, provider: string): UsageLimit | null { @@ -157,9 +232,7 @@ async function fetchUmansUsage(params: UsageFetchParams, ctx: UsageFetchContext) return null; } - const limits: UsageLimit[] = []; - const requests = buildRequestsLimit(payload, params.provider); - if (requests) limits.push(requests); + const limits: UsageLimit[] = [...buildRequestsLimits(payload, params.provider)]; const concurrency = buildConcurrencyLimit(payload, params.provider); if (concurrency) limits.push(concurrency); if (limits.length === 0) return null; diff --git a/packages/ai/src/utils/leaked-thinking-stream.ts b/packages/ai/src/utils/leaked-thinking-stream.ts index 93729a82d..79077ef04 100644 --- a/packages/ai/src/utils/leaked-thinking-stream.ts +++ b/packages/ai/src/utils/leaked-thinking-stream.ts @@ -25,7 +25,7 @@ * events are forwarded verbatim. */ -import { isAnthropicWebSearchHistoryBlock } from "../providers/anthropic-wire"; +import { isAnthropicServerToolHistoryBlock } from "../providers/anthropic-wire"; import type { AnthropicServerToolContent, AssistantMessage, @@ -430,7 +430,7 @@ class LeakedThinkingProjector { const pairedIndexes = new Set(); for (let srcIndex = 0; srcIndex < message.content.length; srcIndex++) { const content = message.content[srcIndex]; - if (content?.type !== "anthropicServerTool" || !isAnthropicWebSearchHistoryBlock(content.block)) continue; + if (content?.type !== "anthropicServerTool" || !isAnthropicServerToolHistoryBlock(content.block)) continue; if (content.block.type === "server_tool_use") { pendingCalls.set(content.block.id, srcIndex); continue; diff --git a/packages/ai/src/utils/schema/strict-tool-validation.ts b/packages/ai/src/utils/schema/strict-tool-validation.ts index 666c83d5d..77fa50287 100644 --- a/packages/ai/src/utils/schema/strict-tool-validation.ts +++ b/packages/ai/src/utils/schema/strict-tool-validation.ts @@ -7,8 +7,18 @@ * copied onto a `type: "null"` branch, or an `enum` placed on an `array` * schema instead of its `items`). One such tool 400s the entire turn, so * callers quarantine just the offending tool. See issue #2652. + * + * xAI additionally rejects a leftover *root* `anyOf`/`oneOf` whose branches + * are not objects ("tool parameter root must be an object type"). That class + * is opt-in via {@link FindStrictToolSchemaViolationOptions.rejectXaiRootObjectUnion} + * so OpenAI/Azure/Codex keep valid object-root unions. */ +export interface FindStrictToolSchemaViolationOptions { + /** xAI (paid + OAuth) only: leftover object-root unions 400 the whole turn. */ + rejectXaiRootObjectUnion?: boolean; +} + type JsonRecord = Record; const SCHEMA_TYPE_NAMES: Record = { @@ -70,10 +80,14 @@ const CHILD_ARRAY_KEYS = ["anyOf", "oneOf", "allOf", "prefixItems"] as const; * contradictions. Returns a JSON-pointer-ish path to the first offending node, * or `null` when the schema is safe to emit. */ -export function findStrictToolSchemaViolation(schema: unknown, path = "#"): string | null { +export function findStrictToolSchemaViolation( + schema: unknown, + path = "#", + options?: FindStrictToolSchemaViolationOptions, +): string | null { if (Array.isArray(schema)) { for (let i = 0; i < schema.length; i++) { - const hit = findStrictToolSchemaViolation(schema[i], `${path}/${i}`); + const hit = findStrictToolSchemaViolation(schema[i], `${path}/${i}`, options); if (hit) return hit; } return null; @@ -91,25 +105,45 @@ export function findStrictToolSchemaViolation(schema: unknown, path = "#"): stri } } + // xAI rejects the whole request when the *root* schema is typed as object + // (or has properties) AND still carries an anyOf/oneOf with a typeless or + // non-object branch. Nested unions and pure root unions are not this error. + if ( + options?.rejectXaiRootObjectUnion && + path === "#" && + (types.includes("object") || (node.properties !== undefined && typeof node.properties === "object")) + ) { + for (const key of ["anyOf", "oneOf"] as const) { + const arr = node[key]; + if (!Array.isArray(arr) || arr.length === 0) continue; + const hasNonObjectBranch = arr.some(branch => { + if (typeof branch !== "object" || branch === null || Array.isArray(branch)) return true; + const branchTypes = declaredTypes(branch as JsonRecord); + return !branchTypes.includes("object"); + }); + if (hasNonObjectBranch) return `${path}/${key}`; + } + } + for (const key of CHILD_MAP_KEYS) { const sub = node[key]; if (sub && typeof sub === "object" && !Array.isArray(sub)) { for (const k of Object.keys(sub as JsonRecord)) { - const hit = findStrictToolSchemaViolation((sub as JsonRecord)[k], `${path}/${key}/${k}`); + const hit = findStrictToolSchemaViolation((sub as JsonRecord)[k], `${path}/${key}/${k}`, options); if (hit) return hit; } } } for (const key of CHILD_SCHEMA_KEYS) { if (key in node) { - const hit = findStrictToolSchemaViolation(node[key], `${path}/${key}`); + const hit = findStrictToolSchemaViolation(node[key], `${path}/${key}`, options); if (hit) return hit; } } for (const key of CHILD_ARRAY_KEYS) { const arr = node[key]; if (Array.isArray(arr)) { - const hit = findStrictToolSchemaViolation(arr, `${path}/${key}`); + const hit = findStrictToolSchemaViolation(arr, `${path}/${key}`, options); if (hit) return hit; } } diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index 2f203c9de..916b49b21 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -204,6 +204,37 @@ function rewriteNullableScalarAnyOf(schema: Record): void { schema.type = [scalarType, "null"]; } +function isExclusiveRequiredBranch(branch: unknown): boolean { + if (!isSchemaRecord(branch)) return false; + if (Object.hasOwn(branch, "type")) return false; + if (!Array.isArray(branch.required) || branch.required.length === 0) return false; + if (!branch.required.every(name => typeof name === "string" && name.length > 0)) return false; + for (const key in branch) { + if (!Object.hasOwn(branch, key)) continue; + if (key === "required" || key === "description" || key === "title") continue; + return false; + } + return true; +} + +/** + * Return an xAI-compatible copy of an object-root schema whose union consists + * only of typeless required-key fragments. Other providers must retain the + * union because it is a real model-facing constraint. + */ +export function flattenExclusiveRequiredRootUnion(schema: Record): Record { + const unionKey = Array.isArray(schema.anyOf) ? "anyOf" : Array.isArray(schema.oneOf) ? "oneOf" : undefined; + if (!unionKey) return schema; + const union = schema[unionKey]; + if (!Array.isArray(union) || union.length === 0) return schema; + const typedObject = schema.type === "object" || (Array.isArray(schema.type) && schema.type.includes("object")); + if (!typedObject && !isSchemaRecord(schema.properties)) return schema; + if (!union.every(isExclusiveRequiredBranch)) return schema; + const flattened = { ...schema }; + delete flattened[unionKey]; + return flattened; +} + /** Keys whose values are a single JSON Schema (not an array or map). */ const SCHEMA_VALUE_KEYS = [ "additionalProperties", diff --git a/packages/ai/src/utils/thinking-loop.ts b/packages/ai/src/utils/thinking-loop.ts index 25e8a493b..cefc033cf 100644 --- a/packages/ai/src/utils/thinking-loop.ts +++ b/packages/ai/src/utils/thinking-loop.ts @@ -8,15 +8,17 @@ * or answering. The runaway is *not* byte-identical, so a cheap verbatim * tail-repeat check alone misses it. * - * This guard watches the streamed `thinking` deltas and, on a match, terminates - * the stream with a synthetic `error` {@link AssistantMessage} that carries - * **no observable content**. An empty-content `stopReason: "error"` message tagged - * with `AIError.Flag.ThinkingLoop` lets result consumers and `AgentSession` discard - * the runaway and re-sample instead of committing garbage transcript. + * This guard watches streamed deltas and, on a match, terminates the stream with + * a synthetic `error` {@link AssistantMessage} whose terminal content is empty. + * Deltas emitted before enough evidence accumulates may already be observable to + * a live streaming consumer; the empty terminal prevents the failed attempt from + * being committed or replayed. Tagged with `AIError.Flag.ThinkingLoop`, the + * result lets `AgentSession` discard the runaway and re-sample. * - * Three failure shapes are detected: - * 1. **Verbatim tail repetition** — a short unit repeated back-to-back (e.g. - * "🌊 🌊 🌊 …"). Caught from a rolling 250-char tail. + * Four failure shapes are detected: + * 1. **Exact suffix cycles** — a byte-identical unit repeated back-to-back, + * including long cycles such as the observed 311-character Kiro runaway. + * This bounded detector applies to every model. * 2. **Near-duplicate segments** — paragraphs that normalize to the same * word-trigram fingerprint. Caught with a Jaccard window over recent * paragraphs. Thresholds were calibrated on a real loop transcript plus @@ -28,13 +30,16 @@ * vocabulary and name nothing concrete. Caught by a run of low-novelty, * anchor-free segments; a segment naming a path/identifier resets the run, so * genuine but vocabulary-repetitive work (per-file templates) is spared. + * 4. **Gemini summary-header runaway** — handled separately by + * {@link GeminiHeaderRunDetector}. * - * Scope is narrow: guarded Gemini, DeepSeek, and Grok family streams before any tool call. Native - * thinking is checked first; assistant text can also be checked for providers - * that surface reasoning as visible prose. On a hit the failed turn is emitted as - * an empty retryable stream-stall error; result-awaiting callers (`complete`, - * `completeSimple`) re-sample it a few times and then let a stubborn loop cook - * through one unguarded pass. Disable detection with `PI_NO_THINKING_LOOP_GUARD=1`. + * Scope: exact cycles are guarded for every model; semantic heuristics remain + * limited to Gemini, DeepSeek, and Grok family streams before any tool call. + * Native thinking is checked first; assistant text can also be checked for + * providers that surface reasoning as visible prose. On a hit the failed turn is + * emitted as an empty retryable stream-stall error; result-awaiting callers + * (`complete`, `completeSimple`) re-sample at most three guarded attempts and + * then fail closed. Disable detection with `PI_NO_THINKING_LOOP_GUARD=1`. */ import { modelFamilyToken } from "@oh-my-pi/pi-catalog/identity"; import { logger } from "@oh-my-pi/pi-utils"; @@ -47,12 +52,17 @@ import { AssistantMessageEventStream } from "./event-stream"; * classifiers treat it as a transient (retryable) stop without bespoke rules. */ export const THINKING_LOOP_ERROR_MARKER = "Thinking loop detected"; -/** Rolling tail (chars) inspected for verbatim back-to-back repetition. */ -const VERBATIM_TAIL_WINDOW = 250; -/** Minimum total repeated chars before a verbatim run counts as a loop. */ -const VERBATIM_MIN_REPEATED_CHARS = 180; -/** Longest unit length probed for a verbatim repeat. */ -const VERBATIM_MAX_UNIT = 60; +/** Rolling tail retained for exact suffix-cycle detection. */ +const EXACT_TAIL_WINDOW = 4096; +/** Longest exact cycle length considered. */ +const EXACT_MAX_UNIT = 1024; +/** New characters between scans. Large deltas are scanned immediately. */ +const EXACT_CHECK_STRIDE = 128; +/** Short cycles need four repeats covering at least this many characters. */ +const EXACT_SHORT_MAX_UNIT = 60; +const EXACT_SHORT_MIN_REPEATED_CHARS = 180; +/** Long cycles need at least three repeats covering at least this many chars. */ +const EXACT_LONG_MIN_REPEATED_CHARS = 1024; /** Char cap for an unterminated segment; forces a flush so a wall-of-text loop * (no blank lines / headings) still segments. */ @@ -96,11 +106,12 @@ const CONCRETE_ANCHOR = /`[^`]+`|\b\w{2,}\.[a-zA-Z]\w{0,4}\b|[\w-]+(?:\/[\w-]+){2,}|\b\w+_\w+\b|\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b/g; /** - * True when `model.id` belongs to a family guarded for thinking/response loops: - * Gemini, DeepSeek, or Grok. + * True when `model.id` belongs to a family guarded by the semantic loop + * heuristics: Gemini, DeepSeek, or Grok. Exact suffix-cycle detection applies to + * every enabled model independently of this predicate. * * Model identity is derived only from its id; provider and compatibility metadata - * do not opt opaque aliases into the guard. + * do not opt opaque aliases into semantic detection. */ export function isLoopGuardedModel(model: Model, options?: StreamOptions): boolean { if (options?.loopGuard?.enabled === false) return false; @@ -120,8 +131,10 @@ export function isLoopGuardedModel(model: Model, options?: StreamOptions): * is responsible for stopping after the first hit. */ export class ThinkingLoopDetector { - /** Rolling char tail for verbatim repeat detection. */ + /** Rolling char tail for exact suffix-cycle detection. */ #tail = ""; + /** Total characters received when the exact detector last scanned. */ + #exactScannedAt = 0; /** Pending thinking text not yet split into completed segments. */ #pending = ""; /** Fingerprints of the most recent substantial segments (≤ SEGMENT_WINDOW). */ @@ -138,18 +151,27 @@ export class ThinkingLoopDetector { * path/identifier every paragraph is still caught. */ #anchorWindow: Set[] = []; + constructor(private readonly semanticHeuristics = true) {} + push(delta: string): string | null { if (!delta) return null; - // 1. Verbatim back-to-back repetition over the rolling tail. + // 1. Exact suffix cycles. Scan at a bounded cadence rather than doing + // quadratic work for every token-sized delta. this.#tail += delta; - if (this.#tail.length > VERBATIM_TAIL_WINDOW) this.#tail = this.#tail.slice(-VERBATIM_TAIL_WINDOW); - const verbatim = detectVerbatimRepetition(this.#tail); - if (verbatim) { - const [unit, times] = verbatim; - return `repeated "${unit.trim()}" ${times}× back-to-back`; + if (this.#tail.length > EXACT_TAIL_WINDOW) this.#tail = this.#tail.slice(-EXACT_TAIL_WINDOW); + this.#exactScannedAt += delta.length; + if (this.#exactScannedAt >= EXACT_CHECK_STRIDE || delta.length >= EXACT_CHECK_STRIDE) { + this.#exactScannedAt = 0; + const exact = detectExactSuffixCycle(this.#tail); + if (exact) { + const [unit, times] = exact; + return `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`; + } } + if (!this.semanticHeuristics) return null; + // 2. Near-duplicate paragraph loop. Append, then drain completed segments. this.#pending += delta; while (true) { @@ -179,7 +201,14 @@ export class ThinkingLoopDetector { * terminator). Called when the thinking block ends so the final segment — * which may be the one that completes a duplicate cluster — is not dropped. */ flush(): string | null { - if (!this.#pending) return null; + // A stream can end before the next cadence boundary. Force one final exact + // check even when semantic heuristics are disabled and #pending is empty. + const exact = detectExactSuffixCycle(this.#tail); + if (exact) { + const [unit, times] = exact; + return `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`; + } + if (!this.semanticHeuristics || !this.#pending) return null; let rest = this.#pending; this.#pending = ""; while (rest.length > 0) { @@ -349,8 +378,9 @@ export function guardThinkingLoopStream( options?: StreamOptions, ): AssistantMessageEventStream { const outer = new AssistantMessageEventStream(); - const thinkingDetector = new ThinkingLoopDetector(); - const textDetector = new ThinkingLoopDetector(); + const semanticHeuristics = isLoopGuardedModel(model, options); + const thinkingDetector = new ThinkingLoopDetector(semanticHeuristics); + const textDetector = new ThinkingLoopDetector(semanticHeuristics); const checkAssistantContent = options?.loopGuard?.checkAssistantContent !== false; void (async () => { @@ -415,12 +445,12 @@ export function guardThinkingLoopStream( } /** - * Apply the loop guard around a provider dispatch. For non-guarded models - * (or when disabled) this is a transparent pass-through. For guarded models it injects a - * guard abort signal into the provider call so a detected loop tears down the - * upstream, then wraps the returned stream. The guard only raises the retryable - * stall; bounding the re-samples and the final cook pass lives in the - * result-awaiting caller. + * Apply the loop guard around a provider dispatch. Unless explicitly disabled, + * every model gets exact suffix-cycle detection; Gemini, DeepSeek, and Grok also + * get the semantic heuristics selected by {@link isLoopGuardedModel}. The guard + * injects an abort signal into the provider call so a detected loop tears down + * the upstream, then wraps the returned stream. Bounding result-path re-samples + * lives in the result-awaiting caller. */ export function withThinkingLoopGuard< O extends { signal?: AbortSignal; loopGuard?: { enabled?: boolean; checkAssistantContent?: boolean } }, @@ -429,7 +459,7 @@ export function withThinkingLoopGuard< options: O | undefined, dispatch: (options: O | undefined) => AssistantMessageEventStream, ): AssistantMessageEventStream { - if (process.env.PI_NO_THINKING_LOOP_GUARD === "1" || !isLoopGuardedModel(model, options)) { + if (process.env.PI_NO_THINKING_LOOP_GUARD === "1" || options?.loopGuard?.enabled === false) { return dispatch(options); } const controller = new AbortController(); @@ -467,31 +497,34 @@ function buildThinkingLoopError(model: Model, detail: string): AssistantMes } /** - * Detect a short unit repeated back-to-back at the tail (verbatim loop). Only a - * unit carrying a letter or pictographic emoji counts — runs of digits, - * whitespace or punctuation are legitimate in tabular / hex / numeric output. + * Detect an exact cycle at the text suffix. A Z-array over the reversed tail + * finds every possible suffix period in linear time without substring churn. + * Short cycles retain the original 180-character/four-repeat sensitivity; long + * cycles require at least three repeats and 1024 repeated characters. */ -function detectVerbatimRepetition(text: string): [unit: string, count: number] | null { - if (text.length < VERBATIM_MIN_REPEATED_CHARS) return null; - const windowSize = Math.min(text.length, VERBATIM_TAIL_WINDOW); - const searchSpace = text.slice(-windowSize); - - for (let len = 2; len <= VERBATIM_MAX_UNIT; len++) { - if (searchSpace.length < len * 4) continue; - const unit = searchSpace.slice(-len); - if (!/[\p{L}\p{Extended_Pictographic}]/u.test(unit)) continue; - - let count = 0; - let pos = searchSpace.length; - while (pos >= len) { - if (searchSpace.slice(pos - len, pos) === unit) { - count++; - pos -= len; - } else { - break; - } +function detectExactSuffixCycle(text: string): [unit: string, count: number] | null { + if (text.length < EXACT_SHORT_MIN_REPEATED_CHARS) return null; + const reversed = text.split("").reverse().join(""); + const z = new Uint16Array(reversed.length); + let left = 0; + let right = 0; + for (let i = 1; i < reversed.length; i++) { + if (i <= right) z[i] = Math.min(right - i + 1, z[i - left]); + while (i + z[i] < reversed.length && reversed[z[i]] === reversed[i + z[i]]) z[i]++; + if (i + z[i] - 1 > right) { + left = i; + right = i + z[i] - 1; } - if (count >= 4 && len * count >= VERBATIM_MIN_REPEATED_CHARS) return [unit, count]; + } + + const maxUnit = Math.min(EXACT_MAX_UNIT, Math.floor(reversed.length / 3)); + for (let len = 2; len <= maxUnit; len++) { + const count = 1 + Math.floor(z[len] / len); + const minCount = len <= EXACT_SHORT_MAX_UNIT ? 4 : 3; + const minChars = len <= EXACT_SHORT_MAX_UNIT ? EXACT_SHORT_MIN_REPEATED_CHARS : EXACT_LONG_MIN_REPEATED_CHARS; + if (count < minCount || len * count < minChars) continue; + const unit = text.slice(-len); + if (/\p{L}|\p{Extended_Pictographic}/u.test(unit)) return [unit, count]; } return null; } diff --git a/packages/ai/test/alibaba-endpoint-selection.test.ts b/packages/ai/test/alibaba-endpoint-selection.test.ts index 30cd8e266..898829457 100644 --- a/packages/ai/test/alibaba-endpoint-selection.test.ts +++ b/packages/ai/test/alibaba-endpoint-selection.test.ts @@ -69,8 +69,10 @@ describe("alibaba-coding-plan endpoint selection", () => { expect(result.access).toBe("sk-cn-key"); expect(result.refresh).toBe("sk-cn-key"); expect(result.enterpriseUrl).toBe("https://coding.dashscope.aliyuncs.com/v1"); - expect(capturedAuth?.url).toBe("https://dashscope.console.aliyun.com/"); - expect(capturedAuth?.instructions).toContain("China mainland"); + expect(capturedAuth?.url).toBe("https://bailian.console.aliyun.com/?tab=model#/api-key"); + expect(capturedAuth?.instructions).toBe( + "Copy your API key from the Alibaba Cloud Bailian console (China mainland)", + ); expect(validateSpy).toHaveBeenCalledWith({ provider: "Alibaba Coding Plan", diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index f6c2f9364..7184a8b3a 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -6,7 +6,10 @@ import { type AnthropicMessagesClientLike, type AnthropicRequestOptions, } from "@oh-my-pi/pi-ai/providers/anthropic-client"; -import type { WebSearchToolResultBlockParam } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; +import type { + ToolSearchToolResultBlockParam, + WebSearchToolResultBlockParam, +} from "@oh-my-pi/pi-ai/providers/anthropic-wire"; import type { AssistantMessageEvent, Context, Model, ModelSpec, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { structuredCloneJSON } from "@oh-my-pi/pi-utils"; @@ -676,6 +679,117 @@ describe("anthropic stream envelope handling", () => { ]); }); + it("replays tool-search server blocks between signed thinking and a client tool call", async () => { + const toolSearchResult: ToolSearchToolResultBlockParam = { + type: "tool_search_tool_result", + tool_use_id: "srvtoolu_search", + content: { + type: "tool_search_tool_search_result", + tool_references: [{ type: "tool_reference", tool_name: "_read" }], + }, + }; + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( + () => + createMockRequest([ + { + type: "message_start", + message: { + id: "msg_tool_search", + usage: { + input_tokens: 12, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Find read." } }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig-1" } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "server_tool_use", + id: "srvtoolu_search", + name: "tool_search_tool_regex", + }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"pattern":"read"}' }, + }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: toolSearchResult }, + { type: "content_block_stop", index: 2 }, + { type: "content_block_start", index: 3, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 3, delta: { type: "thinking_delta", thinking: "Use read." } }, + { type: "content_block_delta", index: 3, delta: { type: "signature_delta", signature: "sig-2" } }, + { type: "content_block_stop", index: 3 }, + { + type: "content_block_start", + index: 4, + content_block: { type: "tool_use", id: "tool_1", name: "_read", input: {} }, + }, + { + type: "content_block_delta", + index: 4, + delta: { type: "input_json_delta", partial_json: '{"path":"notes.txt"}' }, + }, + { type: "content_block_stop", index: 4 }, + { + type: "message_delta", + delta: { stop_reason: "tool_use" }, + usage: { + input_tokens: 12, + output_tokens: 20, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + { type: "message_stop" }, + ]) as never, + ); + + const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); + for await (const _ of stream) { + // drain stream + } + const result = structuredCloneJSON(await stream.result()); + const replay = convertAnthropicMessages( + [ + context.messages[0], + result, + { + role: "toolResult", + toolCallId: "tool_1", + toolName: "_read", + content: [{ type: "text", text: "notes" }], + isError: false, + timestamp: 2, + }, + ], + model, + false, + ); + const assistant = replay.find(message => message.role === "assistant"); + + expect(assistant?.content).toEqual([ + { type: "thinking", thinking: "Find read.", signature: "sig-1" }, + { + type: "server_tool_use", + id: "srvtoolu_search", + name: "tool_search_tool_regex", + input: { pattern: "read" }, + }, + toolSearchResult, + { type: "thinking", thinking: "Use read.", signature: "sig-2" }, + { type: "tool_use", id: "tool_1", name: "_read", input: { path: "notes.txt" } }, + ]); + }); + it("does not persist a code-execution call without its unsupported result block", async () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () => diff --git a/packages/ai/test/auth-gateway-anthropic-messages.test.ts b/packages/ai/test/auth-gateway-anthropic-messages.test.ts index 538df9197..5cc34d2fc 100644 --- a/packages/ai/test/auth-gateway-anthropic-messages.test.ts +++ b/packages/ai/test/auth-gateway-anthropic-messages.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "bun:test"; import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/anthropic-messages-server"; import type { + ToolSearchServerToolUseBlockParam, + ToolSearchToolResultBlockParam, WebSearchServerToolUseBlockParam, WebSearchToolResultBlockParam, } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; @@ -344,6 +346,47 @@ describe("anthropic-messages parseRequest", () => { ]); }); + it("preserves inbound assistant tool-search call/result blocks verbatim", () => { + const serverToolUse: ToolSearchServerToolUseBlockParam = { + type: "server_tool_use", + id: "srvtoolu_search", + name: "tool_search_tool_regex", + input: { pattern: "read" }, + }; + const searchResult: ToolSearchToolResultBlockParam = { + type: "tool_search_tool_result", + tool_use_id: "srvtoolu_search", + content: { + type: "tool_search_tool_search_result", + tool_references: [{ type: "tool_reference", tool_name: "_read" }], + }, + }; + const parsed = parseRequest({ + model: "claude-opus-4-7", + max_tokens: 8, + messages: [ + { role: "user", content: "read notes" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "find read", signature: "sig-1" }, + serverToolUse, + searchResult, + { type: "text", text: "tool loaded" }, + ], + }, + ], + }); + const assistant = parsed.context.messages.find(message => message.role === "assistant"); + + expect(assistant?.content).toEqual([ + { type: "thinking", thinking: "find read", thinkingSignature: "sig-1" }, + { type: "anthropicServerTool", block: serverToolUse }, + { type: "anthropicServerTool", block: searchResult }, + { type: "text", text: "tool loaded" }, + ]); + }); + it("flattens malformed web-search history blocks instead of preserving invalid replay state", () => { const parsed = parseRequest({ model: "claude-opus-4-7", diff --git a/packages/ai/test/auth-gateway-thinking-loop.test.ts b/packages/ai/test/auth-gateway-thinking-loop.test.ts index e2e623b64..d0b274787 100644 --- a/packages/ai/test/auth-gateway-thinking-loop.test.ts +++ b/packages/ai/test/auth-gateway-thinking-loop.test.ts @@ -26,17 +26,15 @@ afterEach(() => { clearCustomApis(); }); -describe("auth-gateway non-streaming thinking-loop cook", () => { - it("returns 200 with cooked output instead of a 502 when the model loops", async () => { +describe("auth-gateway non-streaming thinking-loop retries", () => { + it("returns an error after three guarded looping attempts", async () => { registerMockApi(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "gw-thinking-loop-")); const storage = await AuthStorage.create(path.join(dir, "auth.db")); storage.setRuntimeApiKey("openrouter", "test-key"); const mock = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }); - // Three guarded attempts stall on the thinking loop; the fourth (cook) pass - // runs with the guard disabled and returns the visible answer. for (let i = 0; i < 4; i++) { - mock.push({ content: [{ type: "thinking", thinking: loopThinking() }, "Final answer after cooking."] }); + mock.push({ content: [{ type: "thinking", thinking: loopThinking() }, "Unreachable cooked answer."] }); } const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined); const handle = startAuthGateway({ @@ -56,18 +54,12 @@ describe("auth-gateway non-streaming thinking-loop cook", () => { stream: false, }), }); - const body = (await res.json()) as { - error?: unknown; - choices?: Array<{ message?: { content?: string | null } }>; - }; + const body = (await res.json()) as { error?: unknown }; - expect(res.status).toBe(200); - expect(body.error).toBeUndefined(); - expect(body.choices?.[0]?.message?.content).toContain("Final answer after cooking."); - // Three guarded stalls + one unguarded cook pass. - expect(mock.calls).toHaveLength(4); - expect(mock.calls[0]?.options?.loopGuard?.enabled).toBeUndefined(); - expect(mock.calls[3]?.options?.loopGuard?.enabled).toBe(false); + expect(res.status).toBe(502); + expect(body.error).toBeDefined(); + expect(mock.calls).toHaveLength(3); + expect(mock.calls.every(call => call.options?.loopGuard?.enabled !== false)).toBe(true); } finally { waitSpy.mockRestore(); await handle.close(); @@ -101,7 +93,7 @@ describe("auth-gateway non-streaming thinking-loop cook", () => { }), }); - // A genuine error is never a loop stall, so the cook fallback must not mask it. + // A genuine error is never a loop stall, so loop retry handling must not mask it. expect(res.status).toBe(502); expect(mock.calls).toHaveLength(1); expect(THINKING_LOOP_ERROR_MARKER.length).toBeGreaterThan(0); diff --git a/packages/ai/test/cursor-conversation-rotate.test.ts b/packages/ai/test/cursor-conversation-rotate.test.ts new file mode 100644 index 000000000..8c5e633fa --- /dev/null +++ b/packages/ai/test/cursor-conversation-rotate.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as http2 from "node:http2"; +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { streamCursor } from "@oh-my-pi/pi-ai/providers/cursor"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { + AgentClientMessageSchema, + AgentServerMessageSchema, + InteractionUpdateSchema, + TextDeltaUpdateSchema, + TurnEndedUpdateSchema, +} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; + +// #8345: a server-side per-conversation rejection (bare resource_exhausted, +// zero tokens) poisons the wire conversationId; the next attempt must rotate +// to a fresh id and succeed, instead of failing forever until /fork. + +let server: http2.Http2Server | undefined; +const sessions = new Set(); + +function frameConnectMessage(data: Uint8Array, flags = 0): Buffer { + const frame = Buffer.alloc(5 + data.length); + frame[0] = flags; + frame.writeUInt32BE(data.length, 1); + frame.set(data, 5); + return frame; +} + +function textDeltaFrame(text: string): Buffer { + const message = create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "textDelta", value: create(TextDeltaUpdateSchema, { text }) }, + }), + }, + }); + return frameConnectMessage(toBinary(AgentServerMessageSchema, message)); +} + +function turnEndedFrame(): Buffer { + const message = create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }), + }, + }); + return frameConnectMessage(toBinary(AgentServerMessageSchema, message)); +} + +/** Decode the wire conversationId from the first client frame of a request. */ +function decodeConversationId(chunk: Buffer): string | undefined { + const msg = fromBinary(AgentClientMessageSchema, chunk.subarray(5)); + if (msg.message.case !== "runRequest") return undefined; + return msg.message.value.conversationId; +} + +/** First request ends with a bare resource_exhausted; later ones turn normally. */ +async function startServer(seenConversationIds: string[]): Promise { + server = http2.createServer(); + server.on("session", session => { + sessions.add(session); + session.on("close", () => sessions.delete(session)); + }); + let requestCount = 0; + server.on("stream", (stream: http2.ServerHttp2Stream) => { + stream.on("data", (chunk: Buffer) => { + const conversationId = decodeConversationId(chunk); + if (conversationId !== undefined) seenConversationIds.push(conversationId); + requestCount++; + if (requestCount === 1) { + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }, { waitForTrailers: true }); + stream.once("wantTrailers", () => { + stream.sendTrailers({ "grpc-status": "8", "grpc-message": "resource_exhausted" }); + }); + stream.end(); + } else { + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(textDeltaFrame("recovered")); + stream.write(turnEndedFrame()); + stream.end(); + } + }); + }); + + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(0, "127.0.0.1", listening.resolve); + await listening.promise; + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected the fixture server to bind a tcp port"); + return `http://127.0.0.1:${address.port}`; +} + +async function stopServer(): Promise { + for (const session of sessions) session.destroy(); + sessions.clear(); + if (!server) return; + const closing = server; + server = undefined; + const closed = Promise.withResolvers(); + closing.close(error => (error ? closed.reject(error) : closed.resolve())); + await closed.promise; +} + +function makeModel(baseUrl: string): Model<"cursor-agent"> { + return buildModel({ + id: "cursor-rotation-fixture", + name: "Cursor rotation fixture", + api: "cursor-agent", + provider: "cursor", + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1, + maxTokens: 1, + }); +} + +const context: Context = { messages: [{ role: "user", content: "hello", timestamp: 1 }] }; + +/** Drain a stream and return its terminal event (done / error). */ +async function runToEnd(baseUrl: string, sessionId: string): Promise<{ type: "done" | "error"; message?: string }> { + const stream = streamCursor(makeModel(baseUrl), context, { apiKey: "test-token", sessionId }); + let terminal: { type: "done" | "error"; message?: string } = { type: "done" }; + for await (const event of stream) { + if (event.type === "error") { + terminal = { type: "error", message: event.error.errorMessage }; + } + } + await stream.result().catch(() => {}); + return terminal; +} + +afterEach(async () => { + await stopServer(); +}); + +describe("Cursor conversationId rotation (issue #8345)", () => { + it("rotates the poisoned conversationId and recovers on the next attempt", async () => { + const seenConversationIds: string[] = []; + const baseUrl = await startServer(seenConversationIds); + + const first = await runToEnd(baseUrl, "sess-poisoned"); + expect(first.type).toBe("error"); + expect(first.message).toMatch(/resource.?exhausted/i); + + const second = await runToEnd(baseUrl, "sess-poisoned"); + expect(second.type).toBe("done"); + + expect(seenConversationIds).toHaveLength(2); + expect(seenConversationIds[0]).toBe("sess-poisoned"); + expect(seenConversationIds[1]).not.toBe(seenConversationIds[0]); + }); + + it("keeps the rotated id when the new conversation is also rejected", async () => { + const seenConversationIds: string[] = []; + // Fail every request: rotation must happen exactly once. + server = http2.createServer(); + server.on("session", session => { + sessions.add(session); + session.on("close", () => sessions.delete(session)); + }); + server.on("stream", (stream: http2.ServerHttp2Stream) => { + stream.on("data", (chunk: Buffer) => { + const conversationId = decodeConversationId(chunk); + if (conversationId !== undefined) seenConversationIds.push(conversationId); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }, { waitForTrailers: true }); + stream.once("wantTrailers", () => { + stream.sendTrailers({ "grpc-status": "8", "grpc-message": "resource_exhausted" }); + }); + stream.end(); + }); + }); + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(0, "127.0.0.1", listening.resolve); + await listening.promise; + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected the fixture server to bind a tcp port"); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const r1 = await runToEnd(baseUrl, "sess-sticky"); + const r2 = await runToEnd(baseUrl, "sess-sticky"); + const r3 = await runToEnd(baseUrl, "sess-sticky"); + console.log( + "[test] results:", + JSON.stringify([r1.type, r1.message, r2.type, r2.message, r3.type, r3.message]), + "seen:", + JSON.stringify(seenConversationIds), + ); + + expect(seenConversationIds).toHaveLength(3); + expect(seenConversationIds[0]).toBe("sess-sticky"); + expect(seenConversationIds[1]).toBe(seenConversationIds[2]); + expect(seenConversationIds[1]).not.toBe("sess-sticky"); + }); +}); diff --git a/packages/ai/test/error-id.test.ts b/packages/ai/test/error-id.test.ts index 668218794..8823684e7 100644 --- a/packages/ai/test/error-id.test.ts +++ b/packages/ai/test/error-id.test.ts @@ -130,6 +130,24 @@ describe("error-id classification", () => { expect(assistant.errorId).toBe(id); }); + it("classifies Cursor NGHTTP2 stream resets as transient", () => { + for (const errorMessage of [ + "Stream closed with error code NGHTTP2_INTERNAL_ERROR", + "Stream closed with error code NGHTTP2_REFUSED_STREAM", + "Connect error failed_precondition: Error: Stream closed with error code NGHTTP2_REFUSED_STREAM", + ]) { + const assistant = message({ + api: "cursor-agent", + provider: "cursor", + model: "composer-2.5", + errorMessage, + }); + const id = AIError.classifyMessage(assistant); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + expect(AIError.retriable(id)).toBe(true); + } + }); + it("merges existing cause-chain kinds with finalized error text kinds", () => { const assistant = message({ errorId: AIError.create(AIError.Flag.ThinkingLoop), diff --git a/packages/ai/test/glm-5.3-reasoning-effort.test.ts b/packages/ai/test/glm-5.3-reasoning-effort.test.ts new file mode 100644 index 000000000..158507292 --- /dev/null +++ b/packages/ai/test/glm-5.3-reasoning-effort.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; +import { Effort, type FetchImpl } from "@oh-my-pi/pi-ai"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; + +// GLM-5.3 replaces GLM-5.2's host-specific reasoning_effort dialects with a +// single uniform wire-exact low/high/max ladder on every host, and thinking can +// no longer be disabled (thinking.type must always be "enabled"). These tests +// pin both contracts so a future change cannot regress to the GLM-5.2 shape. +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +function glm53OnFireworks(): Model<"openai-completions"> { + return buildModel({ + id: "glm-5.3", + name: "GLM-5.3", + api: "openai-completions", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 131_072, + } satisfies ModelSpec<"openai-completions">); +} + +function glm53OnZaiAnthropic(): Model<"anthropic-messages"> { + return buildModel({ + id: "glm-5.3", + name: "GLM-5.3", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 131_072, + } satisfies ModelSpec<"anthropic-messages">); +} + +async function captureChatBody( + model: Model<"openai-completions">, + options: { reasoning?: Effort; disableReasoning?: boolean }, +): Promise<{ reasoning_effort?: string; thinking?: { type?: string } }> { + let requestBody: string | undefined; + const fetchMock: FetchImpl = (_input, init) => { + requestBody = typeof init?.body === "string" ? init.body : undefined; + return Promise.resolve( + new Response( + 'data: {"choices":[{"delta":{"content":"ok"}}]}\ndata: {"choices":[{"finish_reason":"stop"}]}\ndata: [DONE]\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + ); + }; + const stream = streamSimple(model, context, { apiKey: "k", fetch: fetchMock, ...options }); + await stream.result(); + if (!requestBody) throw new Error("request body was not captured"); + return JSON.parse(requestBody); +} + +describe("GLM-5.3 reasoning effort wire mapping", () => { + it("derives the uniform low/high/max ladder on a direct GLM host (not the GLM-5.2 host-specific shape)", () => { + const model = glm53OnFireworks(); + expect(model.thinking?.efforts).toEqual([Effort.Low, Effort.High, Effort.Max]); + expect(model.thinking?.requiresEffort).toBe(true); + expect(model.thinking?.defaultLevel).toBe(Effort.Max); + }); + + it("sends wire-exact low/high/max reasoning_effort on a direct GLM host", async () => { + const model = glm53OnFireworks(); + expect((await captureChatBody(model, { reasoning: Effort.Low })).reasoning_effort).toBe("low"); + expect((await captureChatBody(model, { reasoning: Effort.High })).reasoning_effort).toBe("high"); + expect((await captureChatBody(model, { reasoning: Effort.Max })).reasoning_effort).toBe("max"); + }); + + it("clamps thinking-off to the lowest effort instead of disabling (GLM-5.3 cannot disable thinking)", async () => { + const model = glm53OnFireworks(); + const body = await captureChatBody(model, { disableReasoning: true }); + expect(body.reasoning_effort).toBe("low"); + expect(body.thinking).toBeUndefined(); + }); + + it("clamps omitted reasoning to the lowest effort", async () => { + const model = glm53OnFireworks(); + const body = await captureChatBody(model, {}); + expect(body.reasoning_effort).toBe("low"); + }); + + it("derives mandatory reasoning on the zai Anthropic endpoint too", () => { + const model = glm53OnZaiAnthropic(); + expect(model.thinking?.efforts).toEqual([Effort.Low, Effort.High, Effort.Max]); + expect(model.thinking?.requiresEffort).toBe(true); + expect(model.thinking?.defaultLevel).toBe(Effort.Max); + expect(model.thinking?.mode).toBe("anthropic-budget-effort"); + }); +}); diff --git a/packages/ai/test/issue-931-repro.test.ts b/packages/ai/test/issue-931-repro.test.ts index bfe8b620a..df50d9449 100644 --- a/packages/ai/test/issue-931-repro.test.ts +++ b/packages/ai/test/issue-931-repro.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, OpenAICompat } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; const testContext: Context = { @@ -29,7 +30,9 @@ function captureResponsesPayload( } function customResponsesModel(compat: OpenAICompat): Model<"openai-responses"> { - return { + // Resolve compat through the production constructor: sparse user overrides on + // top of host detection, exactly as a configured custom model is built. + return buildModel<"openai-responses">({ id: "deepseek-v4-flash:cloud", name: "deepseek-v4-flash:cloud", api: "openai-responses", @@ -46,7 +49,7 @@ function customResponsesModel(compat: OpenAICompat): Model<"openai-responses"> { effortMap: compat.reasoningEffortMap, }, compat, - } as unknown as Model<"openai-responses">; + }); } describe("issue #931 — openai-responses reasoning effort mapping", () => { diff --git a/packages/ai/test/issue-945-repro.test.ts b/packages/ai/test/issue-945-repro.test.ts index d38b0bcf0..95578963b 100644 --- a/packages/ai/test/issue-945-repro.test.ts +++ b/packages/ai/test/issue-945-repro.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type } from "@oh-my-pi/omptype"; -import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { type OpenAICompletionsOptions, streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -23,7 +24,7 @@ function abortedSignal(): AbortSignal { async function capturePayload( model: Model<"openai-completions">, - opts: Parameters[2], + opts: OpenAICompletionsOptions, ): Promise> { const { promise, resolve } = Promise.withResolvers(); streamOpenAICompletions(model, context, { @@ -35,12 +36,36 @@ async function capturePayload( return (await promise) as Record; } +async function captureResponsesPayload( + model: Model<"openai-responses">, + opts: OpenAIResponsesOptions, +): Promise> { + const { promise, resolve } = Promise.withResolvers(); + streamOpenAIResponses(model, context, { + ...opts, + apiKey: "test-key", + signal: abortedSignal(), + onPayload: payload => resolve(payload), + }); + return (await promise) as Record; +} + describe("OpenCode Go tool_choice compatibility", () => { it("marks deepseek-v4-pro as not supporting tool_choice via compat override", () => { const model = getBundledModel("opencode-go", "deepseek-v4-pro") as Model<"openai-completions">; expect(model.compat?.supportsToolChoice).toBe(false); }); + it("omits forced tool_choice from DeepSeek Flash Responses payloads while preserving tools", async () => { + const model = getBundledModel("opencode-go", "deepseek-v4-flash") as Model<"openai-responses">; + expect(model.compat.supportsToolChoice).toBe(false); + const body = await captureResponsesPayload(model, { + toolChoice: { type: "tool", name: "echo" }, + }); + expect(body.tools).toEqual([expect.objectContaining({ type: "function", name: "echo" })]); + expect(body.tool_choice).toBeUndefined(); + }); + it("marks mimo-v2.5-pro as not supporting tool_choice via compat override", () => { const model = getBundledModel("opencode-go", "mimo-v2.5-pro") as Model<"openai-completions">; expect(model.compat?.supportsToolChoice).toBe(false); diff --git a/packages/ai/test/issue-967-vision-guard.test.ts b/packages/ai/test/issue-967-vision-guard.test.ts index 679e5741e..b4dca632c 100644 --- a/packages/ai/test/issue-967-vision-guard.test.ts +++ b/packages/ai/test/issue-967-vision-guard.test.ts @@ -28,6 +28,7 @@ const compat: ResolvedOpenAICompat = { supportsReasoningEffort: true, supportsReasoningParams: true, supportsSamplingParams: true, + supportsPenaltyAndStopParams: true, alwaysSendMaxTokens: false, isOpenRouterHost: false, isVercelGatewayHost: false, diff --git a/packages/ai/test/kimi-multi-account.test.ts b/packages/ai/test/kimi-multi-account.test.ts new file mode 100644 index 000000000..9dc2c2acb --- /dev/null +++ b/packages/ai/test/kimi-multi-account.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; +import * as kimiOauth from "@oh-my-pi/pi-ai/registry/oauth/kimi"; +import type { OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types"; +import type { UsageLimit, UsageProvider, UsageReport } from "@oh-my-pi/pi-ai/usage"; +import { removeWithRetries } from "../../utils/src/temp"; + +const HOUR_MS = 60 * 60 * 1000; +const WEEK_MS = 7 * 24 * HOUR_MS; + +function createCredential(accountId: string): OAuthCredentials { + return { + access: `access-${accountId}`, + refresh: `refresh-${accountId}`, + expires: Date.now() + HOUR_MS, + accountId, + }; +} + +function createUsageReport(accountId: string, fiveHourUsed: number, weeklyUsed: number): UsageReport { + const now = Date.now(); + const limits: UsageLimit[] = [ + { + id: "kimi-code:7d", + label: "7d limit", + scope: { provider: "kimi-code", accountId, windowId: "7d", shared: true }, + window: { id: "7d", label: "7d limit", durationMs: WEEK_MS, resetsAt: now + WEEK_MS }, + amount: { + unit: "percent", + usedFraction: weeklyUsed, + remainingFraction: 1 - weeklyUsed, + }, + status: weeklyUsed >= 1 ? "exhausted" : weeklyUsed >= 0.9 ? "warning" : "ok", + }, + { + id: "kimi-code:5h", + label: "5h limit", + scope: { provider: "kimi-code", accountId, windowId: "5h", shared: true }, + window: { id: "5h", label: "5h limit", durationMs: 5 * HOUR_MS, resetsAt: now + 5 * HOUR_MS }, + amount: { + unit: "percent", + usedFraction: fiveHourUsed, + remainingFraction: 1 - fiveHourUsed, + }, + status: fiveHourUsed >= 1 ? "exhausted" : fiveHourUsed >= 0.9 ? "warning" : "ok", + }, + ]; + return { provider: "kimi-code", fetchedAt: now, limits, metadata: { accountId } }; +} + +describe("AuthStorage Kimi OAuth ranking", () => { + let tempDir = ""; + let store: AuthCredentialStore | null = null; + let authStorage: AuthStorage | null = null; + const usageByAccount = new Map(); + + const usageProvider: UsageProvider = { + id: "kimi-code", + async fetchUsage(params) { + const accountId = params.credential.accountId; + return accountId ? (usageByAccount.get(accountId) ?? null) : null; + }, + }; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-kimi-selection-")); + store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db")); + authStorage = new AuthStorage(store, { + usageProviderResolver: provider => (provider === "kimi-code" ? usageProvider : undefined), + }); + usageByAccount.clear(); + vi.spyOn(oauthUtils, "getOAuthApiKey").mockImplementation(async (_provider, credentials) => { + const credential = credentials["kimi-code"] as OAuthCredentials | undefined; + if (!credential) return null; + return { apiKey: credential.access, newCredentials: credential }; + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + store?.close(); + store = null; + authStorage = null; + if (tempDir) { + await removeWithRetries(tempDir); + tempDir = ""; + } + }); + + test("new sessions choose the Kimi account with more 5h and 7d headroom", async () => { + if (!authStorage) throw new Error("test setup failed"); + await authStorage.set("kimi-code", [ + { type: "oauth", ...createCredential("loaded") }, + { type: "oauth", ...createCredential("fresh") }, + ]); + usageByAccount.set("loaded", createUsageReport("loaded", 0.92, 0.8)); + usageByAccount.set("fresh", createUsageReport("fresh", 0.01, 0.02)); + + const selected = new Set(); + for (let index = 0; index < 20; index += 1) { + const apiKey = await authStorage.getApiKey("kimi-code", `kimi-ranking-${index}`); + if (apiKey) selected.add(apiKey); + } + + expect(selected).toEqual(new Set(["access-fresh"])); + }); + + test("usage-limit blocks last until the exhausted Kimi window resets", async () => { + if (!authStorage || !store?.getCredentialBlock) throw new Error("test setup failed"); + await authStorage.set("kimi-code", [ + { type: "oauth", ...createCredential("exhausted") }, + { type: "oauth", ...createCredential("sibling") }, + ]); + const exhaustedReport = createUsageReport("exhausted", 1, 0.7); + usageByAccount.set("exhausted", exhaustedReport); + usageByAccount.set("sibling", createUsageReport("sibling", 0, 0)); + const exhaustedRow = store.listAuthCredentials("kimi-code").find(row => { + const credential = row.credential; + return credential.type === "oauth" && credential.accountId === "exhausted"; + }); + if (!exhaustedRow) throw new Error("expected exhausted Kimi credential"); + + const result = await authStorage.markUsageLimitReached("kimi-code", undefined, { + credentialId: exhaustedRow.id, + }); + + expect(result.switched).toBe(true); + const resetAt = exhaustedReport.limits.find(limit => limit.window?.id === "5h")?.window?.resetsAt; + expect(store.getCredentialBlock(exhaustedRow.id, "kimi-code:oauth", "")).toBe(resetAt); + }); +}); + +describe("Kimi OAuth account identity", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("keeps the JWT user id across token refresh", async () => { + const accessToken = `header.${Buffer.from(JSON.stringify({ user_id: "kimi-user-42", sub: "kimi-user-42" })).toString("base64url")}.signature`; + vi.spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async () => + new Response( + JSON.stringify({ + access_token: accessToken, + refresh_token: "refresh-1", + expires_in: 60 * 60, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + { preconnect: fetch.preconnect }, + ), + ); + + const refreshed = await kimiOauth.refreshKimiToken("refresh-0"); + + expect(refreshed.accountId).toBe("kimi-user-42"); + }); +}); diff --git a/packages/ai/test/kimi-usage.test.ts b/packages/ai/test/kimi-usage.test.ts index 559452980..6d0a86c87 100644 --- a/packages/ai/test/kimi-usage.test.ts +++ b/packages/ai/test/kimi-usage.test.ts @@ -3,10 +3,11 @@ import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; import type { UsageFetchContext, UsageFetchParams } from "@oh-my-pi/pi-ai/usage"; import { kimiUsageProvider } from "@oh-my-pi/pi-ai/usage/kimi"; -function makeCredential(): UsageFetchParams["credential"] { +function makeCredential(accountId?: string): UsageFetchParams["credential"] { return { type: "oauth", accessToken: "kimi-test-token", + accountId, }; } @@ -102,4 +103,13 @@ describe("kimi usage provider", () => { expect(report!.limits[0]!.window?.id).toBe("7d"); expect(report!.limits[1]!.window?.id).toBe("90m"); }); + + it("attaches the credential account id used by stable usage labels", async () => { + const report = await kimiUsageProvider.fetchUsage!( + { provider: "kimi-code", credential: makeCredential("kimi-user-42"), signal: undefined }, + makeCtx({ usage: { limit: "100", used: "28", remaining: "72" } }), + ); + + expect(report?.metadata?.accountId).toBe("kimi-user-42"); + }); }); diff --git a/packages/ai/test/leaked-thinking-stream.test.ts b/packages/ai/test/leaked-thinking-stream.test.ts index bd0564d22..300381b9f 100644 --- a/packages/ai/test/leaked-thinking-stream.test.ts +++ b/packages/ai/test/leaked-thinking-stream.test.ts @@ -419,6 +419,58 @@ describe("wrapLeakedThinkingStream", () => { expect(result.content.slice(1, 3)).toEqual(serverBlocks); }); + it("preserves complete Anthropic tool-search history through the custom-endpoint projector", async () => { + const firstThinking: ThinkingContent = { + type: "thinking", + thinking: "find the deferred tool", + thinkingSignature: "sig-1", + }; + const serverBlocks: AnthropicServerToolContent[] = [ + { + type: "anthropicServerTool", + block: { + type: "server_tool_use", + id: "srvtoolu_search", + name: "tool_search_tool_regex", + input: { pattern: "read" }, + }, + }, + { + type: "anthropicServerTool", + block: { + type: "tool_search_tool_result", + tool_use_id: "srvtoolu_search", + content: { + type: "tool_search_tool_search_result", + tool_references: [{ type: "tool_reference", tool_name: "_read" }], + }, + }, + }, + ]; + const secondThinking: ThinkingContent = { + type: "thinking", + thinking: "use the discovered tool", + thinkingSignature: "sig-2", + }; + const call: ToolCall = { + type: "toolCall", + id: "toolu_read", + name: "_read", + arguments: { path: "notes.txt" }, + }; + const content: AssistantMessage["content"] = [firstThinking, ...serverBlocks, secondThinking, call]; + const terminal = msg({ content, stopReason: "toolUse" }); + + const { result } = await runWrapper(inner => { + inner.push({ type: "start", partial: msg() }); + inner.push({ type: "toolcall_start", contentIndex: 4, partial: terminal }); + inner.push({ type: "toolcall_end", contentIndex: 4, toolCall: call, partial: terminal }); + inner.push({ type: "done", reason: "toolUse", message: terminal }); + }); + + expect(result.content).toEqual(content); + }); + it("drops incomplete Anthropic web-search history instead of replaying orphan blocks", async () => { const content: AssistantMessage["content"] = [ { @@ -659,6 +711,43 @@ describe("leaked thinking healing through stream()", () => { return Object.assign(fn, { preconnect: fetch.preconnect }); } + function anthropicThinkingFetch(): FetchImpl { + const body = [ + sseFrame("message_start", { + type: "message_start", + message: { id: "msg_thinking_prefix", usage: { input_tokens: 5, output_tokens: 0 } }, + }), + sseFrame("content_block_start", { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "Summary prefix" }, + }), + sseFrame("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: " summary tail" }, + }), + sseFrame("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "sig_thinking" }, + }), + sseFrame("content_block_stop", { type: "content_block_stop", index: 0 }), + sseFrame("message_delta", { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { input_tokens: 5, output_tokens: 4 }, + }), + sseFrame("message_stop", { type: "message_stop" }), + ].join(""); + const fn = async (_input: string | URL | Request, _init?: RequestInit): Promise => + new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream", "request-id": "req_thinking_prefix" }, + }); + return Object.assign(fn, { preconnect: fetch.preconnect }); + } + function anthropicModel(overrides: Partial> = {}): Model<"anthropic-messages"> { return buildModel({ id: "claude-sonnet-4-5", @@ -711,6 +800,25 @@ describe("leaked thinking healing through stream()", () => { expect(texts(result).join("").trim()).toBe("Final answer."); }); + it("preserves thinking bytes from content_block_start through a non-official endpoint", async () => { + const result = await stream( + anthropicModel({ provider: "zai", baseUrl: "https://api.z.ai/api/anthropic" }), + context, + { + apiKey: "test", + fetch: anthropicThinkingFetch(), + }, + ).result(); + + expect(thinks(result)).toEqual([ + { + type: "thinking", + thinking: "Summary prefix summary tail", + thinkingSignature: "sig_thinking", + }, + ]); + }); + it("replays native web-search history on a custom Anthropic continuation", async () => { const searchResult = { type: "web_search_tool_result", diff --git a/packages/ai/test/oneshot-retry.test.ts b/packages/ai/test/oneshot-retry.test.ts new file mode 100644 index 000000000..97bb9e788 --- /dev/null +++ b/packages/ai/test/oneshot-retry.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "bun:test"; +import { retryTransientCompletion } from "@oh-my-pi/pi-ai/oneshot-retry"; +import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai/types"; + +/** + * Defends the contract every oneshot LLM call site now depends on: + * `completeSimple` reports a transient provider failure by RESOLVING with + * `stopReason: "error"` rather than throwing, so a retry layer that only + * catches exceptions silently never fires. Before this helper, an Anthropic + * `overloaded_error` / 429 / 529 on a summary, title, handoff or image + * description failed on the first blip — or was swallowed into `null`, making a + * transient overload indistinguishable from a legitimate empty result. + * + * These tests pin the four properties the call sites rely on: transient + * error-stops are re-issued, non-transient ones are not, the final failure is + * handed back unchanged (so existing `null`/throw fallbacks still work), and a + * caller abort wins immediately. + */ + +const emptyUsage = (): Usage => + ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }) as unknown as Usage; + +function message(overrides: Partial = {}): AssistantMessage { + return { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: emptyUsage(), + stopReason: "stop", + timestamp: 0, + ...overrides, + } as AssistantMessage; +} + +const overloaded = (): AssistantMessage => + message({ + stopReason: "error", + errorStatus: 529, + errorMessage: "Anthropic stream error (overloaded_error): Overloaded", + }); + +const rateLimited = (): AssistantMessage => + message({ stopReason: "error", errorStatus: 429, errorMessage: "rate_limit_error: too many requests" }); + +// Keep the suite fast: the helper's real backoff floor is 500ms. +const fast = { baseDelayMs: 1, maxAttempts: 3 } as const; + +describe("retryTransientCompletion", () => { + it("re-issues an Anthropic 529 error-stop and returns the eventual success", async () => { + const results = [overloaded(), overloaded(), message({ stopReason: "stop" })]; + let calls = 0; + const final = await retryTransientCompletion(() => { + calls += 1; + return Promise.resolve(results.shift()!); + }, fast); + + expect(calls).toBe(3); + expect(final.stopReason).toBe("stop"); + }); + + it("re-issues a 429 rate-limit error-stop", async () => { + let calls = 0; + const final = await retryTransientCompletion(() => { + calls += 1; + return Promise.resolve(calls === 1 ? rateLimited() : message()); + }, fast); + + expect(calls).toBe(2); + expect(final.stopReason).toBe("stop"); + }); + + it("re-issues a status-only 503 error-stop", async () => { + let calls = 0; + const final = await retryTransientCompletion(() => { + calls += 1; + return Promise.resolve( + calls === 1 + ? message({ stopReason: "error", errorStatus: 503, errorMessage: "request failed" }) + : message(), + ); + }, fast); + + expect(calls).toBe(2); + expect(final.stopReason).toBe("stop"); + }); + + it("returns the failing message unchanged once attempts are exhausted, so caller fallbacks still apply", async () => { + let calls = 0; + const final = await retryTransientCompletion(() => { + calls += 1; + return Promise.resolve(overloaded()); + }, fast); + + expect(calls).toBe(3); + expect(final.stopReason).toBe("error"); + expect(final.errorMessage).toContain("overloaded_error"); + }); + + it("does not retry a non-transient provider error", async () => { + let calls = 0; + const final = await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve( + message({ + stopReason: "error", + errorStatus: 400, + errorMessage: "invalid_request_error: messages: at least one message is required", + }), + ); + }, + { ...fast, maxAttempts: 5 }, + ); + + expect(calls).toBe(1); + expect(final.stopReason).toBe("error"); + }); + + it("does not retry a deterministic llama.cpp tool-call parse failure reported as 500", async () => { + let calls = 0; + const final = await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve( + message({ + stopReason: "error", + errorStatus: 500, + errorMessage: "failed to parse tool call arguments as JSON", + }), + ); + }, + { ...fast, maxAttempts: 5 }, + ); + + expect(calls).toBe(1); + expect(final.stopReason).toBe("error"); + }); + + it("retries a thrown transient error and rethrows the last one when exhausted", async () => { + let calls = 0; + const attempt = retryTransientCompletion(() => { + calls += 1; + const error = new Error("529 overloaded_error: Overloaded") as Error & { status?: number }; + error.status = 529; + throw error; + }, fast); + + await expect(attempt).rejects.toThrow(/overloaded_error/); + expect(calls).toBe(3); + }); + + it("retries a thrown status-only 503 error", async () => { + let calls = 0; + const final = await retryTransientCompletion(() => { + calls += 1; + if (calls === 1) { + const error = new Error("request failed") as Error & { status: number }; + error.status = 503; + throw error; + } + return Promise.resolve(message()); + }, fast); + + expect(calls).toBe(2); + expect(final.stopReason).toBe("stop"); + }); + + it("does not retry a thrown non-transient error", async () => { + let calls = 0; + const attempt = retryTransientCompletion(() => { + calls += 1; + throw new Error("invalid_request_error: bad tool schema"); + }, fast); + + await expect(attempt).rejects.toThrow(/invalid_request_error/); + expect(calls).toBe(1); + }); + + it("stops immediately when the caller aborts", async () => { + const controller = new AbortController(); + let calls = 0; + const final = await retryTransientCompletion( + () => { + calls += 1; + controller.abort(); + return Promise.resolve(overloaded()); + }, + { ...fast, signal: controller.signal }, + ); + + expect(calls).toBe(1); + expect(final.stopReason).toBe("error"); + }); + + it("rejects with the abort reason when the caller cancels during backoff", async () => { + // The cancel lands while we are waiting, not while an attempt is in flight: + // it must stay a cancellation rather than being reported as the provider + // failure we happened to be sleeping on. + const controller = new AbortController(); + const reason = new Error("user pressed escape"); + let calls = 0; + const attempt = retryTransientCompletion( + () => { + calls += 1; + setTimeout(() => controller.abort(reason), 5); + return Promise.resolve(overloaded()); + }, + { maxAttempts: 3, baseDelayMs: 200, signal: controller.signal }, + ); + + await expect(attempt).rejects.toThrow("user pressed escape"); + expect(calls).toBe(1); + }); + + it("reports each retry through onRetry so callers can log the wait", async () => { + const seen: number[] = []; + let calls = 0; + await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve(calls === 1 ? overloaded() : message()); + }, + { ...fast, onRetry: info => seen.push(info.attempt) }, + ); + + expect(seen).toEqual([1]); + }); + + it("surfaces the failure instead of parking when the provider asks for longer than maxDelayMs", async () => { + let calls = 0; + const final = await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve( + message({ + stopReason: "error", + errorStatus: 429, + errorMessage: "rate_limit_error: please retry in 600s", + }), + ); + }, + { ...fast, maxDelayMs: 1_000 }, + ); + + expect(calls).toBe(1); + expect(final.stopReason).toBe("error"); + }); + + it("honors a retry-after-ms response header over the backoff floor", async () => { + // The header is the only place a real Anthropic 429 carries its wait: the + // resolved AssistantMessage has no headers, so a helper that reads only the + // error text would silently fall back to plain backoff. + let calls = 0; + let observedDelay = -1; + await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve(calls === 1 ? rateLimited() : message()); + }, + { + maxAttempts: 2, + baseDelayMs: 1, + getResponseHeaders: () => ({ "retry-after-ms": "120" }), + onRetry: info => { + observedDelay = info.delayMs; + }, + }, + ); + + expect(calls).toBe(2); + expect(observedDelay).toBe(120); + }); + + it("honors the canonical retry-after-ms error-message suffix", async () => { + let calls = 0; + let observedDelay = -1; + await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve( + calls === 1 + ? message({ + stopReason: "error", + errorStatus: 429, + errorMessage: "rate_limit_error: too many requests retry-after-ms=5", + }) + : message(), + ); + }, + { + maxAttempts: 2, + baseDelayMs: 1, + onRetry: info => { + observedDelay = info.delayMs; + }, + }, + ); + + expect(calls).toBe(2); + expect(observedDelay).toBe(5); + }); + + it("surfaces a canonical retry-after-ms suffix above maxDelayMs", async () => { + let calls = 0; + const final = await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve( + message({ + stopReason: "error", + errorStatus: 429, + errorMessage: "rate_limit_error: too many requests retry-after-ms=12000", + }), + ); + }, + { ...fast, maxDelayMs: 1_000 }, + ); + + expect(calls).toBe(1); + expect(final.stopReason).toBe("error"); + }); + + it("surfaces the failure when a retry-after header exceeds maxDelayMs", async () => { + let calls = 0; + const final = await retryTransientCompletion( + () => { + calls += 1; + return Promise.resolve(rateLimited()); + }, + { + maxAttempts: 3, + baseDelayMs: 1, + maxDelayMs: 1_000, + getResponseHeaders: () => ({ "retry-after": "300" }), + }, + ); + + expect(calls).toBe(1); + expect(final.stopReason).toBe("error"); + }); + + it("recovers retry-after from a thrown provider error's own headers", async () => { + let calls = 0; + let observedDelay = -1; + const attempt = retryTransientCompletion( + () => { + calls += 1; + const error = new Error("529 overloaded_error: Overloaded") as Error & { + status?: number; + headers?: Record; + }; + error.status = 529; + error.headers = { "retry-after-ms": "90" }; + throw error; + }, + { + maxAttempts: 2, + baseDelayMs: 1, + onRetry: info => { + observedDelay = info.delayMs; + }, + }, + ); + + await expect(attempt).rejects.toThrow(/overloaded_error/); + expect(calls).toBe(2); + expect(observedDelay).toBe(90); + }); +}); diff --git a/packages/ai/test/openai-completions-cache-affinity.test.ts b/packages/ai/test/openai-completions-cache-affinity.test.ts index 2506e031c..c081b4380 100644 --- a/packages/ai/test/openai-completions-cache-affinity.test.ts +++ b/packages/ai/test/openai-completions-cache-affinity.test.ts @@ -5,9 +5,6 @@ import type { AssistantMessage, Context, FetchImpl, Model, SimpleStreamOptions, import { buildOpenAICompat } from "@oh-my-pi/pi-catalog/compat/openai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -const model = getBundledModel<"openai-completions">("xai", "grok-code-fast-1"); -if (!model) throw new Error("Expected bundled xAI Grok model"); -if (model.api !== "openai-completions") throw new Error(`Expected Chat Completions model, received ${model.api}`); const context: Context = { messages: [{ role: "user", content: "hello", timestamp: 0 }] }; const openAI56ResponsesModel = getBundledModel<"openai-responses">("openai", "gpt-5.6"); @@ -44,7 +41,7 @@ function chatCompletionsSse(): Response { id: "chatcmpl-affinity", object: "chat.completion.chunk", created: 0, - model: model.id, + model: openAI56CompletionsModel.id, choices: [{ index: 0, delta, finish_reason: finishReason }], }); @@ -56,7 +53,7 @@ function chatCompletionsSse(): Response { async function captureRequest( options: OpenAICompletionsOptions, - requestModel: Model<"openai-completions"> = model, + requestModel: Model<"openai-completions"> = openAI56CompletionsModel, requestContext: Context = context, ): Promise<{ headers: Headers; body: Record }> { let requestHeaders: Headers | undefined; @@ -83,7 +80,7 @@ async function captureRequest( async function captureSimpleRequest( options: SimpleStreamOptions, - requestModel: Model<"openai-completions"> = model, + requestModel: Model<"openai-completions"> = openAI56CompletionsModel, requestContext: Context = context, ): Promise<{ headers: Headers; body: Record }> { let requestHeaders: Headers | undefined; @@ -104,51 +101,6 @@ async function captureSimpleRequest( return { headers: requestHeaders, body }; } -describe("openai-completions xAI cache affinity", () => { - const cases: Array<{ - name: string; - options: OpenAICompletionsOptions; - expectedHeader: string | null; - }> = [ - { - name: "uses sessionId when no prompt cache key is provided", - options: { sessionId: "session-fallback" }, - expectedHeader: "session-fallback", - }, - { - name: "keeps the prompt cache key stable across a distinct side-channel session", - options: { promptCacheKey: "stable-cache-key", sessionId: "side-channel-session" }, - expectedHeader: "stable-cache-key", - }, - { - name: "omits automatic affinity when caching is disabled", - options: { - promptCacheKey: "disabled-cache-key", - sessionId: "disabled-session", - cacheRetention: "none", - }, - expectedHeader: null, - }, - { - name: "preserves a caller-provided mixed-case affinity header", - options: { - promptCacheKey: "automatic-cache-key", - sessionId: "automatic-session", - headers: { "X-Grok-Conv-Id": "caller-affinity" }, - }, - expectedHeader: "caller-affinity", - }, - ]; - - for (const { name, options, expectedHeader } of cases) { - it(name, async () => { - const { headers } = await captureRequest(options); - - expect(headers.get("x-grok-conv-id")).toBe(expectedHeader); - }); - } -}); - describe("OpenAI Chat Completions explicit prompt cache policy", () => { const historicalContext: Context = { messages: [ diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index d8bebd5d1..12b679793 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -210,6 +210,7 @@ describe("openai-completions compatibility", () => { toolStrictMode: "none", supportsReasoningParams: true, supportsSamplingParams: true, + supportsPenaltyAndStopParams: true, alwaysSendMaxTokens: false, isOpenRouterHost: false, isVercelGatewayHost: false, @@ -628,6 +629,79 @@ describe("openai-completions compatibility", () => { expect(result.usage.totalTokens).toBe(15); }); + it("preserves opaque tool-call IDs when replaying a custom Chat Completions turn", async () => { + const model: Model<"openai-completions"> = buildModel({ + id: "gateway-model", + name: "Gateway Model", + api: "openai-completions", + provider: "custom-gateway", + baseUrl: "https://gateway.example/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + } satisfies ModelSpec<"openai-completions">); + const toolCallId = "call_abc||gateway_state||opaque"; + const assistant = await streamOpenAICompletions(model, baseContext(), { + apiKey: "test-key", + fetch: createMockFetch([ + { + id: "chatcmpl-opaque-tool-id", + object: "chat.completion.chunk", + created: 0, + model: model.id, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: toolCallId, + type: "function", + function: { name: "read", arguments: '{"path":"README.md"}' }, + }, + ], + }, + }, + ], + }, + { + id: "chatcmpl-opaque-tool-id", + object: "chat.completion.chunk", + created: 0, + model: model.id, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }, + "[DONE]", + ]), + }).result(); + const streamedToolCall = assistant.content.find(content => content.type === "toolCall"); + expect(streamedToolCall?.id).toBe(toolCallId); + + const payload = await captureOpenAICompletionsPayload(model, { + messages: [ + { role: "user", content: "Read README", timestamp: 1 }, + assistant, + { + role: "toolResult", + toolCallId, + toolName: "read", + content: [{ type: "text", text: "done" }], + isError: false, + timestamp: 2, + }, + ], + }); + const replayMessages = getPayloadMessages(payload); + const assistantPayload = replayMessages.find(message => message.role === "assistant"); + const toolCalls = assistantPayload?.tool_calls; + if (!Array.isArray(toolCalls)) throw new Error("assistant tool_calls missing"); + expect(toObject(toolCalls[0])?.id).toBe(toolCallId); + expect(replayMessages.find(message => message.role === "tool")?.tool_call_id).toBe(toolCallId); + }); + it("keeps unindexed batched tool-call arguments isolated", async () => { const model: Model<"openai-completions"> = buildModel({ ...gpt4oMiniSpec, diff --git a/packages/ai/test/openai-completions-error-finish-reason.test.ts b/packages/ai/test/openai-completions-error-finish-reason.test.ts index 0603f2d18..ec4ac71e1 100644 --- a/packages/ai/test/openai-completions-error-finish-reason.test.ts +++ b/packages/ai/test/openai-completions-error-finish-reason.test.ts @@ -107,3 +107,93 @@ describe("finish_reason: error", () => { expect(result.errorMessage).toMatch(RETRYABLE_PATTERN); }, 10_000); }); + +describe("finish_reason: insufficient_system_resource", () => { + // DeepSeek interrupts the generation mid-stream when its inference system + // runs out of resources; the terminal chunk carries + // `finish_reason: "insufficient_system_resource"`. It must surface as a + // retryable provider error — never as a clean `stop`. + it("maps to a retryable error message", async () => { + const fetchMock = createSseFetch([ + completionChunk({ choices: [{ index: 0, delta: { role: "assistant", content: "Hel" } }] }), + completionChunk({ choices: [{ index: 0, delta: {}, finish_reason: "insufficient_system_resource" }] }), + "[DONE]", + ]); + + const result = await streamOpenAICompletions(completionsModel, baseContext(), { + apiKey: "test-key", + fetch: fetchMock, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(RETRYABLE_PATTERN); + }, 10_000); +}); + +describe("premature stream closure", () => { + // The connection dies mid-generation without any `finish_reason` chunk + // (DeepSeek insufficient-system-resource interruption, flaky gateway). + // Before the guard, the partial message finalized as a clean `stop` and + // the agent loop treated the truncated turn as complete — the silent + // mid-sentence halt. Now it must surface as an error turn. + it("fails the turn instead of silently stopping", async () => { + const fetchMock = createSseFetch([ + completionChunk({ choices: [{ index: 0, delta: { role: "assistant", content: "Hel" } }] }), + completionChunk({ choices: [{ index: 0, delta: { content: "lo" } }] }), + ]); + + const eventTypes: string[] = []; + let errorMessage: string | undefined; + for await (const event of streamOpenAICompletions(completionsModel, baseContext(), { + apiKey: "test-key", + fetch: fetchMock, + })) { + eventTypes.push(event.type); + if (event.type === "error") errorMessage = event.error.errorMessage; + } + + expect(eventTypes).toEqual(["start", "text_start", "text_delta", "text_delta", "text_end", "error"]); + expect(errorMessage).toContain("finish_reason"); + }, 10_000); + + it("still retries a genuinely empty close via the empty-completion path", async () => { + // Zero content + no finish_reason is the flaky-gateway empty completion: + // it stays a clean `stop` so withEmptyCompletionRetry can re-sample + // instead of failing outright. + let attempts = 0; + async function fetchMock(_input: string | URL | Request, _init?: RequestInit): Promise { + attempts++; + const events = + attempts === 1 + ? [] + : [ + completionChunk({ choices: [{ index: 0, delta: { role: "assistant", content: "Hi" } }] }), + completionChunk({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }), + "[DONE]", + ]; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const event of events) { + const data = typeof event === "string" ? event : JSON.stringify(event); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + + const result = await streamOpenAICompletions(completionsModel, baseContext(), { + apiKey: "test-key", + fetch: fetchMock as typeof fetch, + }).result(); + + expect(attempts).toBeGreaterThan(1); + expect(result.stopReason).toBe("stop"); + expect(result.content).toEqual([{ type: "text", text: "Hi" }]); + }, 10_000); +}); 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 1219f8e30..18ef096d8 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -51,6 +51,7 @@ const compat: ResolvedOpenAICompat = { toolStrictMode: "none", supportsReasoningParams: true, supportsSamplingParams: true, + supportsPenaltyAndStopParams: true, alwaysSendMaxTokens: false, isOpenRouterHost: false, isVercelGatewayHost: false, @@ -227,7 +228,7 @@ describe("openai-completions convertMessages", () => { const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "toolCall", id: emptyNormalizingId, name: "read", arguments: { path: "README.md" } }], - api: model.api, + api: "openai-responses", provider: model.provider, model: model.id, usage: emptyUsage, diff --git a/packages/ai/test/openai-completions-xai-root-union.test.ts b/packages/ai/test/openai-completions-xai-root-union.test.ts new file mode 100644 index 000000000..fc4ee490b --- /dev/null +++ b/packages/ai/test/openai-completions-xai-root-union.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context, Model, ModelSpec, Tool, ToolChoice } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +interface ChatCompletionsPayload { + tool_choice?: unknown; + tools?: Array<{ type?: string; function?: { name?: string; parameters?: { anyOf?: unknown } } }>; +} + +const coverageTool: Tool = { + name: "mcp__codebase_memory_check_index_coverage", + description: "coverage", + parameters: { + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + } as unknown as Tool["parameters"], +}; + +const leftoverTool: Tool = { + name: "mcp__leftover_union", + description: "union", + parameters: { + type: "object", + properties: { kind: { type: "string" } }, + anyOf: [ + { required: ["kind"], minProperties: 1 }, + { required: ["kind"], minProperties: 2 }, + ], + } as unknown as Tool["parameters"], +}; + +const goodTool: Tool = { + name: "read_file", + description: "read a file", + parameters: type({ path: type("string") }), +}; + +function makeModel(provider: "openai" | "xai"): Model<"openai-completions"> { + return buildModel({ + id: provider === "xai" ? "grok-4" : "gpt-4o-mini", + name: provider === "xai" ? "Grok 4" : "GPT-4o Mini", + api: "openai-completions", + provider, + baseUrl: provider === "xai" ? "https://api.x.ai/v1" : "https://api.openai.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + } as ModelSpec<"openai-completions">); +} + +function abortedSignal(): AbortSignal { + const controller = new AbortController(); + controller.abort(); + return controller.signal; +} + +function capturePayload( + provider: "openai" | "xai", + tools: Tool[], + toolChoice?: ToolChoice, +): Promise { + const { promise, resolve } = Promise.withResolvers(); + const context: Context = { + messages: [{ role: "user", content: "check coverage", timestamp: 0 }], + tools, + }; + streamOpenAICompletions(makeModel(provider), context, { + apiKey: "test-key", + toolChoice, + signal: abortedSignal(), + onPayload: payload => resolve(payload as ChatCompletionsPayload), + }); + return promise; +} + +function toolNames(payload: ChatCompletionsPayload): Array { + return payload.tools?.map(tool => tool.function?.name) ?? []; +} + +describe("openai-completions xAI leftover-union quarantine", () => { + it("keeps an exclusive-required MCP tool after xAI flatten on paid xAI", async () => { + const payload = await capturePayload("xai", [coverageTool, goodTool]); + expect(toolNames(payload)).toEqual(["mcp__codebase_memory_check_index_coverage", "read_file"]); + expect(payload.tools?.[0]?.function?.parameters?.anyOf).toBeUndefined(); + }); + + it("preserves an exclusive-required MCP tool on OpenAI Completions", async () => { + const payload = await capturePayload("openai", [coverageTool, goodTool]); + expect(toolNames(payload)).toEqual(["mcp__codebase_memory_check_index_coverage", "read_file"]); + expect(payload.tools?.[0]?.function?.parameters?.anyOf).toHaveLength(2); + }); + + it("keeps a leftover object-root union on OpenAI Completions", async () => { + const payload = await capturePayload("openai", [leftoverTool, goodTool]); + expect(toolNames(payload)).toEqual(["mcp__leftover_union", "read_file"]); + expect(payload.tools?.[0]?.function?.parameters?.anyOf).toHaveLength(2); + }); + + it("quarantines a leftover object-root union on paid xAI only", async () => { + const payload = await capturePayload("xai", [leftoverTool, goodTool]); + expect(toolNames(payload)).toEqual(["read_file"]); + }); + + it("drops a forced tool_choice when the leftover-union tool was quarantined", async () => { + const payload = await capturePayload("xai", [leftoverTool, goodTool], { + type: "tool", + name: "mcp__leftover_union", + }); + expect(toolNames(payload)).toEqual(["read_file"]); + expect(payload.tool_choice).toBeUndefined(); + }); +}); diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts index fd10d44cf..4eb32abca 100644 --- a/packages/ai/test/openai-responses-cache-affinity.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -57,6 +57,20 @@ const xaiOAuthResponsesModel: Model<"openai-responses"> = { reasoning: true, }), }; +const xaiApiKeyResponsesModel: Model<"openai-responses"> = { + ...model, + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + compat: buildOpenAIResponsesCompat({ + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + }), +}; const openAI56ResponsesModel: Model<"openai-responses"> = { ...model, @@ -675,6 +689,16 @@ describe("openai-responses cache affinity", () => { } }); + it("sets x-grok-conv-id cache affinity for paid xai Responses requests", async () => { + const captured = await captureDispatchedOpenAIResponseHeaders( + { sessionId: "session-fallback" }, + xaiApiKeyResponsesModel, + ); + + expect(getHeader(captured.headers, "x-grok-conv-id")).toBe("session-fallback"); + expect(captured.body?.prompt_cache_key).toBe("session-fallback"); + }); + it("sets OpenRouter Responses session_id from sessionId in the body", async () => { const captured = await captureOpenAIResponseHeaders( { sessionId: "workflow-123", promptCacheKey: "cache-key-123" }, diff --git a/packages/ai/test/openai-responses-tool-quarantine.test.ts b/packages/ai/test/openai-responses-tool-quarantine.test.ts index cb661b9b8..f2ada2a65 100644 --- a/packages/ai/test/openai-responses-tool-quarantine.test.ts +++ b/packages/ai/test/openai-responses-tool-quarantine.test.ts @@ -5,13 +5,13 @@ import type { Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { findStrictToolSchemaViolation } from "@oh-my-pi/pi-ai/utils/schema"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -function makeModel(): Model<"openai-responses"> { +function makeModel(provider: "openai" | "xai-oauth" = "openai"): Model<"openai-responses"> { return buildModel({ - id: "gpt-5", - name: "GPT-5", + id: provider === "xai-oauth" ? "grok-4" : "gpt-5", + name: provider === "xai-oauth" ? "Grok 4" : "GPT-5", api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", + provider, + baseUrl: provider === "xai-oauth" ? "https://api.x.ai/v1" : "https://api.openai.com/v1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -20,6 +20,15 @@ function makeModel(): Model<"openai-responses"> { } as ModelSpec<"openai-responses">); } +const leftoverRootUnion = { + type: "object", + properties: { kind: { type: "string" } }, + anyOf: [ + { required: ["kind"], minProperties: 1 }, + { required: ["kind"], minProperties: 2 }, + ], +} as const; + describe("findStrictToolSchemaViolation (#2652)", () => { test("flags a non-null enum on a null-typed node (nullable-enum shape)", () => { expect(findStrictToolSchemaViolation({ enum: ["A", "B"], type: "null" })).toBe("#/enum"); @@ -54,6 +63,26 @@ describe("findStrictToolSchemaViolation (#2652)", () => { // enum without a declared type cannot contradict anything. expect(findStrictToolSchemaViolation({ enum: ["x"] })).toBeNull(); }); + + test("flags a leftover xAI root anyOf only when the xAI option is on", () => { + expect(findStrictToolSchemaViolation(leftoverRootUnion)).toBeNull(); + expect(findStrictToolSchemaViolation(leftoverRootUnion, "#", { rejectXaiRootObjectUnion: true })).toBe("#/anyOf"); + }); + + test("accepts a root anyOf of typed object branches even for xAI", () => { + expect( + findStrictToolSchemaViolation( + { + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }, + "#", + { rejectXaiRootObjectUnion: true }, + ), + ).toBeNull(); + }); }); const badTool: Tool = { @@ -66,6 +95,20 @@ const badTool: Tool = { additionalProperties: false, } as unknown as Tool["parameters"], }; +const coverageTool: Tool = { + name: "mcp__codebase_memory_check_index_coverage", + description: "coverage", + parameters: { + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + } as unknown as Tool["parameters"], +}; const goodTool: Tool = { name: "read_file", description: "read a file", @@ -91,6 +134,61 @@ describe("convertTools quarantine (#2652)", () => { expect(convertTools([goodTool], true, makeModel())).toHaveLength(1); }); + test("flattens an exclusive-required MCP tool on xAI Responses", () => { + const out = convertTools([coverageTool, goodTool], true, makeModel("xai-oauth")) as Array<{ + name: string; + parameters: { anyOf?: unknown }; + }>; + expect(out.map(t => t.name)).toEqual(["mcp__codebase_memory_check_index_coverage", "read_file"]); + expect(out[0]?.parameters.anyOf).toBeUndefined(); + }); + + test("preserves an exclusive-required MCP tool on OpenAI Responses", () => { + const out = convertTools([coverageTool, goodTool], true, makeModel()) as Array<{ + name: string; + parameters: { anyOf?: unknown }; + }>; + expect(out.map(t => t.name)).toEqual(["mcp__codebase_memory_check_index_coverage", "read_file"]); + expect(out[0]?.parameters.anyOf).toHaveLength(2); + }); + + test("keeps a leftover object-root union on OpenAI Responses", () => { + const leftoverTool: Tool = { + name: "mcp__leftover_union", + description: "union", + parameters: { + type: "object", + properties: { kind: { type: "string" } }, + anyOf: [ + { required: ["kind"], minProperties: 1 }, + { required: ["kind"], minProperties: 2 }, + ], + } as unknown as Tool["parameters"], + }; + const out = convertTools([leftoverTool, goodTool], true, makeModel()) as Array<{ + name: string; + parameters: { anyOf?: unknown }; + }>; + expect(out.map(t => t.name)).toEqual(["mcp__leftover_union", "read_file"]); + expect(out[0]?.parameters.anyOf).toHaveLength(2); + }); + + test("quarantines a leftover object-root union on xAI Responses only", () => { + const leftoverTool: Tool = { + name: "mcp__leftover_union", + description: "union", + parameters: { + type: "object", + properties: { kind: { type: "string" } }, + anyOf: [ + { required: ["kind"], minProperties: 1 }, + { required: ["kind"], minProperties: 2 }, + ], + } as unknown as Tool["parameters"], + }; + const out = convertTools([leftoverTool, goodTool], true, makeModel("xai-oauth")) as Array<{ name: string }>; + expect(out.map(t => t.name)).toEqual(["read_file"]); + }); test("reports the hidden tool name and the offending schema path", () => { const dropped: Array<{ name: string; path: string }> = []; convertTools([badTool], true, makeModel(), (name, path) => dropped.push({ name, path })); diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 5849c576d..34122a4eb 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -154,6 +154,38 @@ describe("parseRateLimitReason", () => { expect(parseRateLimitReason("API 使用频率已达上限")).toBe("UNKNOWN"); }); + it("keeps DashScope/Bailian TPM throttle in the transient lane", () => { + // Bailian reports its per-minute token throttle (429 + // Throttling.AllocationQuota) with OpenAI-compatible billing wording, + // but links the error-code doc's #token-limit anchor, which documents + // the error as a transient TPM/TPS cap (clears within the minute + // window). Must retry on the same credential with a short backoff — + // previously classified QUOTA_EXHAUSTED, blocking the credential for + // 30 minutes and stalling the session. + const throttle = + "429 You exceeded your current quota, please check your plan and billing details. For details, see: https://help.aliyun.com/zh/model-studio/error-code#token-limit\nYou exceeded your current quota, please check your plan and billing details. For details, see: https://help.aliyun.com/zh/model-studio/error-code#token-limit (type=insufficient_quota param=insufficient_quota)"; + expect(parseRateLimitReason(throttle)).toBe("RATE_LIMIT_EXCEEDED"); + expect(isUsageLimit(throttle)).toBe(false); + expect(isUsageLimit(Object.assign(new Error(throttle), { status: 429 }))).toBe(false); + expect(isUsageLimit(new ProviderHttpError(throttle, 429, { code: "insufficient_quota" }))).toBe(false); + expect(isUsageLimitOutcome(429, throttle)).toBe(false); + + // The identical wording WITHOUT the doc anchor is OpenAI's real + // account-quota error and stays quota-exhausted. + const openaiQuota = + "429 You exceeded your current quota, please check your plan and billing details. For details, see: https://platform.openai.com/account/usage (type=insufficient_quota)"; + expect(parseRateLimitReason(openaiQuota)).toBe("QUOTA_EXHAUSTED"); + expect(isUsageLimitOutcome(429, openaiQuota)).toBe(true); + + // The same DashScope doc anchor also covers permanent free-quota + // exhaustion. The anchor alone must not turn that into a retry loop. + const freeQuota = + "429 Free allocated quota exceeded. For details, see: https://help.aliyun.com/zh/model-studio/error-code#token-limit (type=insufficient_quota)"; + expect(parseRateLimitReason(freeQuota)).toBe("QUOTA_EXHAUSTED"); + expect(isUsageLimit(new ProviderHttpError(freeQuota, 429, { code: "insufficient_quota" }))).toBe(true); + expect(isUsageLimitOutcome(429, freeQuota)).toBe(true); + }); + it("classifies Codex usage limit error as QUOTA_EXHAUSTED", () => { expect( parseRateLimitReason("Codex error event: The usage limit has been reached (code=usage_limit_reached)"), diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index 4e5f87b03..cdbef5c33 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -606,6 +606,63 @@ describe("sanitizeSchemaForOpenAIResponses", () => { expect(properties.self).toBe(sanitized as unknown as object); expect((sanitized as { type: unknown }).type).toBe("object"); }); + + it("preserves exclusive-required anyOf for provider-specific handling", () => { + const schema = { + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }; + + expect(sanitizeSchemaForOpenAIResponses(schema)).toEqual({ + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }); + }); + + it("does not flatten nested exclusive-required anyOf (xAI only rejects the tool root)", () => { + const schema = { + type: "object", + properties: { + outputSchema: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }, + }, + required: ["outputSchema"], + }; + const sanitized = sanitizeSchemaForOpenAIResponses(schema); + expect(sanitized.anyOf).toBeUndefined(); + const outputSchema = (sanitized.properties as Record).outputSchema as Record; + expect(outputSchema.anyOf).toEqual([{ required: ["paths"] }, { required: ["scopes"] }]); + }); + + it("does not flatten a root union that constrains existing properties", () => { + const schema = { + type: "object", + properties: { kind: { type: "string" } }, + anyOf: [{ properties: { kind: { const: "a" } } }, { properties: { kind: { const: "b" } } }], + }; + expect(sanitizeSchemaForOpenAIResponses(schema).anyOf).toEqual([ + { properties: { kind: { const: "a" } } }, + { properties: { kind: { const: "b" } } }, + ]); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index ef7078871..db2db81ba 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -87,6 +87,63 @@ describe("toolWireSchema — raw JSON Schema normalization", () => { }); }); + it("preserves exclusive-required anyOf for provider-specific handling", () => { + const wire = toolWireSchema( + jsonTool({ + type: "object", + properties: { + project: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + required: ["project"], + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }), + ); + expect(wire.anyOf).toEqual([{ required: ["paths"] }, { required: ["scopes"] }]); + expect(wire.type).toBe("object"); + expect(wire.required).toEqual(["project"]); + }); + + it("does not flatten nested exclusive-required anyOf (only the tool root 400s xAI)", () => { + const wire = toolWireSchema( + jsonTool({ + type: "object", + properties: { + outputSchema: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + scopes: { type: "array", items: { type: "string" } }, + }, + anyOf: [{ required: ["paths"] }, { required: ["scopes"] }], + }, + }, + required: ["outputSchema"], + }), + ); + expect(wire.anyOf).toBeUndefined(); + const outputSchema = (wire.properties as Record).outputSchema as Record; + expect(outputSchema.anyOf).toEqual([{ required: ["paths"] }, { required: ["scopes"] }]); + }); + + it("does not flatten a root union that constrains existing properties", () => { + const wire = toolWireSchema( + jsonTool({ + type: "object", + properties: { kind: { type: "string" } }, + anyOf: [{ properties: { kind: { const: "a" } } }, { properties: { kind: { const: "b" } } }], + }), + ); + expect(wire.anyOf).toEqual([{ properties: { kind: { const: "a" } } }, { properties: { kind: { const: "b" } } }]); + const properties = wire.properties; + expect( + properties && typeof properties === "object" && "kind" in properties ? properties.kind : undefined, + ).toEqual({ + type: "string", + }); + }); + it("preserves raw JSON Schema required defaults and safe-integer bounds", () => { const wire = toolWireSchema( jsonTool({ diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 0add12576..8017e13bd 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1000,7 +1000,7 @@ describe("Generate E2E Tests", () => { ); }); - describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider (grok-code-fast-1 via OpenAI Completions)", () => { + describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider (grok-code-fast-1 via OpenAI Responses)", () => { const llm = getBundledModel("xai", "grok-code-fast-1"); it( diff --git a/packages/ai/test/thinking-loop.test.ts b/packages/ai/test/thinking-loop.test.ts index 5ff74df21..477cccab2 100644 --- a/packages/ai/test/thinking-loop.test.ts +++ b/packages/ai/test/thinking-loop.test.ts @@ -1,6 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test"; import { scheduler } from "node:timers/promises"; -import { clearCustomApis } from "@oh-my-pi/pi-ai/api-registry"; +import { clearCustomApis, registerCustomApi } from "@oh-my-pi/pi-ai/api-registry"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { createMockModel, type MockContent, registerMockApi } from "@oh-my-pi/pi-ai/providers/mock"; import { complete, completeSimple, stream, streamSimple } from "@oh-my-pi/pi-ai/stream"; @@ -42,6 +42,11 @@ function nearDuplicateLoop(paragraphs: number): string { return out.join("\n\n\n"); } +/** The exact 311-character cycle observed from Kiro gpt-5-6-sol on 2026-08-14. + * The persisted assistant message repeated this cycle 58 times before abort. */ +const OBSERVED_KIRO_CYCLE = + "% shipped. 100% delivered. 100% verified. 100% validated. 100% approved. 100% accepted. 100% merged. 100% deployed. 100% live. 100% operational. 100% successful. 100% excellent. 100% perfect. 100% final. 100% absolute. 100% total. 100% whole. 100% full. 100% entire. 100% complete. 100% done. 100% finished. 100"; + /** Genuinely distinct reasoning paragraphs — must never trip the detector. */ function distinctReasoning(): string { return [ @@ -335,7 +340,7 @@ describe("thinking-loop guard (stream wrapper)", () => { }); } - test("emits no observable thinking/text content before the error terminal", async () => { + test("drops the failed attempt from the terminal after a loop is detected", async () => { registerMockApi(); try { const mock = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }); @@ -344,9 +349,11 @@ describe("thinking-loop guard (stream wrapper)", () => { const events = await collect(stream(mock.model, context())); const terminal = events.at(-1); expect(terminal?.type).toBe("error"); - // The guard must not forward the looping thinking_end / done. + // A prefix can stream before detection, but the failed terminal must not + // carry replayable content or forward the normal completion boundary. expect(events.some(e => e.type === "thinking_end")).toBe(false); expect(events.some(e => e.type === "done")).toBe(false); + if (terminal?.type === "error") expect(terminal.error.content).toEqual([]); } finally { clearCustomApis(); } @@ -367,6 +374,75 @@ describe("thinking-loop guard (stream wrapper)", () => { } }); + test("terminates the observed long-cycle Kiro text runaway", async () => { + registerMockApi(); + try { + const mock = createMockModel({ provider: "kiro", id: "gpt-5-6-sol" }); + mock.push({ content: [`Healthy lead sentence. ${OBSERVED_KIRO_CYCLE.repeat(6)}`] }); + + const result = await stream(mock.model, context()).result(); + + expect(result.stopReason).toBe("error"); + expect(result.content).toEqual([]); + expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true); + } finally { + clearCustomApis(); + } + }); + + test("detects the Kiro cycle across token-sized synthetic-provider deltas", async () => { + const model = { + api: "openai-completions", + provider: "synthetic", + id: "gpt-5-6-sol", + name: "GPT 5.6 Sol", + baseUrl: "https://unused.example.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 32_768, + } as Model<"openai-completions">; + const text = `Healthy lead sentence. ${OBSERVED_KIRO_CYCLE.repeat(6)}`; + const fetch = async (): Promise => { + const events: string[] = []; + for (let i = 0; i < text.length; i += 23) { + events.push( + JSON.stringify({ + id: "cycle", + object: "chat.completion.chunk", + created: 0, + model: model.id, + choices: [{ index: 0, delta: { content: text.slice(i, i + 23) } }], + }), + ); + } + events.push( + JSON.stringify({ + id: "cycle", + object: "chat.completion.chunk", + created: 0, + model: model.id, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }), + "[DONE]", + ); + return new Response(`${events.map(event => `data: ${event}`).join("\n\n")}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + }; + + const events = await collect(streamSimple(model, context(), { apiKey: "test", fetch })); + const terminal = events.at(-1); + + expect(events.some(event => event.type === "text_delta")).toBe(true); + expect(terminal?.type).toBe("error"); + if (terminal?.type !== "error") throw new Error("expected loop error terminal"); + expect(terminal.error.content).toEqual([]); + expect(AIError.is(terminal.error.errorId, AIError.Flag.ThinkingLoop)).toBe(true); + }); + test("does not trip on a healthy gemini turn that reasons then answers", async () => { registerMockApi(); try { @@ -636,12 +712,12 @@ describe("GeminiHeaderRunDetector", () => { }); }); -describe("thinking-loop cook fallback (result path)", () => { +describe("thinking-loop retry budget (result path)", () => { function loopResponse(): { content: MockContent[] } { return { content: [{ type: "thinking", thinking: nearDuplicateLoop(12) }] }; } - test("completeSimple re-samples a loop then cooks through with the guard disabled", async () => { + test("completeSimple fails closed after three guarded attempts", async () => { registerMockApi(); const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined); try { @@ -650,21 +726,18 @@ describe("thinking-loop cook fallback (result path)", () => { const result = await completeSimple(mock.model, context()); - // Three guarded attempts raise the stall; the fourth (guard disabled) cooks through. - expect(mock.calls).toHaveLength(4); - expect(result.stopReason).toBe("stop"); - expect(result.content.some(block => block.type === "thinking")).toBe(true); - expect(result.errorMessage).toBeUndefined(); - // First three dispatches are guarded; only the final cook pass disables it. - expect(mock.calls[0]?.options?.loopGuard?.enabled).toBeUndefined(); - expect(mock.calls[3]?.options?.loopGuard?.enabled).toBe(false); + expect(mock.calls).toHaveLength(3); + expect(result.stopReason).toBe("error"); + expect(result.content).toEqual([]); + expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(mock.calls.every(call => call.options?.loopGuard?.enabled !== false)).toBe(true); } finally { waitSpy.mockRestore(); clearCustomApis(); } }); - test("complete (non-simple) also cooks through after the abort budget", async () => { + test("complete (non-simple) also fails closed after three guarded attempts", async () => { registerMockApi(); const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined); try { @@ -673,11 +746,11 @@ describe("thinking-loop cook fallback (result path)", () => { const result = await complete(mock.model, context()); - expect(mock.calls).toHaveLength(4); - expect(result.stopReason).toBe("stop"); - expect(result.errorMessage).toBeUndefined(); - expect(mock.calls[0]?.options?.loopGuard?.enabled).toBeUndefined(); - expect(mock.calls[3]?.options?.loopGuard?.enabled).toBe(false); + expect(mock.calls).toHaveLength(3); + expect(result.stopReason).toBe("error"); + expect(result.content).toEqual([]); + expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(mock.calls.every(call => call.options?.loopGuard?.enabled !== false)).toBe(true); } finally { waitSpy.mockRestore(); clearCustomApis(); @@ -706,23 +779,79 @@ describe("thinking-loop cook fallback (result path)", () => { } }); - test("does not retry a contentful marker error (replay-unsafe output)", async () => { + test("a caller abort after the third guarded result supersedes the loop error", async () => { registerMockApi(); + const controller = new AbortController(); const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined); try { - const mock = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }); - mock.push({ - content: ["Looping visible reasoning garbage."], - stopReason: "error", - errorMessage: `${THINKING_LOOP_ERROR_MARKER}: already streamed, non-retryable`, + let attempts = 0; + const mock = createMockModel({ + provider: "openrouter", + id: "google/gemini-3.5-flash", + handler: () => { + if (++attempts === 3) controller.abort(new Error("cancelled after final result")); + return loopResponse(); + }, }); - const result = await completeSimple(mock.model, context()); + await expect(completeSimple(mock.model, context(), { signal: controller.signal })).rejects.toThrow( + "cancelled after final result", + ); + expect(mock.calls).toHaveLength(3); + } finally { + waitSpy.mockRestore(); + clearCustomApis(); + } + }); - // Visible content already escaped: the marker error is returned as-is, never re-sampled. - expect(mock.calls).toHaveLength(1); + test("does not retry a contentful ThinkingLoop error (replay-unsafe output)", async () => { + const api = "contentful-loop-test"; + let calls = 0; + const model = { + api, + provider: "test", + id: "test-model", + name: "Test model", + baseUrl: "test://", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000, + maxTokens: 100, + } as unknown as Model; + registerCustomApi(api, () => { + calls++; + const inner = new AssistantMessageEventStream(); + const error: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Looping visible reasoning garbage." }], + 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: "error", + errorMessage: `${THINKING_LOOP_ERROR_MARKER}: already streamed, non-retryable`, + errorId: AIError.create(AIError.Flag.ThinkingLoop), + timestamp: Date.now(), + }; + inner.push({ type: "error", reason: "error", error }); + return inner; + }); + const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined); + try { + const result = await completeSimple(model, context()); + + expect(calls).toBe(1); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER); + expect(result.content).toHaveLength(1); + expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true); } finally { waitSpy.mockRestore(); clearCustomApis(); diff --git a/packages/ai/test/umans-usage.test.ts b/packages/ai/test/umans-usage.test.ts index e75b1738d..061604f33 100644 --- a/packages/ai/test/umans-usage.test.ts +++ b/packages/ai/test/umans-usage.test.ts @@ -4,6 +4,8 @@ import { umansUsageProvider } from "../src/usage/umans"; const DEFAULT_BASE_URL = "https://api.code.umans.ai"; +const RESETS_AT = "2026-08-06T21:52:21.202174+00:00"; + function umansPayload(overrides: Record = {}): Record { return { plan: { display_name: "Code Max" }, @@ -11,9 +13,16 @@ function umansPayload(overrides: Record = {}): Record { - it("parses the rolling 5h request window into a UsageLimit with used/remaining/fraction", async () => { + it("splits requests into soft-cap (weighted) and burst-ceiling (raw) limits", async () => { const report = await umansUsageProvider.fetchUsage( { provider: "umans", @@ -60,20 +68,229 @@ describe("umans usage provider", () => { { fetch: fakeFetch(umansPayload()) }, ); expect(report).not.toBeNull(); + const soft = report?.limits.find(l => l.id === "umans:requests:soft"); + expect(soft).toBeDefined(); + // Weighted "effective requests" are authoritative against the soft cap: + // 96 effective used of 200. + expect(soft?.amount.used).toBe(96); + expect(soft?.amount.limit).toBe(200); + expect(soft?.amount.remaining).toBe(104); + expect(soft?.amount.usedFraction).toBeCloseTo(0.48, 5); + expect(soft?.amount.remainingFraction).toBeCloseTo(0.52, 5); + expect(soft?.amount.unit).toBe("requests"); + expect(soft?.status).toBe("ok"); + // The rolling 5h window still exposes its absolute `resets_at` as an + // incremental countdown for the status line. + expect(soft?.window?.resetsAt).toBe(Date.parse(RESETS_AT)); + expect(soft?.window?.resetLabel).toBe("tick"); + expect(soft?.window?.durationMs).toBe(18000_000); + expect(soft?.window?.label).toBe("rolling 5h"); + // Raw counts against the burst ceiling are a separate row. + const hard = report?.limits.find(l => l.id === "umans:requests:hard"); + expect(hard).toBeDefined(); + expect(hard?.amount.used).toBe(48); + expect(hard?.amount.limit).toBe(400); + expect(hard?.amount.usedFraction).toBeCloseTo(0.12, 5); + expect(hard?.status).toBe("ok"); + }); + + it("falls back to the legacy raw row when weighted fields are absent", async () => { + const report = await umansUsageProvider.fetchUsage( + { + provider: "umans", + credential: { type: "api_key", apiKey: "sk-test" }, + }, + { + fetch: fakeFetch( + umansPayload({ + window: undefined, + usage: { + requests_in_window: 48, + remaining_requests: 152, + concurrent_sessions: 1, + tokens_in: 0, + tokens_out: 0, + priority: { low: false }, + }, + }), + ), + }, + ); const requests = report?.limits.find(l => l.id === "umans:requests"); expect(requests).toBeDefined(); + expect(report?.limits.some(l => l.id.startsWith("umans:requests:"))).toBe(false); expect(requests?.amount.used).toBe(48); - expect(requests?.amount.limit).toBe(200); expect(requests?.amount.remaining).toBe(152); expect(requests?.amount.usedFraction).toBeCloseTo(0.24, 5); - expect(requests?.amount.remainingFraction).toBeCloseTo(0.76, 5); - expect(requests?.amount.unit).toBe("requests"); - // Rolling window: no fabricated reset timestamp. expect(requests?.window?.resetsAt).toBeUndefined(); - expect(requests?.window?.durationMs).toBe(18000_000); expect(requests?.window?.label).toBe("rolling 5h"); }); + it("does not report exhausted when raw requests exceed the soft cap but weighted headroom remains (#7858)", async () => { + // Real payload from https://github.com/can1357/oh-my-pi/issues/7858: + // raw 838 exceeds the 500 soft cap (previously clamped to 1.0 → false + // exhausted), while weighted "effective requests" are 207/500 with 293 + // remaining — the account continues normally. Raw traffic only reaches + // the burst ceiling (1000) before throttling applies. + const report = await umansUsageProvider.fetchUsage( + { + provider: "umans", + credential: { type: "api_key", apiKey: "sk-test" }, + }, + { + fetch: fakeFetch( + umansPayload({ + limits: { + requests: { limit: 500, hard_cap: 1000, burst_pct: 1.0, window_seconds: 18000 }, + concurrency: { limit: 4, hard_cap: 8, burst_pct: 1.0 }, + }, + usage: { + requests_in_window: 838, + remaining_requests: 0, + weighted_in_window: 207, + weighted_remaining_requests: 293, + concurrent_sessions: 0, + tokens_in: 3_557_477, + tokens_out: 723_550, + priority: { low: false, boxed_until: null, reason: null }, + }, + }), + ), + }, + ); + const soft = report?.limits.find(l => l.id === "umans:requests:soft"); + expect(soft).toBeDefined(); + expect(soft?.amount.used).toBe(207); + expect(soft?.amount.remaining).toBe(293); + expect(soft?.amount.usedFraction).toBeCloseTo(0.414, 3); + expect(soft?.status).toBe("ok"); + expect(soft?.window?.resetsAt).toBe(Date.parse(RESETS_AT)); + const hard = report?.limits.find(l => l.id === "umans:requests:hard"); + expect(hard).toBeDefined(); + expect(hard?.amount.used).toBe(838); + expect(hard?.amount.limit).toBe(1000); + expect(hard?.amount.usedFraction).toBeCloseTo(0.838, 3); + expect(hard?.status).toBe("ok"); + expect(report?.limits.some(l => l.status === "exhausted")).toBe(false); + }); + + it("collapses to a single weighted requests row that can exhaust when no burst ceiling is reported", async () => { + // Weighted counters present but `hard_cap` absent: without a burst + // ceiling there is no hard row to defer exhaustion to, so the weighted + // effective-request budget is the operative ceiling — the single row + // must be able to report `exhausted` or a spent account could never + // trigger the usage-aware fallback. + const report = await umansUsageProvider.fetchUsage( + { + provider: "umans", + credential: { type: "api_key", apiKey: "sk-test" }, + }, + { + fetch: fakeFetch( + umansPayload({ + limits: { + requests: { limit: 200, window_seconds: 18000 }, + concurrency: { limit: 4, hard_cap: 8, burst_pct: 1.0 }, + }, + usage: { + requests_in_window: 400, + remaining_requests: 0, + weighted_in_window: 200, + weighted_remaining_requests: 0, + concurrent_sessions: 0, + tokens_in: 0, + tokens_out: 0, + priority: { low: false }, + }, + }), + ), + }, + ); + const requests = report?.limits.find(l => l.id === "umans:requests"); + expect(requests).toBeDefined(); + // No soft/hard split without a reported burst ceiling. + expect(report?.limits.some(l => l.id.startsWith("umans:requests:"))).toBe(false); + // Weighted effective requests are authoritative: raw 400 overshoots the + // 200 limit, but it is the weighted 200/200 that reports exhausted. + expect(requests?.amount.used).toBe(200); + expect(requests?.amount.limit).toBe(200); + expect(requests?.amount.usedFraction).toBe(1); + expect(requests?.status).toBe("exhausted"); + }); + + it("keeps weighted headroom decisive when no burst ceiling is reported", async () => { + // Same #7858 shape (raw usage over the soft limit, weighted headroom + // remaining) but with no `hard_cap` in the payload: the weighted counter + // must still decide, so raw burst traffic cannot fabricate an exhausted + // state even when there is no hard row to buffer it. + const report = await umansUsageProvider.fetchUsage( + { + provider: "umans", + credential: { type: "api_key", apiKey: "sk-test" }, + }, + { + fetch: fakeFetch( + umansPayload({ + limits: { + requests: { limit: 200, window_seconds: 18000 }, + concurrency: { limit: 4, hard_cap: 8, burst_pct: 1.0 }, + }, + usage: { + requests_in_window: 300, + remaining_requests: 0, + weighted_in_window: 100, + weighted_remaining_requests: 100, + concurrent_sessions: 0, + tokens_in: 0, + tokens_out: 0, + priority: { low: false }, + }, + }), + ), + }, + ); + const requests = report?.limits.find(l => l.id === "umans:requests"); + expect(requests).toBeDefined(); + expect(requests?.amount.used).toBe(100); + expect(requests?.amount.remaining).toBe(100); + expect(requests?.amount.usedFraction).toBeCloseTo(0.5, 5); + expect(requests?.status).toBe("ok"); + expect(report?.limits.some(l => l.status === "exhausted")).toBe(false); + }); + + it("reserves exhausted for the burst ceiling and warns at the soft cap", async () => { + const report = await umansUsageProvider.fetchUsage( + { + provider: "umans", + credential: { type: "api_key", apiKey: "sk-test" }, + }, + { + fetch: fakeFetch( + umansPayload({ + usage: { + requests_in_window: 1000, + remaining_requests: 0, + weighted_in_window: 500, + weighted_remaining_requests: 0, + concurrent_sessions: 0, + tokens_in: 0, + tokens_out: 0, + priority: { low: false }, + }, + }), + ), + }, + ); + const soft = report?.limits.find(l => l.id === "umans:requests:soft"); + expect(soft?.amount.usedFraction).toBe(1); + // Soft cap hit = burst headroom in use; warn, never exhaust. + expect(soft?.status).toBe("warning"); + const hard = report?.limits.find(l => l.id === "umans:requests:hard"); + expect(hard?.amount.usedFraction).toBe(1); + // Only the raw burst ceiling can exhaust (that's where 429s start). + expect(hard?.status).toBe("exhausted"); + }); + it("emits a concurrency limit from limits.concurrency", async () => { const report = await umansUsageProvider.fetchUsage( { diff --git a/packages/ai/test/xai-login.test.ts b/packages/ai/test/xai-login.test.ts index 1cdd2ca1d..3571b531a 100644 --- a/packages/ai/test/xai-login.test.ts +++ b/packages/ai/test/xai-login.test.ts @@ -29,6 +29,52 @@ describe("xAI API login wiring", () => { expect(getEnvApiKey("xai")).toBe("xai-env-key"); }); + test("XAI_API_KEY alone does not mark SuperGrok as available", async () => { + const originalOauthToken = Bun.env.XAI_OAUTH_TOKEN; + Bun.env.XAI_API_KEY = "xai-env-key"; + delete Bun.env.XAI_OAUTH_TOKEN; + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + const storage = new AuthStorage(store); + await storage.reload(); + try { + expect(storage.hasAuth("xai")).toBe(true); + expect(storage.hasAuth("xai-oauth")).toBe(false); + expect(storage.hasResolvableAuth("xai")).toBe(true); + expect(storage.hasResolvableAuth("xai-oauth")).toBe(true); + expect(getEnvApiKey("xai-oauth")).toBe("xai-env-key"); + expect(storage.getCredentialOrigin("xai")).toEqual({ kind: "env", envVar: "XAI_API_KEY" }); + expect(storage.getCredentialOrigin("xai-oauth")).toBeUndefined(); + } finally { + if (originalOauthToken === undefined) { + delete Bun.env.XAI_OAUTH_TOKEN; + } else { + Bun.env.XAI_OAUTH_TOKEN = originalOauthToken; + } + store.close(); + } + }); + + test("XAI_OAUTH_TOKEN marks SuperGrok available without a paid API key", async () => { + const originalOauthToken = Bun.env.XAI_OAUTH_TOKEN; + delete Bun.env.XAI_API_KEY; + Bun.env.XAI_OAUTH_TOKEN = "xai-oauth-env"; + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + const storage = new AuthStorage(store); + await storage.reload(); + try { + expect(storage.hasAuth("xai")).toBe(false); + expect(storage.hasAuth("xai-oauth")).toBe(true); + expect(storage.getCredentialOrigin("xai-oauth")).toEqual({ kind: "env" }); + } finally { + if (originalOauthToken === undefined) { + delete Bun.env.XAI_OAUTH_TOKEN; + } else { + Bun.env.XAI_OAUTH_TOKEN = originalOauthToken; + } + store.close(); + } + }); + test("AuthStorage.login('xai') validates against /models and stores the pasted key", async () => { const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { diff --git a/packages/ai/test/xai-oauth-effort-strip.test.ts b/packages/ai/test/xai-oauth-effort-strip.test.ts index c84012057..6b7f99d89 100644 --- a/packages/ai/test/xai-oauth-effort-strip.test.ts +++ b/packages/ai/test/xai-oauth-effort-strip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { buildParams } from "@oh-my-pi/pi-ai/providers/openai-responses"; -import type { Context } from "@oh-my-pi/pi-ai/types"; +import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -38,6 +38,23 @@ describe("effort-dial-less reasoner encoding (regression)", () => { expect(grokR.thinking).toBeUndefined(); }); + test("paid xai/grok-code-fast-1 reasons but carries no thinking config", () => { + const grokCodeFast = getBundledModel("xai", "grok-code-fast-1"); + if (!grokCodeFast) throw new Error("xai/grok-code-fast-1 must be in bundled models.json"); + expect(grokCodeFast.api).toBe("openai-responses"); + expect(grokCodeFast.reasoning).toBe(true); + expect(grokCodeFast.thinking).toBeUndefined(); + expect(getSupportedEfforts(grokCodeFast)).toEqual([]); + }); + + test("paid xai/grok-4.3 keeps its effort dial", () => { + const grok43 = getBundledModel("xai", "grok-4.3"); + if (!grok43) throw new Error("xai/grok-4.3 must be in bundled models.json"); + expect(grok43.api).toBe("openai-responses"); + expect(grok43.thinking).toBeDefined(); + expect(getSupportedEfforts(grok43).length).toBeGreaterThan(0); + }); + test("the no-dial encoding stays scoped to openai-responses*", () => { const claude = getBundledModel("anthropic", "claude-sonnet-4-6"); if (!claude) throw new Error("anthropic/claude-sonnet-4-6 must be in bundled models.json"); @@ -57,6 +74,7 @@ describe("xAI OAuth Responses reasoning payload (regression)", () => { const { params } = buildParams(grok45, singleUserContext, undefined, undefined); expect(params.reasoning).toBeUndefined(); + expect(params.include).toContain("reasoning.encrypted_content"); }); test("xai-oauth/grok-4.5 omits unsupported reasoning summary", () => { @@ -66,5 +84,142 @@ describe("xAI OAuth Responses reasoning payload (regression)", () => { const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.High }, undefined); expect(params.reasoning).toEqual({ effort: "high" }); + expect(params.include).toContain("reasoning.encrypted_content"); + }); + + test("paid xai/grok-4.5 omits unsupported reasoning summary", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.High }, undefined); + + expect(params.reasoning).toEqual({ effort: "high" }); + expect(params.include).toContain("reasoning.encrypted_content"); + }); + + test("paid xai/grok-4.5 requests encrypted reasoning content", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.High }, undefined); + + expect(params.include).toContain("reasoning.encrypted_content"); + }); + + test("paid xai/grok-4.5 omits presence_penalty on reasoning models", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams( + grok45, + singleUserContext, + { reasoning: Effort.High, presencePenalty: 0.4, temperature: 0.2 }, + undefined, + ); + + expect(params).not.toHaveProperty("presence_penalty"); + expect(params.temperature).toBe(0.2); + }); + + test("paid xai/grok-2 omits presence_penalty on non-reasoning Responses models", () => { + const grok2 = getBundledModel<"openai-responses">("xai", "grok-2"); + if (!grok2) throw new Error("xai/grok-2 must be in bundled models.json"); + + const { params } = buildParams(grok2, singleUserContext, { presencePenalty: 0.4, temperature: 0.2 }, undefined); + + expect(params).not.toHaveProperty("presence_penalty"); + expect(params.temperature).toBe(0.2); + }); + + test("paid xai/grok-4.5 clamps minimal reasoning effort to low", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.Minimal }, undefined); + + expect(params.reasoning).toEqual({ effort: "low" }); + }); + + test("xai-oauth/grok-4.5 clamps minimal reasoning effort to low", () => { + const grok45 = getBundledModel<"openai-responses">("xai-oauth", "grok-4.5"); + if (!grok45) throw new Error("xai-oauth/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, singleUserContext, { reasoning: Effort.Minimal }, undefined); + + expect(params.reasoning).toEqual({ effort: "low" }); + }); + + test("xai-oauth/grok-4.5 replays encrypted reasoning on the next turn", () => { + const grok45 = getBundledModel<"openai-responses">("xai-oauth", "grok-4.5"); + if (!grok45) throw new Error("xai-oauth/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, followUpContextWithEncryptedReasoning(grok45), undefined, undefined); + + expect(params.include).toContain("reasoning.encrypted_content"); + expect(findEncryptedReasoning(params.input)).toEqual({ + type: "reasoning", + id: "rs_xai_next_turn", + encrypted_content: "enc_next_turn", + }); + }); + + test("paid xai/grok-4.5 replays encrypted reasoning on the next turn", () => { + const grok45 = getBundledModel<"openai-responses">("xai", "grok-4.5"); + if (!grok45) throw new Error("xai/grok-4.5 must be in bundled models.json"); + + const { params } = buildParams(grok45, followUpContextWithEncryptedReasoning(grok45), undefined, undefined); + + expect(findEncryptedReasoning(params.input)).toEqual({ + type: "reasoning", + id: "rs_xai_next_turn", + encrypted_content: "enc_next_turn", + }); }); }); + +function followUpContextWithEncryptedReasoning(model: Model<"openai-responses">): Context { + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal plan", + thinkingSignature: JSON.stringify({ + type: "reasoning", + id: "rs_xai_next_turn", + encrypted_content: "enc_next_turn", + }), + }, + { type: "text", text: "done" }, + ], + api: "openai-responses", + 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: 1, + }; + return { + messages: [ + { role: "user", content: "first", timestamp: 0 }, + assistant, + { role: "user", content: "continue", timestamp: 2 }, + ], + }; +} + +function findEncryptedReasoning(input: unknown): Record | undefined { + if (!Array.isArray(input)) return undefined; + return input.find(item => { + if (!item || typeof item !== "object") return false; + const candidate = item as { type?: unknown; encrypted_content?: unknown }; + return candidate.type === "reasoning" && typeof candidate.encrypted_content === "string"; + }) as Record | undefined; +} diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 9f7534918..9822535f3 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog ## [Unreleased] +### Fixed + +- Raised the GPT-5.6 Sol/Terra/Luna context window on the Codex transport (openai-codex) from 372K to 1M tokens: OpenAI enabled the 1M window for subscription Codex on 2026-08-16, but the Codex model registry still reports the stale 272,000, so discovery now floors these SKUs at 1,000,000 instead of trusting the reported value ([openai/codex#38917](https://github.com/openai/codex/issues/38917)). + + +## [17.3.5] - 2026-08-16 + +### Added + +- Added support for GLM-5.3 on the z.AI provider, featuring a unified low/high/max reasoning-effort ladder across all hosts, mandatory thinking mode, 1M context, and default-model status for the z.AI provider. + +### Changed + +- Switched the paid xAI provider (xai / XAI_API_KEY) from Chat Completions to the OpenAI Responses API, aligning it with SuperGrok (xai-oauth) for prompt-cache affinity, reasoning-effort handling, and encrypted-reasoning replay. +- Changed the paid xAI (XAI_API_KEY) default model to grok-4.5. +- Changed the SuperGrok (xai-oauth) default model to grok-4.5. +- Improved reasoning continuity for xAI models by requesting and replaying encrypted reasoning content across multi-turn Responses API calls. + +### Fixed + +- Fixed Codex Daybreak Blue and Red model discovery reporting zero token prices, which incorrectly labeled the models as free in the model picker. +- Fixed Baseten's moonshotai/Kimi-K3 catalog metadata so its low/high/max thinking levels are available. +- Fixed opencode-go/deepseek-v4-flash Responses requests sending forced named tool_choice selectors that are rejected while thinking mode is active. ## [17.3.4] - 2026-08-14 diff --git a/packages/catalog/package.json b/packages/catalog/package.json index a968cd4ea..247bef560 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-catalog", - "version": "17.3.4", + "version": "17.3.5", "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/catalog/scripts/generate-models.ts b/packages/catalog/scripts/generate-models.ts index e51cabdf4..4fba4246f 100644 --- a/packages/catalog/scripts/generate-models.ts +++ b/packages/catalog/scripts/generate-models.ts @@ -20,6 +20,7 @@ import { ANTIGRAVITY_PRIMARY_ENDPOINT, fetchAntigravityDiscoveryModels } from ". import { buildGitLabDuoWorkflowFallbackModel } from "../src/discovery/gitlab-duo-workflow"; import { createModelManager } from "../src/model-manager"; import prevModelsJson from "../src/models.json" with { type: "json" }; +import { resolveOpenAIDaybreakStandardCost } from "../src/openai-pricing"; import { toModelSpec } from "../src/provider-models/bundled-references"; import { allowsUnauthenticatedCatalogDiscovery, @@ -285,7 +286,7 @@ function applyCodexPricingFallback(models: readonly ModelSpec[]): ModelSpec[] { return model; } - const openAICost = openAIModels.get(model.id); + const openAICost = openAIModels.get(model.id) ?? resolveOpenAIDaybreakStandardCost(model.id); if (!openAICost) { return model; } @@ -555,6 +556,24 @@ async function generateModels() { // Mythos 5). Deduped behind upstream entries; metadata is pinned in // applyAnthropicCatalogPolicy. allModels.push(...ANTHROPIC_CURATED_FALLBACK_MODELS); + // Seed GLM-5.3 on the z.AI provider. GLM-5.3 is live on the Anthropic and + // coding endpoints but not yet advertised in `/v1/models` (which still tops + // out at glm-5.2), so endpoint discovery misses it. The zai provider is not + // authoritative, so the seed survives regeneration; thinking metadata + // (low/high/max uniform ladder, mandatory reasoning, defaultLevel=max) is + // derived by rebakeModelThinking from the identity classifiers. + allModels.push({ + id: "glm-5.3", + name: "GLM-5.3", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 131_072, + } as ModelSpec<"anthropic-messages">); // Seed Meta's documented Muse model so first-run selection does not depend on // credentials or live discovery. allModels.push(...META_MUSE_STATIC_MODELS); diff --git a/packages/catalog/scripts/generated-policies.ts b/packages/catalog/scripts/generated-policies.ts index 2b97e0dfb..2ca015337 100644 --- a/packages/catalog/scripts/generated-policies.ts +++ b/packages/catalog/scripts/generated-policies.ts @@ -21,6 +21,7 @@ import { resolveModelThinking } from "../src/model-thinking"; import { isOllamaCloudOutputCapped, OLLAMA_CLOUD_MAX_OUTPUT_TOKENS } from "../src/provider-models/ollama"; import { ALIBABA_TOKEN_PLAN_STATIC_MODELS, + applyXaiResponsesThinkingPolicy, OPENAI_GPT_56_LONG_CONTEXT_COSTS, resolveWaferServerlessThinkingFormat, } from "../src/provider-models/openai-compat"; @@ -144,7 +145,7 @@ const CODEX_GPT_5_4_PRIORITY_BY_VARIANT: Partial> nano: 2, }; -const CODEX_GPT_5_6_372K_MODEL_IDS: Record = { +const CODEX_GPT_5_6_1M_MODEL_IDS: Record = { "gpt-5.6-luna": true, "gpt-5.6-sol": true, "gpt-5.6-terra": true, @@ -353,6 +354,10 @@ export function applyOllamaCloudOutputCap(models: ModelSpec[]): void { } function applyGeneratedModelPolicy(model: ModelSpec): void { + if ((model.provider === "xai" || model.provider === "xai-oauth") && model.api === "openai-responses") { + const updated = applyXaiResponsesThinkingPolicy(model as ModelSpec<"openai-responses">); + model.compat = updated.compat; + } const copilotLimits = model.provider === "github-copilot" ? COPILOT_GENERATED_LIMITS[model.id] : undefined; if (copilotLimits) { model.contextWindow = copilotLimits.contextWindow; @@ -367,9 +372,13 @@ function applyGeneratedModelPolicy(model: ModelSpec): void { model.omitMaxOutputTokens = true; } - // GLM Coding Plan: GLM-5.2 is the selectable 1M served id; pin it so + // GLM Coding Plan: the selectable 1M-context served ids; pin them so // endpoint discovery or older bundled fallbacks cannot regress to 200k. - if ((model.provider === "zai" || model.provider === "zhipu-coding-plan") && model.id === "glm-5.2") { + // GLM-5.3 succeeds GLM-5.2 with the same 1M context window. + if ( + (model.provider === "zai" || model.provider === "zhipu-coding-plan") && + (model.id === "glm-5.2" || model.id === "glm-5.3") + ) { model.contextWindow = 1_000_000; model.maxTokens = 131_072; } @@ -425,7 +434,7 @@ function applyGeneratedModelPolicy(model: ModelSpec): void { }; } if ( - model.api === "openai-completions" && + (model.api === "openai-completions" || model.api === "openai-responses") && model.provider === "opencode-go" && (model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro") ) { @@ -527,12 +536,12 @@ function applyOpenAICatalogPolicy(model: ModelSpec, parsedModel: OpenAIMode model.contextWindow = 272000; } } - // GPT-5.6 luna/sol/terra on the Codex transport: OpenAI's Codex model - // registry declares context_window = max_context_window = 372000, but Codex - // discovery omits `context_window` for these SKUs and falls back to - // DEFAULT_CONTEXT_WINDOW (272000, src/discovery/codex.ts), which regressed - // the bundled hard capacity (#5705). Pin the true 372K input window. - if (model.api === "openai-codex-responses" && CODEX_GPT_5_6_372K_MODEL_IDS[model.id]) { - model.contextWindow = 372000; + // GPT-5.6 luna/sol/terra on the Codex transport: OpenAI enabled a 1M-token + // window for subscription Codex (2026-08-16), but the Codex model registry + // still reports the stale 272000 (openai/codex#38917), so floor the bundled + // window at 1,000,000. Daybreak aliases are excluded — the registry actively + // reports their true window. + if (model.api === "openai-codex-responses" && CODEX_GPT_5_6_1M_MODEL_IDS[model.id]) { + model.contextWindow = Math.max(model.contextWindow ?? 0, 1_000_000); } } diff --git a/packages/catalog/src/compat/openai.ts b/packages/catalog/src/compat/openai.ts index ad7ba06b0..2eeec29f1 100644 --- a/packages/catalog/src/compat/openai.ts +++ b/packages/catalog/src/compat/openai.ts @@ -16,6 +16,7 @@ import { isDeepseekModelIdOrName, isGlm52ReasoningEffortModelId, isGrokReasoningEffortCapable, + isGrokXHighEffortCapable, isKimiK3ModelId, isKimiK26ModelId, isKimiModelId, @@ -177,6 +178,22 @@ const MIMO_REASONING_EFFORT_MAP: NonNullable xhigh: "high", }; +/** Shared `minimal → low` clamp. xhigh-capable Grok keeps `xhigh` unmapped. */ +const XAI_RESPONSES_MINIMAL_EFFORT_MAP: NonNullable = { + minimal: "low", +}; +/** Grok 4.5 / 4.3 / 3-mini: leftover `xhigh`/`max` clamp to `high`. */ +const XAI_RESPONSES_CLAMPED_EFFORT_MAP: NonNullable = { + minimal: "low", + xhigh: "high", + max: "high", +}; + +/** Wire effort remap for first-party xAI Responses. */ +export function xaiResponsesReasoningEffortMap(modelId: string): NonNullable { + return isGrokXHighEffortCapable(modelId) ? XAI_RESPONSES_MINIMAL_EFFORT_MAP : XAI_RESPONSES_CLAMPED_EFFORT_MAP; +} + function mergeModelReasoningEffortMap( compat: ResolvedOpenAISharedCompat, modelId: string, @@ -471,6 +488,8 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv // OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit // temperature/top_p/… with a 400 on every serving host (#5606). supportsSamplingParams: !isOpenAISamplingRestrictedModelId(spec.id), + // xAI reasoning models 400 on presence/frequency penalties and stop. + supportsPenaltyAndStopParams: !(isGrok && Boolean(spec.reasoning)), reasoningEffortMap: {}, supportsUsageInStreaming: !isCerebras, // Kimi (including via OpenRouter and Fireworks router-form IDs such as @@ -684,34 +703,46 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol const isLocalServingBackend = (!PROXY_OPENAI_COMPAT_PROVIDERS.has(spec.provider) && LOCAL_OPENAI_COMPAT_PROVIDERS.has(spec.provider)) || hasLocalLoopbackBaseUrl(baseUrl); + const isXaiHost = modelMatchesHost({ provider: spec.provider, baseUrl }, "xai"); const compat: ResolvedOpenAIResponsesCompat = { supportsDeveloperRole: isAzure || isOpenAIUrl || hostMatchesUrl(baseUrl, "githubCopilot"), supportsStrictMode: isAzure || detectStrictModeSupport(spec.provider, baseUrl), - supportsReasoningEffort: spec.provider !== "xai-oauth" || isGrokReasoningEffortCapable(id), + // Paid `xai` and SuperGrok `xai-oauth` share api.x.ai `/v1/responses`. + // Only the Grok effort-capable allowlist accepts `reasoning.effort`; + // other reasoners (grok-build, grok-code-fast-1, …) 400 if it is sent. + supportsReasoningEffort: !isXaiHost || isGrokReasoningEffortCapable(id), supportsLongPromptCacheRetention: isOpenAIUrl, supportsPromptCacheBreakpoints, promptCacheBreakpointTtl: supportsPromptCacheBreakpoints ? "30m" : undefined, // Azure OpenAI and GitHub Copilot Responses paths require tool results // to strictly match prior tool calls when building Responses inputs. strictResponsesPairing: isAzure || spec.provider === "github-copilot", - // GitHub Copilot and xAI OAuth reject `detail: "original"` (400 / 422). - // Every other host preserves native-resolution frames (snapcompact relies - // on `original`). Detect Copilot by provider id or base-URL host so a - // model pointed at the Copilot host under a different provider id still - // clamps; xai-oauth is provider-id only (same host family as paid `xai`). + // GitHub Copilot and first-party xAI `/v1/responses` reject + // `detail: "original"` (400 / 422). Every other host preserves + // native-resolution frames (snapcompact relies on `original`). Detect + // Copilot by provider id or base-URL host so a model pointed at the + // Copilot host under a different provider id still clamps. supportsImageDetailOriginal: - spec.provider !== "xai-oauth" && !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"), - reasoningEffortMap: {}, + !isXaiHost && !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"), + // api.x.ai rejects `reasoning.summary` (SuperGrok and paid key alike). + supportsReasoningSummary: !isXaiHost, + reasoningEffortMap: isXaiHost ? { ...xaiResponsesReasoningEffortMap(id) } : {}, supportsReasoningParams: true, // OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit // temperature/top_p/… with a 400 on every serving host (#5606). supportsSamplingParams: !isOpenAISamplingRestrictedModelId(id), + // xAI `/v1/responses` rejects presence/frequency penalties for every + // model, not only reasoners (https://docs.x.ai/developers/rest-api-reference/inference/chat). + supportsPenaltyAndStopParams: !isXaiHost, thinkingFormat, reasoningDisableMode: resolveReasoningDisableMode(thinkingFormat), omitReasoningEffort: false, - includeEncryptedReasoning: spec.provider !== "xai-oauth", - filterReasoningHistory: spec.provider === "xai-oauth" || (isOpenRouter && isAnthropicModel), + // Ask xAI `/v1/responses` for `reasoning.encrypted_content` and replay + // those items on later turns. OpenRouter Anthropic still filters + // reasoning wrappers independently. + includeEncryptedReasoning: true, + filterReasoningHistory: isOpenRouter && isAnthropicModel, disableReasoningOnForcedToolChoice: isKimiModel, disableReasoningOnToolChoice: isDeepseekFamily && reasoningCapable && !isOpenRouter, supportsToolChoice: true, @@ -752,13 +783,24 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol MINIMAX_PROVIDER_OR_ID_PATTERN.test(spec.provider) || (id ? MINIMAX_PROVIDER_OR_ID_PATTERN.test(id) : false), emptyLengthFinishIsContextError: spec.provider === "ollama", usesOpenAIToolCallIdLimit: spec.provider === "openai", - promptCacheSessionHeader: spec.provider === "xai-oauth" ? "x-grok-conv-id" : undefined, + promptCacheSessionHeader: isXaiHost ? "x-grok-conv-id" : undefined, streamFirstEventTimeoutMs: isLocalServingBackend ? 0 : spec.compat?.streamFirstEventTimeoutMs, streamIdleTimeoutMs: isLocalServingBackend ? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS : spec.compat?.streamIdleTimeoutMs, }; applyCompatOverrides(compat, spec.compat); + if (isXaiHost) { + const canonical = xaiResponsesReasoningEffortMap(id); + compat.reasoningEffortMap = { ...compat.reasoningEffortMap, ...canonical }; + // xhigh-capable Grok advertises unmapped `xhigh`; drop a stale clamp + // from previous snapshots so 4.6 / 16-agent mode is not rewritten to `high`. + for (const key of ["xhigh", "max"] as const) { + if (!(key in canonical)) { + delete compat.reasoningEffortMap[key]; + } + } + } if (spec.compat?.reasoningDisableMode === undefined) { compat.reasoningDisableMode = resolveReasoningDisableMode(compat.thinkingFormat); } @@ -776,6 +818,7 @@ function pickResponsesOnly(compat: ResolvedOpenAIResponsesCompat): ResponsesOnly strictResponsesPairing: compat.strictResponsesPairing, supportsImageDetailOriginal: compat.supportsImageDetailOriginal, supportsObfuscationOptOut: compat.supportsObfuscationOptOut, + supportsReasoningSummary: compat.supportsReasoningSummary, isVercelGatewayHost: compat.isVercelGatewayHost, } satisfies ResponsesOnlyCompat; } diff --git a/packages/catalog/src/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts index b8ecc5e58..9c950ee14 100644 --- a/packages/catalog/src/discovery/codex.ts +++ b/packages/catalog/src/discovery/codex.ts @@ -1,5 +1,6 @@ import { type } from "@oh-my-pi/omptype"; import { parseKnownModel, semverEqual } from "../identity/classify"; +import { resolveOpenAIDaybreakStandardCost } from "../openai-pricing"; import type { FetchImpl, ModelSpec } from "../types"; import { discoveryFetch } from "../utils"; import { CODEX_BASE_URL, CODEX_CLIENT_VERSION, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../wire/codex"; @@ -8,13 +9,19 @@ const DEFAULT_MODEL_LIST_PATHS = ["/codex/models", "/models"] as const; const DEFAULT_CONTEXT_WINDOW = 272_000; const DEFAULT_MAX_TOKENS = 128_000; /** - * GPT-5.6 luna/sol/terra hard context capacity. Codex discovery omits - * `context_window` for these SKUs, so the generic {@link DEFAULT_CONTEXT_WINDOW} - * (272000) would understate the real window — OpenAI's Codex model registry - * declares context_window = max_context_window = 372000 (#5705). Used as the - * fallback only when upstream reports no value. + * Fallback for GPT-5.6-family SKUs when upstream omits `context_window`: the + * generic {@link DEFAULT_CONTEXT_WINDOW} (272000) understates the registry's + * former 372000 hard capacity (#5705). */ const GPT_5_6_CONTEXT_WINDOW = 372_000; +/** + * OpenAI enabled a 1M-token window for subscription Codex on GPT-5.6 + * luna/sol/terra (2026-08-16), but the Codex model registry still reports the + * stale 272000 — so the reported value must be floored, not just defaulted + * (openai/codex#38917; Codex CLI override `model_context_window = 1000000`). + */ +const GPT_5_6_1M_CONTEXT_WINDOW = 1_000_000; +const CODEX_GPT_5_6_1M_SLUGS: ReadonlySet = new Set(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]); const CODEX_REMOTE_COMPACTION = { enabled: true, api: "openai-codex-responses", @@ -223,20 +230,25 @@ function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCo } const name = toNonEmptyString(payload.display_name) ?? slug; - // Codex discovery omits `context_window` for GPT-5.6 luna/sol/terra; the - // generic 272000 fallback understates their real 372000 window (#5705). + // Codex discovery historically omitted `context_window` for GPT-5.6-family + // SKUs (#5705); luna/sol/terra additionally floor the reported value because + // the registry still declares the pre-1M 272000 window. const parsed = parseKnownModel(slug); const fallbackContextWindow = parsed.family === "openai" && semverEqual(parsed.version, "5.6") ? GPT_5_6_CONTEXT_WINDOW : DEFAULT_CONTEXT_WINDOW; - const contextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow; + const reportedContextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow; + const contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(slug) + ? Math.max(reportedContextWindow, GPT_5_6_1M_CONTEXT_WINDOW) + : reportedContextWindow; const maxTokens = Math.min(DEFAULT_MAX_TOKENS, contextWindow); const reasoning = supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels); const input = normalizeInputModalities(payload.input_modalities); const preferWebsockets = toBoolean(payload.prefer_websockets) === true; const useResponsesLite = toBoolean(payload.use_responses_lite) === true; const priority = toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER; + const daybreakCost = resolveOpenAIDaybreakStandardCost(slug); return { priority, @@ -248,7 +260,7 @@ function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCo baseUrl, reasoning, input, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: daybreakCost ? { ...daybreakCost } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, remoteCompaction: CODEX_REMOTE_COMPACTION, contextWindow, maxTokens, diff --git a/packages/catalog/src/hosts.ts b/packages/catalog/src/hosts.ts index 176f77125..5c7f3e2fb 100644 --- a/packages/catalog/src/hosts.ts +++ b/packages/catalog/src/hosts.ts @@ -47,7 +47,7 @@ export const KNOWN_HOSTS = { }, umans: { providers: ["umans"], urlMarkers: ["api.code.umans.ai"] }, xiaomi: { providers: ["xiaomi"], providerPrefixes: ["xiaomi-token-plan-"], urlMarkers: ["xiaomimimo.com"] }, - xai: { providers: ["xai"], urlMarkers: ["api.x.ai"] }, + xai: { providers: ["xai", "xai-oauth"], urlMarkers: ["api.x.ai"] }, mistral: { providers: ["mistral"], urlMarkers: ["mistral.ai"] }, together: { providers: ["together"], urlMarkers: ["api.together.xyz"] }, baseten: { providers: ["baseten"], urlMarkers: ["baseten.co"] }, diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index 289f80821..9395c3e13 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -110,7 +110,13 @@ export const isGrokModelId = memo((modelId: string): boolean => { return /(?:^|[./_-])grok(?:[-.]|$)/i.test(modelId); }); -const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "grok-4.3", "grok-4.5"] as const; +const GROK_EFFORT_CAPABLE_PREFIXES = [ + "grok-3-mini", + "grok-4.20-multi-agent", + "grok-4.3", + "grok-4.5", + "grok-4.6", +] as const; /** * Grok SKUs that expose the wire `reasoning.effort` dial. Other Grok reasoners @@ -123,6 +129,27 @@ export const isGrokReasoningEffortCapable = memo((modelId: string): boolean => { return GROK_EFFORT_CAPABLE_PREFIXES.some(prefix => bare.startsWith(prefix)); }); +/** + * `grok-4.20-multi-agent*` uses `reasoning.effort` to pick agent count + * (`xhigh` is the 16-agent mode). Other first-party Grok effort SKUs stay on + * `low|medium|high` unless {@link isGrokXHighEffortCapable} (currently + * `grok-4.6*` plus multi-agent). + * https://docs.x.ai/developers/model-capabilities/text/reasoning + */ +export const isGrokMultiAgentModelId = memo((modelId: string): boolean => { + return bareModelId(modelId).trim().toLowerCase().startsWith("grok-4.20-multi-agent"); +}); + +/** + * First-party Grok SKUs whose Responses wire accepts `reasoning.effort: "xhigh"`. + * `grok-4.6*` documents xhigh as a reasoning depth; multi-agent uses it as + * 16-agent mode. `grok-4.5` / `grok-4.3` / `grok-3-mini` do not. + */ +export const isGrokXHighEffortCapable = memo((modelId: string): boolean => { + if (isGrokMultiAgentModelId(modelId)) return true; + return bareModelId(modelId).trim().toLowerCase().startsWith("grok-4.6"); +}); + /** * MiniMax M2-generation family (M2, M2.1, M2.5, M2.7, including `-highspeed`/ * `-lightning`/`-her`/`-turbo` variants, dotless aliases like `minimax-m21`, @@ -251,6 +278,25 @@ export const isGlm52ReasoningEffortModelId = memo((modelId: string): boolean => return semverGte(glm.version, "5.2"); }); +/** + * GLM-5.3+ coding SKUs. Unlike GLM-5.2 (whose reasoning_effort dialect is + * host-specific), GLM-5.3+ exposes a uniform wire-exact `low`/`high`/`max` + * ladder on every host, and thinking can no longer be disabled — + * `thinking.type` must always be `enabled`. Matching the family keeps future + * bumps (`glm-5.4`, `glm-6`, …) covered while excluding the vision (`…v`) + * shape and the non-reasoning `-flash`/`-flashx`/`-preview` variants. + */ +export const isGlm53ReasoningEffortModelId = memo((modelId: string): boolean => { + const glm = parseGlmModel(bareModelId(modelId)); + if (!glm || glm.vision) { + return false; + } + if (glm.variant !== "base" && glm.variant !== "air" && glm.variant !== "turbo") { + return false; + } + return semverGte(glm.version, "5.3"); +}); + /** GLM vision SKUs — the `v` that attaches to the version (`glm-4v`, `glm-4.5v`). */ export const isGlmVisionModelId = memo((modelId: string): boolean => { return parseGlmModel(bareModelId(modelId))?.vision === true; diff --git a/packages/catalog/src/model-thinking.ts b/packages/catalog/src/model-thinking.ts index be0ba1151..6d0c40428 100644 --- a/packages/catalog/src/model-thinking.ts +++ b/packages/catalog/src/model-thinking.ts @@ -26,6 +26,8 @@ import { isDeepseekModelIdOrName, isDeepseekV4FlashModelId, isGlm52ReasoningEffortModelId, + isGlm53ReasoningEffortModelId, + isGrokXHighEffortCapable, isKimiK3ModelId, isMimoModelIdOrName, isMinimaxM2FamilyModelId, @@ -178,7 +180,8 @@ function fillThinkingWireDefaults( (spec.api === "anthropic-messages" || spec.api === "bedrock-converse-stream") && supportsAdaptiveThinkingDisplay(spec.id); const needsRequiresEffort = thinking.requiresEffort === undefined && impliesMandatoryReasoning(parsed, spec.id); - const needsDefaultLevel = thinking.defaultLevel === undefined && isKimiK3ModelId(spec.id); + const needsDefaultLevel = + thinking.defaultLevel === undefined && (isKimiK3ModelId(spec.id) || isGlm53ReasoningEffortModelId(spec.id)); if (!effortsChanged && !shouldReplaceEffortMap && !needsDisplay && !needsRequiresEffort && !needsDefaultLevel) { return thinking; } @@ -216,7 +219,7 @@ export function deriveThinking(spec: ModelSpec, compat: mode: inferThinkingControlMode(spec, parsed), efforts, }; - if (isKimiK3ModelId(spec.id)) { + if (isKimiK3ModelId(spec.id) || isGlm53ReasoningEffortModelId(spec.id)) { config.defaultLevel = Effort.Max; } const effortMap = inferEffortMap(spec, compat, config.mode, config.efforts); @@ -312,6 +315,13 @@ function getModelDefinedEfforts( spec: ModelSpec, compat: CompatOf, ): readonly Effort[] | undefined { + if (isGlm53ReasoningEffortModelId(spec.id)) { + // GLM-5.3+ exposes a uniform wire-exact low/high/max ladder on every + // host — unlike GLM-5.2, whose reasoning_effort dialect is + // host-specific. Thinking can no longer be disabled (handled by + // impliesMandatoryReasoning), and the default effort is `max`. + return LOW_HIGH_MAX_REASONING_EFFORTS; + } if (isGlm52ReasoningEffortModelId(spec.id)) { // GLM-5.2's reasoning_effort dialect is host-specific (verified against // live endpoints): @@ -395,6 +405,11 @@ function getModelDefinedEfforts( // Baseten's gpt-oss router mirrors its GLM route: high/max only. return HIGH_MAX_REASONING_EFFORTS; } + // First-party Grok: `grok-4.6*` and `grok-4.20-multi-agent*` advertise + // `xhigh`. Other effort-capable SKUs stay on `minimal/low/medium/high`. + if (modelMatchesHost({ provider: spec.provider, baseUrl: spec.baseUrl ?? "" }, "xai")) { + return isGrokXHighEffortCapable(spec.id) ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH : DEFAULT_REASONING_EFFORTS; + } return isOpenAICompatReasoningApi(spec.api) && (isMinimaxM2FamilyModelId(spec.id) || isOpenAIGptOssModelId(spec.id) || @@ -579,6 +594,9 @@ function impliesMandatoryReasoning(parsed: ParsedModel, modelId: string): boolea if (parsed.kind === "pro" && semverGte(parsed.version, "2.5")) return true; } if (isKimiK3ModelId(modelId)) return true; + // GLM-5.3+ no longer supports disabling thinking — thinking.type must + // always be "enabled". Floor thinking-off requests to the lowest effort. + if (isGlm53ReasoningEffortModelId(modelId)) return true; if (isMinimaxM2FamilyModelId(modelId)) return true; if (OPENAI_O_SERIES_RE.test(bareModelId(modelId))) return true; return findThinkingVariantToken(modelId) !== undefined; diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index 5dc7d1f9b..e7215b13a 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -2901,7 +2901,7 @@ }, "gemini-2.5-flash-lite": { "id": "gemini-2.5-flash-lite", - "name": "Gemini 2.5 Flash-Lite", + "name": "Gemini 2.5 Flash Lite", "api": "openai-completions", "provider": "aimlapi", "baseUrl": "https://api.aimlapi.com/v1", @@ -9533,6 +9533,96 @@ "supportsDisplay": true } }, + "global.openai.gpt-5.6-luna": { + "id": "global.openai.gpt-5.6-luna", + "name": "GPT-5.6 Luna (Global)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.22, + "output": 1.32, + "cacheRead": 0.022, + "cacheWrite": 0.275 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + }, + "global.openai.gpt-5.6-sol": { + "id": "global.openai.gpt-5.6-sol", + "name": "GPT-5.6 Sol (Global)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5.5, + "output": 33, + "cacheRead": 0.55, + "cacheWrite": 6.875 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + }, + "global.openai.gpt-5.6-terra": { + "id": "global.openai.gpt-5.6-terra", + "name": "GPT-5.6 Terra (Global)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 2.2, + "output": 13.2, + "cacheRead": 0.22, + "cacheWrite": 2.75 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + }, "google.gemma-3-27b-it": { "id": "google.gemma-3-27b-it", "name": "Google Gemma 3 27B Instruct", @@ -13656,7 +13746,7 @@ "api": "openai-completions", "provider": "baseten", "baseUrl": "https://inference.baseten.co/v1", - "reasoning": false, + "reasoning": true, "input": [ "text", "image" @@ -13670,7 +13760,20 @@ "contextWindow": 1048576, "maxTokens": 262144, "supportsComputerUse": false, - "supportsComputerUseConfig": false + "supportsComputerUseConfig": false, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ], + "defaultLevel": "max", + "effortMap": { + "max": "max" + }, + "requiresEffort": true + } }, "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": { "id": "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B", @@ -14209,7 +14312,7 @@ }, "anthropic/claude-3-haiku": { "id": "anthropic/claude-3-haiku", - "name": "Claude Haiku 3", + "name": "Claude 3 Haiku", "api": "anthropic-messages", "provider": "cloudflare-ai-gateway", "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", @@ -14627,7 +14730,7 @@ "cacheRead": 0.3, "cacheWrite": 3.75 }, - "contextWindow": 200000, + "contextWindow": 1000000, "maxTokens": 64000, "thinking": { "mode": "budget", @@ -14658,7 +14761,7 @@ "cacheWrite": 3.75 }, "contextWindow": 1000000, - "maxTokens": 64000, + "maxTokens": 128000, "thinking": { "mode": "anthropic-adaptive", "efforts": [ @@ -14798,6 +14901,66 @@ "contextWindow": 128000, "maxTokens": 4096 }, + "openai/gpt-4.1": { + "id": "openai/gpt-4.1", + "name": "GPT-4.1", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 2, + "output": 8, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "contextWindow": 1047576, + "maxTokens": 32768 + }, + "openai/gpt-4.1-mini": { + "id": "openai/gpt-4.1-mini", + "name": "GPT-4.1 mini", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 1.6, + "cacheRead": 0.1, + "cacheWrite": 0 + }, + "contextWindow": 1047576, + "maxTokens": 32768 + }, + "openai/gpt-4.1-nano": { + "id": "openai/gpt-4.1-nano", + "name": "GPT-4.1 nano", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.4, + "cacheRead": 0.025, + "cacheWrite": 0 + }, + "contextWindow": 1047576, + "maxTokens": 32768 + }, "openai/gpt-4o": { "id": "openai/gpt-4o", "name": "GPT-4o", @@ -14832,12 +14995,128 @@ "cost": { "input": 0.15, "output": 0.6, - "cacheRead": 0.08, + "cacheRead": 0.075, "cacheWrite": 0 }, "contextWindow": 128000, "maxTokens": 16384 }, + "openai/gpt-5": { + "id": "openai/gpt-5", + "name": "GPT-5", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1.25, + "output": 10, + "cacheRead": 0.125, + "cacheWrite": 0 + }, + "contextWindow": 400000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "openai/gpt-5-mini": { + "id": "openai/gpt-5-mini", + "name": "GPT-5 Mini", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.25, + "output": 2, + "cacheRead": 0.025, + "cacheWrite": 0 + }, + "contextWindow": 400000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "openai/gpt-5-nano": { + "id": "openai/gpt-5-nano", + "name": "GPT-5 Nano", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.05, + "output": 0.4, + "cacheRead": 0.005, + "cacheWrite": 0 + }, + "contextWindow": 400000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "openai/gpt-5-pro": { + "id": "openai/gpt-5-pro", + "name": "GPT-5 Pro", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 15, + "output": 120, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 400000, + "maxTokens": 272000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "openai/gpt-5.1": { "id": "openai/gpt-5.1", "name": "GPT-5.1", @@ -14852,7 +15131,7 @@ "cost": { "input": 1.25, "output": 10, - "cacheRead": 0.13, + "cacheRead": 0.125, "cacheWrite": 0 }, "contextWindow": 400000, @@ -14925,6 +15204,35 @@ ] } }, + "openai/gpt-5.2-chat-latest": { + "id": "openai/gpt-5.2-chat-latest", + "name": "GPT-5.2 Chat", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1.75, + "output": 14, + "cacheRead": 0.175, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 16384, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, "openai/gpt-5.2-codex": { "id": "openai/gpt-5.2-codex", "name": "GPT-5.2 Codex", @@ -14954,6 +15262,55 @@ ] } }, + "openai/gpt-5.2-pro": { + "id": "openai/gpt-5.2-pro", + "name": "GPT-5.2 Pro", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 21, + "output": 168, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 400000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "openai/gpt-5.3-chat-latest": { + "id": "openai/gpt-5.3-chat-latest", + "name": "GPT-5.3 Chat", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1.75, + "output": 14, + "cacheRead": 0.175, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 16384 + }, "openai/gpt-5.3-codex": { "id": "openai/gpt-5.3-codex", "name": "GPT-5.3 Codex", @@ -14983,6 +15340,36 @@ ] } }, + "openai/gpt-5.3-codex-spark": { + "id": "openai/gpt-5.3-codex-spark", + "name": "GPT-5.3 Codex Spark", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1.75, + "output": 14, + "cacheRead": 0.175, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 32000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "contextPromotionTarget": "cloudflare-ai-gateway/openai/gpt-5.5" + }, "openai/gpt-5.4": { "id": "openai/gpt-5.4", "name": "GPT-5.4", @@ -15012,6 +15399,93 @@ ] } }, + "openai/gpt-5.4-mini": { + "id": "openai/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.75, + "output": 4.5, + "cacheRead": 0.075, + "cacheWrite": 0 + }, + "contextWindow": 400000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "openai/gpt-5.4-nano": { + "id": "openai/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.2, + "output": 1.25, + "cacheRead": 0.02, + "cacheWrite": 0 + }, + "contextWindow": 400000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "openai/gpt-5.4-pro": { + "id": "openai/gpt-5.4-pro", + "name": "GPT-5.4 Pro", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 30, + "output": 180, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, "openai/gpt-5.5": { "id": "openai/gpt-5.5", "name": "GPT-5.5", @@ -15042,6 +15516,66 @@ }, "contextPromotionTarget": "cloudflare-ai-gateway/openai/gpt-5.4" }, + "openai/gpt-5.5-pro": { + "id": "openai/gpt-5.5-pro", + "name": "GPT-5.5 Pro", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 30, + "output": 180, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "contextPromotionTarget": "cloudflare-ai-gateway/openai/gpt-5.4" + }, + "openai/gpt-5.6": { + "id": "openai/gpt-5.6", + "name": "GPT-5.6", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 30, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + }, "openai/gpt-5.6-luna": { "id": "openai/gpt-5.6-luna", "name": "GPT-5.6 Luna", @@ -15054,10 +15588,10 @@ "image" ], "cost": { - "input": 1, - "output": 6, - "cacheRead": 0.1, - "cacheWrite": 0 + "input": 0.2, + "output": 1.2, + "cacheRead": 0.02, + "cacheWrite": 0.25 }, "contextWindow": 1050000, "maxTokens": 128000, @@ -15087,7 +15621,7 @@ "input": 5, "output": 30, "cacheRead": 0.5, - "cacheWrite": 0 + "cacheWrite": 6.25 }, "contextWindow": 1050000, "maxTokens": 128000, @@ -15114,10 +15648,10 @@ "image" ], "cost": { - "input": 2.5, - "output": 15, - "cacheRead": 0.25, - "cacheWrite": 0 + "input": 2, + "output": 12, + "cacheRead": 0.2, + "cacheWrite": 2.5 }, "contextWindow": 1050000, "maxTokens": 128000, @@ -15163,6 +15697,37 @@ "requiresEffort": true } }, + "openai/o1-pro": { + "id": "openai/o1-pro", + "name": "o1-pro", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 150, + "output": 600, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 100000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "requiresEffort": true + } + }, "openai/o3": { "id": "openai/o3", "name": "o3", @@ -15269,7 +15834,7 @@ "cost": { "input": 1.1, "output": 4.4, - "cacheRead": 0.28, + "cacheRead": 0.275, "cacheWrite": 0 }, "contextWindow": 200000, @@ -15286,6 +15851,113 @@ "requiresEffort": true } }, + "workers-ai/@cf/google/gemma-4-26b-a4b-it": { + "id": "workers-ai/@cf/google/gemma-4-26b-a4b-it", + "name": "Gemma 4 26B A4B IT", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.3, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "workers-ai/@cf/ibm-granite/granite-4.0-h-micro": { + "id": "workers-ai/@cf/ibm-granite/granite-4.0-h-micro", + "name": "Granite 4.0 H Micro", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.017, + "output": 0.112, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131000, + "maxTokens": 131000 + }, + "workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast": { + "id": "workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast", + "name": "Llama 3.3 70B Instruct fp8 Fast", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.293, + "output": 2.253, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 24000, + "maxTokens": 24000 + }, + "workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct": { + "id": "workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct", + "name": "Llama 4 Scout 17B 16E Instruct", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.27, + "output": 0.85, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131000, + "maxTokens": 16384 + }, + "workers-ai/@cf/mistralai/mistral-small-3.1-24b-instruct": { + "id": "workers-ai/@cf/mistralai/mistral-small-3.1-24b-instruct", + "name": "Mistral Small 3.1 24B Instruct", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.351, + "output": 0.555, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 128000 + }, "workers-ai/@cf/moonshotai/kimi-k2.5": { "id": "workers-ai/@cf/moonshotai/kimi-k2.5", "name": "Kimi K2.5", @@ -15333,7 +16005,7 @@ "cacheRead": 0.16, "cacheWrite": 0 }, - "contextWindow": 256000, + "contextWindow": 262144, "maxTokens": 256000, "thinking": { "mode": "budget", @@ -15346,6 +16018,36 @@ ] } }, + "workers-ai/@cf/moonshotai/kimi-k2.7-code": { + "id": "workers-ai/@cf/moonshotai/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.95, + "output": 4, + "cacheRead": 0.19, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "workers-ai/@cf/nvidia/nemotron-3-120b-a12b": { "id": "workers-ai/@cf/nvidia/nemotron-3-120b-a12b", "name": "Nemotron 3 Super 120B", @@ -15375,6 +16077,93 @@ ] } }, + "workers-ai/@cf/openai/gpt-oss-120b": { + "id": "workers-ai/@cf/openai/gpt-oss-120b", + "name": "GPT OSS 120B", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.35, + "output": 0.75, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 16384, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "workers-ai/@cf/openai/gpt-oss-20b": { + "id": "workers-ai/@cf/openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.2, + "output": 0.3, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 16384, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "workers-ai/@cf/qwen/qwen3-30b-a3b-fp8": { + "id": "workers-ai/@cf/qwen/qwen3-30b-a3b-fp8", + "name": "Qwen3 30B A3b fp8", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.0509, + "output": 0.335, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 32768, + "maxTokens": 32768, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "workers-ai/@cf/zai-org/glm-4.7-flash": { "id": "workers-ai/@cf/zai-org/glm-4.7-flash", "name": "GLM-4.7-Flash", @@ -15386,7 +16175,7 @@ "text" ], "cost": { - "input": 0.06, + "input": 0.0605, "output": 0.4, "cacheRead": 0, "cacheWrite": 0 @@ -15421,7 +16210,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 256000, "thinking": { "mode": "budget", "efforts": [ @@ -16323,7 +17112,7 @@ "cursor": { "claude-4-sonnet": { "id": "claude-4-sonnet", - "name": "Sonnet 4", + "name": "Claude Sonnet 4", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16362,7 +17151,7 @@ }, "claude-4.5-opus-high": { "id": "claude-4.5-opus-high", - "name": "Opus 4.5", + "name": "Claude Opus 4.5", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16401,7 +17190,7 @@ }, "claude-4.5-sonnet": { "id": "claude-4.5-sonnet", - "name": "Sonnet 4.5", + "name": "Claude Sonnet 4.5", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16440,7 +17229,7 @@ }, "claude-4.6-opus-high": { "id": "claude-4.6-opus-high", - "name": "Opus 4.6 1M", + "name": "Claude Opus 4.6 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16478,7 +17267,7 @@ }, "claude-4.6-opus-max": { "id": "claude-4.6-opus-max", - "name": "Opus 4.6 1M Max", + "name": "Claude Opus 4.6 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16517,7 +17306,7 @@ }, "claude-4.6-sonnet-medium": { "id": "claude-4.6-sonnet-medium", - "name": "Sonnet 4.6 1M", + "name": "Claude Sonnet 4.6 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16555,7 +17344,7 @@ }, "claude-fable-5-high": { "id": "claude-fable-5-high", - "name": "Fable 5 1M (NO ZDR)", + "name": "Claude Fable 5 1M (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16594,7 +17383,7 @@ }, "claude-fable-5-low": { "id": "claude-fable-5-low", - "name": "Fable 5 1M Low (NO ZDR)", + "name": "Claude Fable 5 1M Low (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16633,7 +17422,7 @@ }, "claude-fable-5-max": { "id": "claude-fable-5-max", - "name": "Fable 5 1M Max (NO ZDR)", + "name": "Claude Fable 5 1M Max (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16672,7 +17461,7 @@ }, "claude-fable-5-medium": { "id": "claude-fable-5-medium", - "name": "Fable 5 1M Medium (NO ZDR)", + "name": "Claude Fable 5 1M Medium (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16711,7 +17500,7 @@ }, "claude-fable-5-xhigh": { "id": "claude-fable-5-xhigh", - "name": "Fable 5 1M Extra High (NO ZDR)", + "name": "Claude Fable 5 1M Extra High (NO ZDR)", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16750,7 +17539,7 @@ }, "claude-opus-4-7-high": { "id": "claude-opus-4-7-high", - "name": "Opus 4.7 1M High", + "name": "Claude Opus 4.7 1M High", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16789,7 +17578,7 @@ }, "claude-opus-4-7-high-fast": { "id": "claude-opus-4-7-high-fast", - "name": "Opus 4.7 1M High Fast", + "name": "Claude Opus 4.7 1M High Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16828,7 +17617,7 @@ }, "claude-opus-4-7-low": { "id": "claude-opus-4-7-low", - "name": "Opus 4.7 1M Low", + "name": "Claude Opus 4.7 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16867,7 +17656,7 @@ }, "claude-opus-4-7-low-fast": { "id": "claude-opus-4-7-low-fast", - "name": "Opus 4.7 1M Low Fast", + "name": "Claude Opus 4.7 1M Low Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16906,7 +17695,7 @@ }, "claude-opus-4-7-max": { "id": "claude-opus-4-7-max", - "name": "Opus 4.7 1M Max", + "name": "Claude Opus 4.7 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16945,7 +17734,7 @@ }, "claude-opus-4-7-max-fast": { "id": "claude-opus-4-7-max-fast", - "name": "Opus 4.7 1M Max Fast", + "name": "Claude Opus 4.7 1M Max Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -16984,7 +17773,7 @@ }, "claude-opus-4-7-medium": { "id": "claude-opus-4-7-medium", - "name": "Opus 4.7 1M Medium", + "name": "Claude Opus 4.7 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17023,7 +17812,7 @@ }, "claude-opus-4-7-medium-fast": { "id": "claude-opus-4-7-medium-fast", - "name": "Opus 4.7 1M Medium Fast", + "name": "Claude Opus 4.7 1M Medium Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17062,7 +17851,7 @@ }, "claude-opus-4-7-xhigh": { "id": "claude-opus-4-7-xhigh", - "name": "Opus 4.7 1M", + "name": "Claude Opus 4.7 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17101,7 +17890,7 @@ }, "claude-opus-4-7-xhigh-fast": { "id": "claude-opus-4-7-xhigh-fast", - "name": "Opus 4.7 1M Fast", + "name": "Claude Opus 4.7 1M Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17140,7 +17929,7 @@ }, "claude-opus-4-8-high": { "id": "claude-opus-4-8-high", - "name": "Opus 4.8 1M", + "name": "Claude Opus 4.8 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17179,7 +17968,7 @@ }, "claude-opus-4-8-high-fast": { "id": "claude-opus-4-8-high-fast", - "name": "Opus 4.8 1M Fast", + "name": "Claude Opus 4.8 1M Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17218,7 +18007,7 @@ }, "claude-opus-4-8-low": { "id": "claude-opus-4-8-low", - "name": "Opus 4.8 1M Low", + "name": "Claude Opus 4.8 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17257,7 +18046,7 @@ }, "claude-opus-4-8-low-fast": { "id": "claude-opus-4-8-low-fast", - "name": "Opus 4.8 1M Low Fast", + "name": "Claude Opus 4.8 1M Low Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17296,7 +18085,7 @@ }, "claude-opus-4-8-max": { "id": "claude-opus-4-8-max", - "name": "Opus 4.8 1M Max", + "name": "Claude Opus 4.8 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17335,7 +18124,7 @@ }, "claude-opus-4-8-max-fast": { "id": "claude-opus-4-8-max-fast", - "name": "Opus 4.8 1M Max Fast", + "name": "Claude Opus 4.8 1M Max Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17374,7 +18163,7 @@ }, "claude-opus-4-8-medium": { "id": "claude-opus-4-8-medium", - "name": "Opus 4.8 1M Medium", + "name": "Claude Opus 4.8 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17413,7 +18202,7 @@ }, "claude-opus-4-8-medium-fast": { "id": "claude-opus-4-8-medium-fast", - "name": "Opus 4.8 1M Medium Fast", + "name": "Claude Opus 4.8 1M Medium Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17452,7 +18241,7 @@ }, "claude-opus-4-8-xhigh": { "id": "claude-opus-4-8-xhigh", - "name": "Opus 4.8 1M Extra High", + "name": "Claude Opus 4.8 1M Extra High", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17491,7 +18280,7 @@ }, "claude-opus-4-8-xhigh-fast": { "id": "claude-opus-4-8-xhigh-fast", - "name": "Opus 4.8 1M Extra High Fast", + "name": "Claude Opus 4.8 1M Extra High Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17530,7 +18319,7 @@ }, "claude-opus-5-high": { "id": "claude-opus-5-high", - "name": "Opus 5 1M", + "name": "Claude Opus 5 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17569,7 +18358,7 @@ }, "claude-opus-5-high-fast": { "id": "claude-opus-5-high-fast", - "name": "Opus 5 1M Fast", + "name": "Claude Opus 5 1M Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17608,7 +18397,7 @@ }, "claude-opus-5-low": { "id": "claude-opus-5-low", - "name": "Opus 5 1M Low", + "name": "Claude Opus 5 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17647,7 +18436,7 @@ }, "claude-opus-5-low-fast": { "id": "claude-opus-5-low-fast", - "name": "Opus 5 1M Low Fast", + "name": "Claude Opus 5 1M Low Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17686,7 +18475,7 @@ }, "claude-opus-5-medium": { "id": "claude-opus-5-medium", - "name": "Opus 5 1M Medium", + "name": "Claude Opus 5 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17725,7 +18514,7 @@ }, "claude-opus-5-medium-fast": { "id": "claude-opus-5-medium-fast", - "name": "Opus 5 1M Medium Fast", + "name": "Claude Opus 5 1M Medium Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17764,7 +18553,7 @@ }, "claude-opus-5-thinking-max": { "id": "claude-opus-5-thinking-max", - "name": "Opus 5 1M Max Thinking", + "name": "Claude Opus 5 1M Max Thinking", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17787,7 +18576,7 @@ }, "claude-opus-5-thinking-max-fast": { "id": "claude-opus-5-thinking-max-fast", - "name": "Opus 5 1M Max Thinking Fast", + "name": "Claude Opus 5 1M Max Thinking Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17810,7 +18599,7 @@ }, "claude-opus-5-thinking-xhigh": { "id": "claude-opus-5-thinking-xhigh", - "name": "Opus 5 1M Extra High Thinking", + "name": "Claude Opus 5 1M Extra High Thinking", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17833,7 +18622,7 @@ }, "claude-opus-5-thinking-xhigh-fast": { "id": "claude-opus-5-thinking-xhigh-fast", - "name": "Opus 5 1M Extra High Thinking Fast", + "name": "Claude Opus 5 1M Extra High Thinking Fast", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17856,7 +18645,7 @@ }, "claude-sonnet-5-high": { "id": "claude-sonnet-5-high", - "name": "Sonnet 5 1M", + "name": "Claude Sonnet 5 1M", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17895,7 +18684,7 @@ }, "claude-sonnet-5-low": { "id": "claude-sonnet-5-low", - "name": "Sonnet 5 1M Low", + "name": "Claude Sonnet 5 1M Low", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17934,7 +18723,7 @@ }, "claude-sonnet-5-max": { "id": "claude-sonnet-5-max", - "name": "Sonnet 5 1M Max", + "name": "Claude Sonnet 5 1M Max", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -17973,7 +18762,7 @@ }, "claude-sonnet-5-medium": { "id": "claude-sonnet-5-medium", - "name": "Sonnet 5 1M Medium", + "name": "Claude Sonnet 5 1M Medium", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -18012,7 +18801,7 @@ }, "claude-sonnet-5-xhigh": { "id": "claude-sonnet-5-xhigh", - "name": "Sonnet 5 1M Extra High", + "name": "Claude Sonnet 5 1M Extra High", "api": "cursor-agent", "provider": "cursor", "baseUrl": "https://api2.cursor.sh", @@ -21396,6 +22185,7 @@ ] }, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "supportsToolChoice": false, "requiresAssistantContentForToolCalls": true @@ -21461,6 +22251,34 @@ "requiresAssistantContentForToolCalls": true } }, + "deepseek-v4-pro-0813": { + "id": "deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "api": "openai-completions", + "provider": "fireworks", + "baseUrl": "https://api.fireworks.ai/inference/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.73, + "output": 4.95, + "cacheRead": 0.33, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + }, + "supportsComputerUse": false + }, "flux-1-schnell-fp8": { "id": "flux-1-schnell-fp8", "name": "FLUX.1 [schnell] FP8", @@ -22294,6 +23112,40 @@ }, "supportsComputerUse": false }, + "qwen3.8-2.4t-a95b": { + "id": "qwen3.8-2.4t-a95b", + "name": "Qwen3.8-2.4T-A95B", + "api": "openai-completions", + "provider": "fireworks", + "baseUrl": "https://api.fireworks.ai/inference/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "effortMap": { + "minimal": "none" + } + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false + }, "qwen3.8-max": { "id": "qwen3.8-max", "name": "Qwen3.8 Max", @@ -22311,7 +23163,7 @@ "cacheRead": 0.25, "cacheWrite": 2.5 }, - "contextWindow": 262144, + "contextWindow": 1000000, "maxTokens": 131072, "supportsTools": false, "thinking": { @@ -22937,6 +23789,45 @@ "requiresEffort": true } }, + "gemini-3.7-flash": { + "id": "gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "api": "openai-completions", + "provider": "github-copilot", + "baseUrl": "https://api.githubcopilot.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 64000, + "headers": { + "User-Agent": "opencode/1.3.15", + "X-GitHub-Api-Version": "2026-06-01" + }, + "compat": { + "supportsStore": false, + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + } + }, "gpt-4.1": { "id": "gpt-4.1", "name": "GPT-4.1", @@ -23554,6 +24445,44 @@ ] } }, + "grok-4.6": { + "id": "grok-4.6", + "name": "Grok 4.6", + "api": "openai-completions", + "provider": "github-copilot", + "baseUrl": "https://api.githubcopilot.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 2, + "output": 6, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "contextWindow": 500000, + "maxTokens": 128000, + "headers": { + "User-Agent": "opencode/1.3.15", + "X-GitHub-Api-Version": "2026-06-01" + }, + "compat": { + "supportsStore": false, + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "grok-code-fast-1": { "id": "grok-code-fast-1", "name": "Grok Code Fast 1", @@ -25685,7 +26614,7 @@ }, "gemini-2.5-flash-lite": { "id": "gemini-2.5-flash-lite", - "name": "Gemini 2.5 Flash-Lite", + "name": "Gemini 2.5 Flash Lite", "api": "google-gemini-cli", "provider": "google-antigravity", "baseUrl": "https://daily-cloudcode-pa.googleapis.com", @@ -26025,6 +26954,36 @@ } } }, + "gemini-3.7-flash-tiered": { + "id": "gemini-3.7-flash-tiered", + "name": "gemini-3.7-flash-tiered", + "api": "google-gemini-cli", + "provider": "google-antigravity", + "baseUrl": "https://daily-cloudcode-pa.googleapis.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + } + }, "gpt-oss-120b": { "id": "gpt-oss-120b", "name": "GPT OSS 120B", @@ -27387,11 +28346,11 @@ }, "qwen/qwen3-32b": { "id": "qwen/qwen3-32b", - "name": "Qwen3 32B", + "name": "Qwen 3 32b", "api": "openai-completions", "provider": "groq", "baseUrl": "https://api.groq.com/openai/v1", - "reasoning": true, + "reasoning": false, "input": [ "text" ], @@ -27402,22 +28361,7 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 40960, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ], - "effortMap": { - "minimal": "default", - "low": "default", - "medium": "default", - "high": "default" - } - } + "maxTokens": 40960 }, "qwen/qwen3.6-27b": { "id": "qwen/qwen3.6-27b", @@ -27521,6 +28465,25 @@ "contextWindow": 64000, "maxTokens": 8192 }, + "deepseek-ai/DeepSeek-V3-0324": { + "id": "deepseek-ai/DeepSeek-V3-0324", + "name": "DeepSeek V3 0324", + "api": "openai-completions", + "provider": "huggingface", + "baseUrl": "https://router.huggingface.co/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.27, + "output": 1.12, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 163840, + "maxTokens": 163840 + }, "deepseek-ai/DeepSeek-V3.1": { "id": "deepseek-ai/DeepSeek-V3.1", "name": "DeepSeek-V3.1", @@ -27654,6 +28617,33 @@ ] } }, + "deepseek-ai/DeepSeek-V4-Pro-0813": { + "id": "deepseek-ai/DeepSeek-V4-Pro-0813", + "name": "DeepSeek V4 Pro 0813", + "api": "openai-completions", + "provider": "huggingface", + "baseUrl": "https://router.huggingface.co/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 384000, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, "google/gemma-4-26B-A4B-it": { "id": "google/gemma-4-26B-A4B-it", "name": "Gemma 4 26B A4B IT", @@ -27714,6 +28704,25 @@ ] } }, + "meta-llama/Llama-3.1-8B-Instruct": { + "id": "meta-llama/Llama-3.1-8B-Instruct", + "name": "Llama-3.1-8B-Instruct", + "api": "openai-completions", + "provider": "huggingface", + "baseUrl": "https://router.huggingface.co/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.06, + "output": 0.06, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131072, + "maxTokens": 4096 + }, "meta-llama/Llama-3.3-70B-Instruct": { "id": "meta-llama/Llama-3.3-70B-Instruct", "name": "Llama-3.3-70B-Instruct", @@ -28139,6 +29148,25 @@ ] } }, + "Qwen/Qwen2.5-Coder-32B-Instruct": { + "id": "Qwen/Qwen2.5-Coder-32B-Instruct", + "name": "Qwen2.5-Coder-32B-Instruct", + "api": "openai-completions", + "provider": "huggingface", + "baseUrl": "https://router.huggingface.co/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.06, + "output": 0.2, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131072, + "maxTokens": 8192 + }, "Qwen/Qwen3-235B-A22B": { "id": "Qwen/Qwen3-235B-A22B", "name": "Qwen3 235B-A22B", @@ -28215,6 +29243,34 @@ "requiresEffort": true } }, + "Qwen/Qwen3-30B-A3B": { + "id": "Qwen/Qwen3-30B-A3B", + "name": "Qwen3 30B A3B", + "api": "openai-completions", + "provider": "huggingface", + "baseUrl": "https://router.huggingface.co/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.12, + "output": 0.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 40960, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "Qwen/Qwen3-32B": { "id": "Qwen/Qwen3-32B", "name": "Qwen3 32B", @@ -28541,6 +29597,34 @@ ] } }, + "Qwen/Qwen3.8-2.4T-A95B": { + "id": "Qwen/Qwen3.8-2.4T-A95B", + "name": "Qwen3.8 2.4T A95B", + "api": "openai-completions", + "provider": "huggingface", + "baseUrl": "https://router.huggingface.co/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 2.5, + "output": 6.25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "stepfun-ai/Step-3.5-Flash": { "id": "stepfun-ai/Step-3.5-Flash", "name": "Step 3.5 Flash", @@ -29165,9 +30249,9 @@ "text" ], "cost": { - "input": 0.072, - "output": 0.144, - "cacheRead": 0.016, + "input": 0.0603, + "output": 0.1206, + "cacheRead": 0.01206, "cacheWrite": 0 }, "contextWindow": 262144, @@ -29253,9 +30337,9 @@ "image" ], "cost": { - "input": 2.4, - "output": 12, - "cacheRead": 0.24, + "input": 2.6, + "output": 13, + "cacheRead": 0.29, "cacheWrite": 0 }, "contextWindow": 974842, @@ -29511,7 +30595,7 @@ }, "aion-labs/aion-rp-llama-3.1-8b": { "id": "aion-labs/aion-rp-llama-3.1-8b", - "name": "Aion-RP 1.0 (8B)", + "name": "Llama 3.1 8b (uncensored)", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", @@ -29525,8 +30609,8 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, + "contextWindow": 32768, + "maxTokens": 16384, "supportsComputerUse": false }, "alfredpros/codellama-7b-instruct-solidity": { @@ -31012,7 +32096,7 @@ }, "bytedance-seed/seed-2.0-lite": { "id": "bytedance-seed/seed-2.0-lite", - "name": "Seed-2.0-Lite", + "name": "Seed 2.0 Lite", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", @@ -31042,7 +32126,7 @@ }, "bytedance-seed/seed-2.0-mini": { "id": "bytedance-seed/seed-2.0-mini", - "name": "Seed-2.0-Mini", + "name": "Seed 2.0 Mini", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", @@ -31551,8 +32635,8 @@ "cacheRead": 0.028, "cacheWrite": 0 }, - "contextWindow": 1048576, - "maxTokens": 393216, + "contextWindow": 1024000, + "maxTokens": 384000, "thinking": { "mode": "effort", "efforts": [ @@ -31579,7 +32663,7 @@ "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 384000, + "maxTokens": 393216, "thinking": { "mode": "effort", "efforts": [ @@ -31654,7 +32738,7 @@ "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 393216, + "maxTokens": 384000, "thinking": { "mode": "effort", "efforts": [ @@ -31675,9 +32759,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 1.32, + "output": 3.96, + "cacheRead": 0.35, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -31718,6 +32802,36 @@ ] } }, + "dots-studio/dots-3-note-preview:free": { + "id": "dots-studio/dots-3-note-preview:free", + "name": "Dots3-Note Preview (free)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 512000, + "maxTokens": 512000, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "eleutherai/llemma_7b": { "id": "eleutherai/llemma_7b", "name": "Llemma 7b", @@ -32596,7 +33710,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 16384, "thinking": { "mode": "effort", "efforts": [ @@ -33431,7 +34545,7 @@ "cacheWrite": 0 }, "contextWindow": 128000, - "maxTokens": 32768, + "maxTokens": 8192, "thinking": { "mode": "effort", "efforts": [ @@ -33639,7 +34753,7 @@ }, "meta-llama/llama-3.1-8b-instruct": { "id": "meta-llama/llama-3.1-8b-instruct", - "name": "Llama 3.1 8B Instruct", + "name": "Llama-3.1-8B-Instruct", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", @@ -33753,7 +34867,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 128000, + "contextWindow": 1048576, "maxTokens": 16384 }, "meta-llama/llama-4-scout": { @@ -35507,7 +36621,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 228000, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -35703,7 +36817,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -37342,9 +38456,10 @@ "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { "input": 0, @@ -37354,7 +38469,18 @@ }, "contextWindow": 200000, "maxTokens": 100000, - "supportsComputerUse": false + "supportsComputerUse": false, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "requiresEffort": true + } }, "openai/o3": { "id": "openai/o3", @@ -38399,10 +39525,10 @@ "text" ], "cost": { - "input": 0.4, - "output": 1.2, + "input": 0.26, + "output": 0.78, "cacheRead": 0, - "cacheWrite": 0.5 + "cacheWrite": 0 }, "contextWindow": 1000000, "maxTokens": 32768, @@ -38661,8 +39787,8 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 40960, - "maxTokens": 16384, + "contextWindow": 131072, + "maxTokens": 8192, "thinking": { "mode": "effort", "efforts": [ @@ -39056,8 +40182,8 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 16384 + "contextWindow": 131072, + "maxTokens": 32768 }, "qwen/qwen3-vl-30b-a3b-thinking": { "id": "qwen/qwen3-vl-30b-a3b-thinking", @@ -39235,7 +40361,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -39264,7 +40390,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -39408,8 +40534,8 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 262144, + "contextWindow": 131072, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -39432,7 +40558,7 @@ "image" ], "cost": { - "input": 0.15, + "input": 0.14, "output": 1, "cacheRead": 0.05, "cacheWrite": 0 @@ -39698,7 +40824,7 @@ "input": 2, "output": 6, "cacheRead": 0.25, - "cacheWrite": 0 + "cacheWrite": 2.5 }, "contextWindow": 1000000, "maxTokens": 262144, @@ -39712,6 +40838,35 @@ ] } }, + "qwen/qwen3.8-27b": { + "id": "qwen/qwen3.8-27b", + "name": "Qwen3.8 27B", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.45, + "output": 3.2, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "qwen/qwen3.8-max": { "id": "qwen/qwen3.8-max", "name": "Qwen3.8 Max", @@ -41339,7 +42494,7 @@ "cost": { "input": 0.14, "output": 0.28, - "cacheRead": 0.0028, + "cacheRead": 0.003, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -41364,9 +42519,9 @@ "text" ], "cost": { - "input": 0.4, - "output": 1.5, - "cacheRead": 0.08, + "input": 0.435, + "output": 0.87, + "cacheRead": 0.004, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -41500,12 +42655,12 @@ "text" ], "cost": { - "input": 0.5, - "output": 2, - "cacheRead": 0.1, + "input": 0.55, + "output": 2.2, + "cacheRead": 0.11, "cacheWrite": 0 }, - "contextWindow": 202752, + "contextWindow": 204800, "maxTokens": 131072, "thinking": { "mode": "effort", @@ -41638,13 +42793,13 @@ "text" ], "cost": { - "input": 0.95, - "output": 2.55, - "cacheRead": 0.2, + "input": 0.6, + "output": 1.92, + "cacheRead": 0.12, "cacheWrite": 0 }, - "contextWindow": 204800, - "maxTokens": 131072, + "contextWindow": 198000, + "maxTokens": 128000, "thinking": { "mode": "effort", "efforts": [ @@ -41701,8 +42856,8 @@ "cacheRead": 0.26, "cacheWrite": 0 }, - "contextWindow": 202752, - "maxTokens": 131072, + "contextWindow": 200000, + "maxTokens": 128000, "thinking": { "mode": "effort", "efforts": [ @@ -41731,7 +42886,7 @@ "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 131072, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -41743,6 +42898,26 @@ ] } }, + "z-ai/glm-5.2:free": { + "id": "z-ai/glm-5.2:free", + "name": "GLM 5.2 (free)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 131072, + "supportsComputerUse": false + }, "z-ai/glm-5v-turbo": { "id": "z-ai/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -44185,7 +45360,8 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-128k-vision-preview": { "id": "moonshot-v1-128k-vision-preview", @@ -44205,7 +45381,8 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-32k": { "id": "moonshot-v1-32k", @@ -44224,7 +45401,8 @@ "cacheWrite": 0 }, "contextWindow": 32768, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-32k-vision-preview": { "id": "moonshot-v1-32k-vision-preview", @@ -44244,7 +45422,8 @@ "cacheWrite": 0 }, "contextWindow": 32768, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-8k": { "id": "moonshot-v1-8k", @@ -44263,7 +45442,8 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-8k-vision-preview": { "id": "moonshot-v1-8k-vision-preview", @@ -44283,7 +45463,8 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false }, "moonshot-v1-auto": { "id": "moonshot-v1-auto", @@ -44302,7 +45483,8 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": null + "maxTokens": null, + "supportsComputerUse": false } }, "nanogpt": { @@ -44375,30 +45557,18 @@ "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": true, + "reasoning": false, "input": [ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.8, + "output": 1.6, + "cacheRead": 0.2, "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 32768, - "supportsComputerUse": false, - "supportsComputerUseConfig": false, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } + "maxTokens": 32768 }, "aion-labs/aion-2.5": { "id": "aion-labs/aion-2.5", @@ -44423,7 +45593,7 @@ }, "aion-labs/aion-3.0": { "id": "aion-labs/aion-3.0", - "name": "Aion-3.0", + "name": "Aion 3.0", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -44432,15 +45602,13 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 3, + "output": 6, + "cacheRead": 0.75, "cacheWrite": 0 }, "contextWindow": 131072, "maxTokens": 32768, - "supportsComputerUse": false, - "supportsComputerUseConfig": false, "thinking": { "mode": "effort", "efforts": [ @@ -44454,7 +45622,7 @@ }, "aion-labs/aion-3.0-mini": { "id": "aion-labs/aion-3.0-mini", - "name": "Aion-3.0-Mini", + "name": "Aion 3.0 Mini", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -44463,15 +45631,13 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.7, + "output": 1.4, + "cacheRead": 0.18, "cacheWrite": 0 }, "contextWindow": 131072, "maxTokens": 32768, - "supportsComputerUse": false, - "supportsComputerUseConfig": false, "thinking": { "mode": "effort", "efforts": [ @@ -44485,7 +45651,7 @@ }, "aion-labs/aion-rp-llama-3.1-8b": { "id": "aion-labs/aion-rp-llama-3.1-8b", - "name": "aion-labs/aion-rp-llama-3.1-8b", + "name": "Llama 3.1 8b (uncensored)", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -44494,15 +45660,13 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.8, + "output": 1.6, + "cacheRead": 0.4, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 32768, + "maxTokens": 16384 }, "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B": { "id": "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B", @@ -44527,22 +45691,43 @@ }, "alibaba/qwen3.6-27b": { "id": "alibaba/qwen3.6-27b", - "name": "alibaba/qwen3.6-27b", + "name": "Qwen3.6 27B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.203, + "output": 2.24, + "cacheRead": 0.1015, + "cacheWrite": 0 + }, + "contextWindow": 260096, + "maxTokens": 65536 + }, + "alibaba/qwen3.6-27b:thinking": { + "id": "alibaba/qwen3.6-27b:thinking", + "name": "Qwen3.6 27B Thinking", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.203, + "output": 2.24, + "cacheRead": 0.1015, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 262140, + "contextWindow": 260096, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -44551,9 +45736,7 @@ "medium", "high" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "alibaba/qwen3.6-flash": { "id": "alibaba/qwen3.6-flash", @@ -45482,7 +46665,7 @@ }, "auto-model": { "id": "auto-model", - "name": "auto-model", + "name": "Auto model", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -45496,14 +46679,12 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 1000000, + "maxTokens": 1000000 }, "auto-model-basic": { "id": "auto-model-basic", - "name": "auto-model-basic", + "name": "Auto model (Basic)", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -45512,19 +46693,17 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 9.996, + "output": 19.992, + "cacheRead": 4.998, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 1000000, + "maxTokens": 1000000 }, "auto-model-premium": { "id": "auto-model-premium", - "name": "auto-model-premium", + "name": "Auto model (Premium)", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -45533,19 +46712,17 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 9.996, + "output": 19.992, + "cacheRead": 4.998, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 1000000, + "maxTokens": 1000000 }, "auto-model-standard": { "id": "auto-model-standard", - "name": "auto-model-standard", + "name": "Auto model (Standard)", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -45554,15 +46731,13 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 9.996, + "output": 19.992, + "cacheRead": 4.998, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 1000000, + "maxTokens": 1000000 }, "azure-gpt-4-turbo": { "id": "azure-gpt-4-turbo", @@ -45785,7 +46960,7 @@ }, "baseten/Kimi-K2-Instruct-FP4": { "id": "baseten/Kimi-K2-Instruct-FP4", - "name": "baseten/Kimi-K2-Instruct-FP4", + "name": "Kimi K2 0711 Instruct FP4", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -45794,15 +46969,13 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.4, + "output": 1.8, + "cacheRead": 0.2, "cacheWrite": 0 }, - "contextWindow": 131072, - "maxTokens": 16384, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 128000, + "maxTokens": 131072 }, "brave": { "id": "brave", @@ -45929,7 +47102,7 @@ }, "bytedance-seed/seed-2.0-lite": { "id": "bytedance-seed/seed-2.0-lite", - "name": "Seed-2.0-Lite", + "name": "Seed 2.0 Lite", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -47288,7 +48461,7 @@ }, "deepseek-ai/DeepSeek-R1-0528": { "id": "deepseek-ai/DeepSeek-R1-0528", - "name": "DeepSeek-R1-0528", + "name": "DeepSeek R1 0528", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -47297,22 +48470,20 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.4, + "output": 1.7, + "cacheRead": 0.2, "cacheWrite": 0 }, - "contextWindow": 163840, - "maxTokens": 32768, + "contextWindow": 128000, + "maxTokens": 163840, "thinking": { "mode": "effort", "efforts": [ "high", "max" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "deepseek-ai/DeepSeek-V3.1": { "id": "deepseek-ai/DeepSeek-V3.1", @@ -47325,15 +48496,13 @@ "text" ], "cost": { - "input": 0.6, - "output": 1.25, - "cacheRead": 0.6, - "cacheWrite": 0.6 + "input": 0.2, + "output": 0.7, + "cacheRead": 0.1, + "cacheWrite": 0 }, - "contextWindow": 131072, - "maxTokens": 8192, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 128000, + "maxTokens": 65536 }, "deepseek-ai/DeepSeek-V3.1-Terminus": { "id": "deepseek-ai/DeepSeek-V3.1-Terminus", @@ -47373,6 +48542,25 @@ "contextWindow": 128000, "maxTokens": 65536 }, + "deepseek-ai/DeepSeek-V3.1:thinking": { + "id": "deepseek-ai/DeepSeek-V3.1:thinking", + "name": "DeepSeek V3.1 Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.2, + "output": 0.7, + "cacheRead": 0.1, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 65536 + }, "deepseek-ai/deepseek-v3.2-exp": { "id": "deepseek-ai/deepseek-v3.2-exp", "name": "deepseek-ai/deepseek-v3.2-exp", @@ -47475,24 +48663,29 @@ }, "deepseek-r1": { "id": "deepseek-r1", - "name": "deepseek-r1", + "name": "DeepSeek R1", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.4, + "output": 1.7, + "cacheRead": 0.2, "cacheWrite": 0 }, - "contextWindow": 163840, - "maxTokens": 16000, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 128000, + "maxTokens": 8192, + "thinking": { + "mode": "effort", + "efforts": [ + "high", + "max" + ] + } }, "deepseek-r1-sambanova": { "id": "deepseek-r1-sambanova", @@ -47587,9 +48780,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 1.1, + "output": 2.5, + "cacheRead": 0.04, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -47700,9 +48893,9 @@ "text" ], "cost": { - "input": 0.07, - "output": 0.14, - "cacheRead": 0.014, + "input": 0.14, + "output": 0.28, + "cacheRead": 0.0028, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -47768,7 +48961,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-flash-0731-cheaper:thinking": { "id": "deepseek/deepseek-v4-flash-0731-cheaper:thinking", @@ -47795,7 +48989,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-flash-0731:thinking": { "id": "deepseek/deepseek-v4-flash-0731:thinking", @@ -47862,9 +49057,9 @@ "text" ], "cost": { - "input": 0.07, - "output": 0.14, - "cacheRead": 0.014, + "input": 0.14, + "output": 0.28, + "cacheRead": 0.0028, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -47916,9 +49111,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 1.1, + "output": 2.5, + "cacheRead": 0.04, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -47943,9 +49138,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 1.1, + "output": 2.5, + "cacheRead": 0.04, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -47984,7 +49179,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-pro-cheaper:thinking": { "id": "deepseek/deepseek-v4-pro-cheaper:thinking", @@ -48011,7 +49207,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek/deepseek-v4-pro:thinking": { "id": "deepseek/deepseek-v4-pro:thinking", @@ -48103,6 +49300,67 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "dots-studio/dots-3-note-preview": { + "id": "dots-studio/dots-3-note-preview", + "name": "Dots3-Note Preview", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.2, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "contextWindow": 393216, + "maxTokens": 393216, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "dots-studio/dots-3-note-preview:free": { + "id": "dots-studio/dots-3-note-preview:free", + "name": "Dots3-Note Preview (free)", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 393216, + "maxTokens": 393216, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "supportsComputerUse": false + }, "doubao-1-5-thinking-pro-250415": { "id": "doubao-1-5-thinking-pro-250415", "name": "doubao-1-5-thinking-pro-250415", @@ -49008,10 +50266,10 @@ "cost": { "input": 0.3, "output": 2.5, - "cacheRead": 0.075, + "cacheRead": 0.03, "cacheWrite": 0 }, - "contextWindow": 1048576, + "contextWindow": 1048756, "maxTokens": 65536, "thinking": { "mode": "effort", @@ -49021,13 +50279,11 @@ "medium", "high" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "gemini-2.5-flash-lite": { "id": "gemini-2.5-flash-lite", - "name": "Gemini 2.5 Flash-Lite", + "name": "Gemini 2.5 Flash Lite", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -49039,10 +50295,10 @@ "cost": { "input": 0.1, "output": 0.4, - "cacheRead": 0.025, + "cacheRead": 0.01, "cacheWrite": 0 }, - "contextWindow": 1048576, + "contextWindow": 1048756, "maxTokens": 65536, "thinking": { "mode": "effort", @@ -49052,9 +50308,7 @@ "medium", "high" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "gemini-2.5-flash-lite-preview-06-17": { "id": "gemini-2.5-flash-lite-preview-06-17", @@ -49256,14 +50510,11 @@ "cost": { "input": 1.25, "output": 10, - "cacheRead": 0.31, - "cacheWrite": 0 + "cacheRead": 0.125, + "cacheWrite": 0.375 }, - "contextWindow": 1048576, + "contextWindow": 1048756, "maxTokens": 65536, - "headers": { - "User-Agent": "opencode/1.3.15" - }, "thinking": { "mode": "effort", "efforts": [ @@ -49273,12 +50524,6 @@ "high" ], "requiresEffort": true - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false, - "compat": { - "supportsStore": false, - "supportsReasoningEffort": false } }, "gemini-2.5-pro-exp-03-25": { @@ -49678,45 +50923,64 @@ }, "Gemma-4-31B-Claude-4.6-Opus-Reasoning-Distilled": { "id": "Gemma-4-31B-Claude-4.6-Opus-Reasoning-Distilled", - "name": "Gemma-4-31B-Claude-4.6-Opus-Reasoning-Distilled", + "name": "Gemma 4 31B Claude 4.6 Opus Reasoning Distilled", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.306, + "output": 0.306, + "cacheRead": 0.0306, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "requiresEffort": true + } }, "Gemma-4-31B-Cognitive-Unshackled": { "id": "Gemma-4-31B-Cognitive-Unshackled", - "name": "Gemma-4-31B-Cognitive-Unshackled", + "name": "Gemma 4 31B Cognitive Unshackled", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.306, + "output": 0.306, + "cacheRead": 0.153, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "Gemma-4-31B-Dark-Gemistry": { "id": "Gemma-4-31B-Dark-Gemistry", @@ -49741,24 +51005,33 @@ }, "Gemma-4-31B-DarkIdol": { "id": "Gemma-4-31B-DarkIdol", - "name": "Gemma-4-31B-DarkIdol", + "name": "Gemma 4 31B DarkIdol", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.306, + "output": 0.306, + "cacheRead": 0.153, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "gemma-4-31B-Fabled": { "id": "gemma-4-31B-Fabled", @@ -49846,24 +51119,33 @@ }, "Gemma-4-31B-GarnetV2": { "id": "Gemma-4-31B-GarnetV2", - "name": "Gemma-4-31B-GarnetV2", + "name": "Gemma 4 31B Garnet V2", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.306, + "output": 0.306, + "cacheRead": 0.153, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "Gemma-4-31B-Gembrain": { "id": "Gemma-4-31B-Gembrain", @@ -50308,24 +51590,33 @@ }, "Gemma-4-31B-Queen": { "id": "Gemma-4-31B-Queen", - "name": "Gemma-4-31B-Queen", + "name": "Gemma 4 31B Queen", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.306, + "output": 0.306, + "cacheRead": 0.153, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "Gemma-4-31B-SDFT-Heretic-RP": { "id": "Gemma-4-31B-SDFT-Heretic-RP", @@ -50789,24 +52080,32 @@ }, "GLM-4.6-Derestricted-v5": { "id": "GLM-4.6-Derestricted-v5", - "name": "GLM-4.6-Derestricted-v5", + "name": "GLM 4.6 Derestricted v5", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.4, + "output": 1.5, + "cacheRead": 0.2, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 131072, + "maxTokens": 8192, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "glm-4.7-flash-heretic": { "id": "glm-4.7-flash-heretic", @@ -51154,9 +52453,9 @@ "image" ], "cost": { - "input": 1.5, - "output": 7.5, - "cacheRead": 0.15, + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, "cacheWrite": 0.041667 }, "contextWindow": 1048576, @@ -51317,7 +52616,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -51328,12 +52627,12 @@ ], "cost": { "input": 0.13, - "output": 0.39999999999999997, - "cacheRead": 0, + "output": 0.4, + "cacheRead": 0.065, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -51343,13 +52642,11 @@ "high", "xhigh" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, - "google/gemma-4-31b-it": { - "id": "google/gemma-4-31b-it", - "name": "Gemma 4 31B IT", + "google/gemma-4-26b-a4b-it:thinking": { + "id": "google/gemma-4-26b-a4b-it:thinking", + "name": "Gemma 4 26B A4B Thinking", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -51359,13 +52656,13 @@ "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.13, + "output": 0.4, + "cacheRead": 0.065, "cacheWrite": 0 }, - "contextWindow": 256000, - "maxTokens": 16384, + "contextWindow": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -51375,9 +52672,67 @@ "high", "xhigh" ] + } + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma 4 31B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.35, + "cacheRead": 0.05, + "cacheWrite": 0 }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "google/gemma-4-31b-it:thinking": { + "id": "google/gemma-4-31b-it:thinking", + "name": "Gemma 4 31B Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.35, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "grok-3-beta": { "id": "grok-3-beta", @@ -52241,7 +53596,7 @@ }, "kimi-k2-instruct-fast": { "id": "kimi-k2-instruct-fast", - "name": "kimi-k2-instruct-fast", + "name": "Kimi K2 0711 Fast", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -52250,15 +53605,13 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.4, + "output": 1.8, + "cacheRead": 0.2, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 131072, + "maxTokens": 16384 }, "kimi-thinking-preview": { "id": "kimi-thinking-preview", @@ -53708,7 +55061,7 @@ }, "meta-llama/llama-3.1-8b-instruct": { "id": "meta-llama/llama-3.1-8b-instruct", - "name": "Llama 3.1 8B Instruct", + "name": "Llama-3.1-8B-Instruct", "api": "openai-completions", "baseUrl": "https://nano-gpt.com/api/v1", "provider": "nanogpt", @@ -53779,15 +55132,13 @@ "image" ], "cost": { - "input": 0.18000000000000002, - "output": 0.8, - "cacheRead": 0, + "input": 0.15, + "output": 0.6, + "cacheRead": 0.075, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 65536, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "maxTokens": 65536 }, "meta-llama/llama-4-scout": { "id": "meta-llama/llama-4-scout", @@ -55707,6 +57058,25 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "NousResearch/hermes-3-llama-3.1-70b": { + "id": "NousResearch/hermes-3-llama-3.1-70b", + "name": "Hermes 3 70B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.408, + "output": 0.408, + "cacheRead": 0.204, + "cacheWrite": 0 + }, + "contextWindow": 65536, + "maxTokens": 8192 + }, "nousresearch/hermes-4-405b": { "id": "nousresearch/hermes-4-405b", "name": "nousresearch/hermes-4-405b", @@ -55917,7 +57287,7 @@ }, "nvidia/nemotron-3-super-120b-a12b": { "id": "nvidia/nemotron-3-super-120b-a12b", - "name": "Nvidia Nemotron 3 Super 120B", + "name": "Nemotron 3 Super", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -55942,7 +57312,9 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "nvidia/nemotron-3-super-120b-a12b:thinking": { "id": "nvidia/nemotron-3-super-120b-a12b:thinking", @@ -55971,7 +57343,9 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "nvidia/nemotron-3-ultra-550b-a55b": { "id": "nvidia/nemotron-3-ultra-550b-a55b", @@ -56294,8 +57668,8 @@ "id": "openai/gpt-4o", "name": "GPT-4o", "api": "openai-completions", - "baseUrl": "https://nano-gpt.com/api/v1", "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": false, "input": [ "text", @@ -56308,16 +57682,14 @@ "cacheWrite": 0 }, "contextWindow": 128000, - "maxTokens": 16384, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "maxTokens": 16384 }, "openai/gpt-4o-2024-08-06": { "id": "openai/gpt-4o-2024-08-06", "name": "GPT-4o (2024-08-06)", "api": "openai-completions", - "baseUrl": "https://nano-gpt.com/api/v1", "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": false, "input": [ "text", @@ -56330,16 +57702,14 @@ "cacheWrite": 0 }, "contextWindow": 128000, - "maxTokens": 16384, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "maxTokens": 16384 }, "openai/gpt-4o-2024-11-20": { "id": "openai/gpt-4o-2024-11-20", "name": "GPT-4o (2024-11-20)", "api": "openai-completions", - "baseUrl": "https://nano-gpt.com/api/v1", "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": false, "input": [ "text", @@ -56352,9 +57722,7 @@ "cacheWrite": 0 }, "contextWindow": 128000, - "maxTokens": 16384, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "maxTokens": 16384 }, "openai/gpt-4o-mini": { "id": "openai/gpt-4o-mini", @@ -57367,13 +58735,13 @@ "text" ], "cost": { - "input": 0.075, + "input": 0.2, "output": 0.3, "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 131072, - "maxTokens": 65536, + "contextWindow": 128000, + "maxTokens": 16384, "thinking": { "mode": "effort", "efforts": [ @@ -57381,9 +58749,7 @@ "medium", "high" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "openai/gpt-oss-safeguard-20b": { "id": "openai/gpt-oss-safeguard-20b", @@ -57470,13 +58836,14 @@ }, "openai/o1-pro": { "id": "openai/o1-pro", - "name": "openai/o1-pro", + "name": "o1-pro", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { "input": 0, @@ -57487,7 +58854,18 @@ "contextWindow": 200000, "maxTokens": 100000, "supportsComputerUse": false, - "supportsComputerUseConfig": false + "supportsComputerUseConfig": false, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "requiresEffort": true + } }, "openai/o3": { "id": "openai/o3", @@ -58108,24 +59486,23 @@ }, "qwen-3.6-plus": { "id": "qwen-3.6-plus", - "name": "qwen-3.6-plus", + "name": "Qwen 3.6 Plus", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": false, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 + "input": 0.325, + "output": 1.95, + "cacheRead": 0.0325, + "cacheWrite": 0.40625 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 991808, + "maxTokens": 65536 }, "qwen-long": { "id": "qwen-long", @@ -58213,28 +59590,26 @@ }, "qwen/qwen-2.5-72b-instruct": { "id": "qwen/qwen-2.5-72b-instruct", - "name": "Qwen2.5 72B Instruct", + "name": "Qwen2.5 72B", "api": "openai-completions", - "baseUrl": "https://nano-gpt.com/api/v1", "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": false, "input": [ "text" ], "cost": { - "input": 0.12, - "output": 0.39, - "cacheRead": 0, + "input": 0.357, + "output": 0.408, + "cacheRead": 0.1785, "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 16384, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "maxTokens": 8192 }, "Qwen/Qwen2.5-Coder-32B-Instruct": { "id": "Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen/Qwen2.5-Coder-32B-Instruct", + "name": "Qwen2.5-Coder-32B-Instruct", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -58389,7 +59764,9 @@ "cacheWrite": 0 }, "contextWindow": 256000, - "maxTokens": 262144 + "maxTokens": 262144, + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE": { "id": "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", @@ -58412,6 +59789,25 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "qwen/Qwen3-235B-A22B-Thinking-2507": { + "id": "qwen/Qwen3-235B-A22B-Thinking-2507", + "name": "Qwen 3 235b A22B 2507 Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0.3, + "output": 0.5, + "cacheRead": 0.15, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 262144 + }, "Qwen/Qwen3-235B-A22B-Thinking-2507": { "id": "Qwen/Qwen3-235B-A22B-Thinking-2507", "name": "Qwen3-235B-A22B-Thinking-2507", @@ -58475,33 +59871,22 @@ }, "qwen/qwen3-32b": { "id": "qwen/qwen3-32b", - "name": "Qwen3 32B", + "name": "Qwen 3 32b", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": true, + "reasoning": false, "input": [ "text" ], "cost": { - "input": 0.29, - "output": 0.59, - "cacheRead": 0, + "input": 0.1, + "output": 0.3, + "cacheRead": 0.05, "cacheWrite": 0 }, - "contextWindow": 131072, - "maxTokens": 40960, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 41000, + "maxTokens": 32768 }, "Qwen/Qwen3-8B": { "id": "Qwen/Qwen3-8B", @@ -58719,23 +60104,23 @@ }, "qwen/qwen3.5-397b-a17b": { "id": "qwen/qwen3.5-397b-a17b", - "name": "Qwen3.5 397B-A17B", + "name": "Qwen3.5 397B A17B", "api": "openai-completions", - "baseUrl": "https://nano-gpt.com/api/v1", "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": true, "input": [ "text", "image" ], "cost": { - "input": 0.15, - "output": 1, - "cacheRead": 0.15, + "input": 0.6, + "output": 3.6, + "cacheRead": 0.3, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 8192, + "contextWindow": 258048, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -58751,9 +60136,7 @@ "medium": "qwen/qwen3.5-397b-a17b-thinking", "high": "qwen/qwen3.5-397b-a17b-thinking" } - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "qwen/qwen3.5-9b": { "id": "qwen/qwen3.5-9b", @@ -58767,9 +60150,9 @@ "image" ], "cost": { - "input": 0.049999999999999996, + "input": 0.05, "output": 0.15, - "cacheRead": 0, + "cacheRead": 0.025, "cacheWrite": 0 }, "contextWindow": 256000, @@ -58782,9 +60165,7 @@ "medium", "high" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "qwen/qwen3.5-plus": { "id": "qwen/qwen3.5-plus", @@ -58824,6 +60205,26 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "qwen/Qwen3.6-35B-A3B": { + "id": "qwen/Qwen3.6-35B-A3B", + "name": "Qwen3.6 35B A3B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.112, + "output": 0.8, + "cacheRead": 0.056, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 16384 + }, "Qwen/Qwen3.6-35B-A3B": { "id": "Qwen/Qwen3.6-35B-A3B", "name": "Qwen3.6 35B A3B", @@ -58855,6 +60256,35 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "qwen/Qwen3.6-35B-A3B:thinking": { + "id": "qwen/Qwen3.6-35B-A3B:thinking", + "name": "Qwen3.6 35B A3B Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.112, + "output": 0.8, + "cacheRead": 0.056, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "qwen/qwq-32b-preview": { "id": "qwen/qwq-32b-preview", "name": "qwen/qwq-32b-preview", @@ -59021,6 +60451,35 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "qwen3.5-0.8b": { + "id": "qwen3.5-0.8b", + "name": "Qwen3.5 0.8B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.06, + "output": 0.12, + "cacheRead": 0.03, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, "qwen3.5-122b-a10b": { "id": "qwen3.5-122b-a10b", "name": "Qwen3.5 122B A10B", @@ -59070,33 +60529,23 @@ }, "qwen3.5-27b": { "id": "qwen3.5-27b", - "name": "qwen3.5-27b", + "name": "Qwen3.5 27B", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": true, + "reasoning": false, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.27, + "output": 2.16, + "cacheRead": 0.135, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 65536, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 260096, + "maxTokens": 65536 }, "Qwen3.5-27B-Anko": { "id": "Qwen3.5-27B-Anko", @@ -59247,24 +60696,32 @@ }, "Qwen3.5-27B-BlueStar-v3-Derestricted": { "id": "Qwen3.5-27B-BlueStar-v3-Derestricted", - "name": "Qwen3.5-27B-BlueStar-v3-Derestricted", + "name": "Qwen3.5 27B BlueStar v3 Derestricted", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.306, + "output": 0.306, + "cacheRead": 0.153, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } }, "Qwen3.5-27B-BlueStar-v3-Derestricted-Lite": { "id": "Qwen3.5-27B-BlueStar-v3-Derestricted-Lite", @@ -59835,24 +61292,32 @@ }, "Qwen3.5-27B-Queen-Derestricted": { "id": "Qwen3.5-27B-Queen-Derestricted", - "name": "Qwen3.5-27B-Queen-Derestricted", + "name": "Qwen3.5 27B Queen Derestricted", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.306, + "output": 0.306, + "cacheRead": 0.153, "cacheWrite": 0 }, - "contextWindow": null, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } }, "Qwen3.5-27B-Queen-Derestricted-Lite": { "id": "Qwen3.5-27B-Queen-Derestricted-Lite", @@ -60043,24 +61508,25 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, - "qwen3.5-35b-a3b": { - "id": "qwen3.5-35b-a3b", - "name": "qwen3.5-35b-a3b", + "qwen3.5-27b:thinking": { + "id": "qwen3.5-27b:thinking", + "name": "Qwen3.5 27B Thinking", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.27, + "output": 2.16, + "cacheRead": 0.135, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 262144, + "contextWindow": 260096, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -60069,9 +61535,114 @@ "medium", "high" ] + } + }, + "qwen3.5-2b": { + "id": "qwen3.5-2b", + "name": "Qwen3.5 2B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.08, + "output": 0.16, + "cacheRead": 0.04, + "cacheWrite": 0 }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "qwen3.5-35b-a3b": { + "id": "qwen3.5-35b-a3b", + "name": "Qwen3.5 35B A3B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.225, + "output": 1.8, + "cacheRead": 0.1125, + "cacheWrite": 0 + }, + "contextWindow": 260096, + "maxTokens": 65536 + }, + "qwen3.5-35b-a3b:thinking": { + "id": "qwen3.5-35b-a3b:thinking", + "name": "Qwen3.5 35B A3B Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.225, + "output": 1.8, + "cacheRead": 0.1125, + "cacheWrite": 0 + }, + "contextWindow": 260096, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "qwen3.5-4b": { + "id": "qwen3.5-4b", + "name": "Qwen3.5 4B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.1, + "output": 0.2, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } }, "qwen3.5-flash": { "id": "qwen3.5-flash", @@ -60235,10 +61806,10 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 + "input": 2.5, + "output": 7.5, + "cacheRead": 0.5, + "cacheWrite": 3.125 }, "contextWindow": 1000000, "maxTokens": 65536, @@ -60250,9 +61821,35 @@ "medium", "high" ] + } + }, + "qwen3.7-max:thinking": { + "id": "qwen3.7-max:thinking", + "name": "Qwen3.7 Max Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 2.5, + "output": 7.5, + "cacheRead": 0.5, + "cacheWrite": 3.125 }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 1000000, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } }, "qwen3.7-plus": { "id": "qwen3.7-plus", @@ -60266,12 +61863,12 @@ "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 + "input": 0.4, + "output": 1.6, + "cacheRead": 0.08, + "cacheWrite": 0.5 }, - "contextWindow": 262144, + "contextWindow": 991808, "maxTokens": 65536, "thinking": { "mode": "effort", @@ -60281,9 +61878,94 @@ "medium", "high" ] + } + }, + "qwen3.7-plus:thinking": { + "id": "qwen3.7-plus:thinking", + "name": "Qwen3.7 Plus Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 1.6, + "cacheRead": 0.08, + "cacheWrite": 0.5 }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 983616, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "qwen3.8-27b": { + "id": "qwen3.8-27b", + "name": "Qwen3.8 27B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 3, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "qwen3.8-27b:thinking": { + "id": "qwen3.8-27b:thinking", + "name": "Qwen3.8 27B Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 3, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } }, "qwen3.8-max": { "id": "qwen3.8-max", @@ -61373,6 +63055,60 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "TEE/deepseek-v4-pro-0813": { + "id": "TEE/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813 TEE", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.132, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 1048576, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, + "TEE/deepseek-v4-pro-0813:thinking": { + "id": "TEE/deepseek-v4-pro-0813:thinking", + "name": "DeepSeek V4 Pro 0813 Thinking TEE", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.132, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 1048576, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, "TEE/deepseek-v4-pro:thinking": { "id": "TEE/deepseek-v4-pro:thinking", "name": "DeepSeek V4 Pro Thinking TEE", @@ -61474,22 +63210,43 @@ }, "TEE/gemma4-31b": { "id": "TEE/gemma4-31b", - "name": "TEE/gemma4-31b", + "name": "Gemma 4 31B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 1, + "cacheRead": 0.4, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072 + }, + "TEE/gemma4-31b:thinking": { + "id": "TEE/gemma4-31b:thinking", + "name": "Gemma 4 31B Thinking TEE", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.4, + "output": 1, + "cacheRead": 0.4, "cacheWrite": 0 }, - "contextWindow": 256000, - "maxTokens": 8192, + "contextWindow": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -61499,9 +63256,7 @@ "high", "xhigh" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "TEE/glm-4.6": { "id": "TEE/glm-4.6", @@ -61628,7 +63383,7 @@ }, "TEE/glm-5.1": { "id": "TEE/glm-5.1", - "name": "TEE/glm-5.1", + "name": "GLM 5.1 TEE", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -61637,13 +63392,13 @@ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 1.5, + "output": 5.25, + "cacheRead": 0.3, "cacheWrite": 0 }, - "contextWindow": 204800, - "maxTokens": 131072, + "contextWindow": 202752, + "maxTokens": 65535, "thinking": { "mode": "effort", "efforts": [ @@ -61661,9 +63416,7 @@ "high": "TEE/glm-5.1-thinking", "xhigh": "TEE/glm-5.1-thinking" } - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "TEE/glm-5.2": { "id": "TEE/glm-5.2", @@ -61845,6 +63598,36 @@ "contextWindow": 262144, "maxTokens": 65536 }, + "TEE/kimi-k2.7-code": { + "id": "TEE/kimi-k2.7-code", + "name": "Kimi K2.7 Code TEE", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.95, + "output": 4, + "cacheRead": 0.19, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "TEE/kimi-k3": { "id": "TEE/kimi-k3", "name": "Kimi K3 TEE", @@ -63350,7 +65133,7 @@ "cost": { "input": 2, "output": 6, - "cacheRead": 0, + "cacheRead": 1, "cacheWrite": 0 }, "contextWindow": 2000000, @@ -63364,9 +65147,7 @@ "high", "xhigh" ] - }, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + } }, "x-ai/grok-4.20-multi-agent-beta": { "id": "x-ai/grok-4.20-multi-agent-beta", @@ -64187,24 +65968,32 @@ }, "zai-org/glm-4.6-original": { "id": "zai-org/glm-4.6-original", - "name": "zai-org/glm-4.6-original", + "name": "GLM 4.6 Original", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.35, + "output": 1.4, + "cacheRead": 0.175, "cacheWrite": 0 }, - "contextWindow": 204800, - "maxTokens": 131072, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "contextWindow": 256000, + "maxTokens": 65535, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "zai-org/GLM-4.6-turbo": { "id": "zai-org/GLM-4.6-turbo", @@ -64387,6 +66176,64 @@ ] } }, + "zai-org/glm-4.7-flash-original:thinking": { + "id": "zai-org/glm-4.7-flash-original:thinking", + "name": "GLM 4.7 Flash Original Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.07, + "output": 0.4, + "cacheRead": 0.035, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "zai-org/glm-4.7-flash:thinking": { + "id": "zai-org/glm-4.7-flash:thinking", + "name": "GLM 4.7 Flash Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.07, + "output": 0.4, + "cacheRead": 0.035, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "zai-org/glm-4.7-original": { "id": "zai-org/glm-4.7-original", "name": "GLM 4.7 Original", @@ -64727,6 +66574,64 @@ ] } }, + "zai-org/glm-5.3": { + "id": "zai-org/glm-5.3", + "name": "GLM 5.3 Preview", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ], + "defaultLevel": "max", + "requiresEffort": true + } + }, + "zai-org/glm-5.3:thinking": { + "id": "zai-org/glm-5.3:thinking", + "name": "GLM 5.3 Preview Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ], + "defaultLevel": "max", + "requiresEffort": true + } + }, "zai-org/glm-latest": { "id": "zai-org/glm-latest", "name": "GLM Latest", @@ -65396,6 +67301,36 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "deepseek/deepseek-v4-pro-0813": { + "id": "deepseek/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "api": "openai-completions", + "provider": "novita", + "baseUrl": "https://api.novita.ai/openai/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.35, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 393216, + "supportsTools": true, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false + }, "dev/glm46": { "id": "dev/glm46", "name": "dev/glm46", @@ -65466,7 +67401,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "openai-completions", "provider": "novita", "baseUrl": "https://api.novita.ai/openai/v1", @@ -65707,7 +67642,7 @@ }, "meta-llama/llama-3.1-8b-instruct": { "id": "meta-llama/llama-3.1-8b-instruct", - "name": "Llama 3.1 8B Instruct", + "name": "Llama-3.1-8B-Instruct", "api": "openai-completions", "provider": "novita", "baseUrl": "https://api.novita.ai/openai/v1", @@ -66527,7 +68462,7 @@ }, "qwen/qwen-2.5-72b-instruct": { "id": "qwen/qwen-2.5-72b-instruct", - "name": "Qwen2.5 72B Instruct", + "name": "Qwen2.5 72B", "api": "openai-completions", "provider": "novita", "baseUrl": "https://api.novita.ai/openai/v1", @@ -68891,6 +70826,36 @@ "contextWindow": 128000, "maxTokens": 4096 }, + "meta/muse-glimmer-30b": { + "id": "meta/muse-glimmer-30b", + "name": "Muse Glimmer 30B", + "api": "openai-completions", + "provider": "nvidia", + "baseUrl": "https://integrate.api.nvidia.com/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 131072, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "microsoft/kosmos-2": { "id": "microsoft/kosmos-2", "name": "microsoft/kosmos-2", @@ -71673,6 +73638,35 @@ ] } }, + "deepseek-v4-pro:0813": { + "id": "deepseek-v4-pro:0813", + "name": "deepseek-v4-pro:0813", + "api": "ollama-chat", + "provider": "ollama-cloud", + "baseUrl": "https://ollama.com", + "reasoning": true, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + }, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "omitMaxOutputTokens": true, + "supportsComputerUse": false + }, "deepseek-v4-pro:preview": { "id": "deepseek-v4-pro:preview", "name": "deepseek-v4-pro:preview", @@ -74559,7 +76553,7 @@ "api": "openai-codex-responses", "v2StreamingEnabled": true }, - "contextWindow": 372000, + "contextWindow": 1000000, "maxTokens": 128000, "preferWebsockets": true, "useResponsesLite": true, @@ -74598,7 +76592,7 @@ "api": "openai-codex-responses", "v2StreamingEnabled": true }, - "contextWindow": 372000, + "contextWindow": 1000000, "maxTokens": 128000, "preferWebsockets": true, "useResponsesLite": true, @@ -74637,7 +76631,7 @@ "api": "openai-codex-responses", "v2StreamingEnabled": true }, - "contextWindow": 372000, + "contextWindow": 1000000, "maxTokens": 128000, "preferWebsockets": true, "useResponsesLite": true, @@ -74666,10 +76660,10 @@ "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 + "input": 5, + "output": 30, + "cacheRead": 0.5, + "cacheWrite": 6.25 }, "remoteCompaction": { "enabled": true, @@ -74844,7 +76838,7 @@ "opencode-go": { "deepseek-v4-flash": { "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash (2x usage)", + "name": "DeepSeek V4 Flash", "api": "openai-responses", "provider": "opencode-go", "baseUrl": "https://opencode.ai/zen/go/v1", @@ -74853,13 +76847,19 @@ "text" ], "cost": { - "input": 0.07, - "output": 0.14, - "cacheRead": 0.0014, + "input": 0.22, + "output": 0.66, + "cacheRead": 0.007, "cacheWrite": 0 }, "contextWindow": 1000000, "maxTokens": 384000, + "compat": { + "supportsToolChoice": false, + "maxTokensField": "max_tokens", + "reasoningContentField": "reasoning_content", + "requiresReasoningContentForToolCalls": true + }, "thinking": { "mode": "effort", "efforts": [ @@ -74882,9 +76882,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 0.66, + "output": 1.98, + "cacheRead": 0.022, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -74991,6 +76991,35 @@ ] } }, + "glm-5.3": { + "id": "glm-5.3", + "name": "GLM-5.3", + "api": "openai-completions", + "provider": "opencode-go", + "baseUrl": "https://opencode.ai/zen/go/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ], + "defaultLevel": "max", + "requiresEffort": true + } + }, "gpt-5.6-luna": { "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna (2x usage)", @@ -75480,9 +77509,9 @@ "qwen3.7-max": { "id": "qwen3.7-max", "name": "Qwen3.7 Max", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text" @@ -75496,22 +77525,21 @@ "contextWindow": 1000000, "maxTokens": 65536, "thinking": { - "mode": "budget", + "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high", - "xhigh" + "high" ] } }, "qwen3.7-plus": { "id": "qwen3.7-plus", "name": "Qwen3.7 Plus", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text", @@ -75526,22 +77554,21 @@ "contextWindow": 1000000, "maxTokens": 65536, "thinking": { - "mode": "budget", + "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high", - "xhigh" + "high" ] } }, "qwen3.8-max": { "id": "qwen3.8-max", "name": "Qwen3.8 Max", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text", @@ -75556,13 +77583,12 @@ "contextWindow": 1000000, "maxTokens": 131072, "thinking": { - "mode": "budget", + "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high", - "xhigh" + "high" ] } } @@ -77721,6 +79747,36 @@ ] } }, + "muse-spark-1.2": { + "id": "muse-spark-1.2", + "name": "Muse Spark 1.2", + "api": "openai-responses", + "provider": "opencode-zen", + "baseUrl": "https://opencode.ai/zen/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1.25, + "output": 4.25, + "cacheRead": 0.15, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "nemotron-3-super-free": { "id": "nemotron-3-super-free", "name": "Nemotron 3 Super Free", @@ -78110,12 +80166,12 @@ "text" ], "cost": { - "input": 0.072, - "output": 0.144, - "cacheRead": 0.016, + "input": 0.060300000000000006, + "output": 0.12060000000000001, + "cacheRead": 0.01206, "cacheWrite": 0 }, - "contextWindow": 1048576, + "contextWindow": 1310720, "maxTokens": 262144, "thinking": { "mode": "effort", @@ -78202,9 +80258,9 @@ "image" ], "cost": { - "input": 2.4, - "output": 12, - "cacheRead": 0.24, + "input": 2.6, + "output": 13, + "cacheRead": 0.29, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -78608,7 +80664,7 @@ }, "anthropic/claude-3-haiku": { "id": "anthropic/claude-3-haiku", - "name": "Claude Haiku 3", + "name": "Claude 3 Haiku", "api": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", "provider": "openrouter", @@ -80048,7 +82104,7 @@ }, "bytedance-seed/seed-2.0-lite": { "id": "bytedance-seed/seed-2.0-lite", - "name": "Seed-2.0-Lite", + "name": "Seed 2.0 Lite", "api": "openrouter", "provider": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", @@ -80079,7 +82135,7 @@ }, "bytedance-seed/seed-2.0-mini": { "id": "bytedance-seed/seed-2.0-mini", - "name": "Seed-2.0-Mini", + "name": "Seed 2.0 Mini", "api": "openrouter", "provider": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", @@ -80422,13 +82478,13 @@ "text" ], "cost": { - "input": 0.14, - "output": 0.28, - "cacheRead": 0.028, + "input": 0.08259999999999999, + "output": 0.16519999999999999, + "cacheRead": 0.01652, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 393216, + "maxTokens": 384000, "thinking": { "mode": "effort", "efforts": [ @@ -80451,13 +82507,13 @@ "text" ], "cost": { - "input": 0.08, - "output": 0.18, - "cacheRead": 0.016, + "input": 0.14, + "output": 0.28, + "cacheRead": 0.028, "cacheWrite": 0 }, - "contextWindow": 1048576, - "maxTokens": 384000, + "contextWindow": 1310720, + "maxTokens": 393216, "thinking": { "mode": "effort", "efforts": [ @@ -80509,13 +82565,13 @@ "text" ], "cost": { - "input": 1.1680000000000001, - "output": 2.3360000000000003, - "cacheRead": 0.09855000000000001, + "input": 1.32, + "output": 3.9600000000000004, + "cacheRead": 0.044, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 393216, + "maxTokens": 384000, "thinking": { "mode": "effort", "efforts": [ @@ -80536,9 +82592,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.003625, + "input": 1.32, + "output": 3.9600000000000004, + "cacheRead": 0.044, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -80546,6 +82602,39 @@ "thinking": { "mode": "effort", "efforts": [ + "low", + "high", + "max" + ] + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false + }, + "dots-studio/dots-3-note-preview:free": { + "id": "dots-studio/dots-3-note-preview:free", + "name": "Dots3-Note Preview (free)", + "api": "openrouter", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 512000, + "maxTokens": 512000, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", "high" ] }, @@ -81555,7 +83644,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "openrouter", "provider": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", @@ -81565,13 +83654,13 @@ "image" ], "cost": { - "input": 0.12, - "output": 0.39999999999999997, + "input": 0.07, + "output": 0.33999999999999997, "cacheRead": 0.049999999999999996, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 16384, "thinking": { "mode": "effort", "efforts": [ @@ -82136,7 +84225,7 @@ "cacheWrite": 0 }, "contextWindow": 128000, - "maxTokens": 32768, + "maxTokens": 8192, "thinking": { "mode": "effort", "efforts": [ @@ -82265,7 +84354,7 @@ }, "meta-llama/llama-3.1-8b-instruct": { "id": "meta-llama/llama-3.1-8b-instruct", - "name": "Llama 3.1 8B Instruct", + "name": "Llama-3.1-8B-Instruct", "api": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", "provider": "openrouter", @@ -82339,7 +84428,7 @@ ], "cost": { "input": 0.19999999999999998, - "output": 0.696, + "output": 0.7999999999999999, "cacheRead": 0, "cacheWrite": 0 }, @@ -83421,9 +85510,9 @@ "image" ], "cost": { - "input": 0.5605, - "output": 2.36, - "cacheRead": 0.0944, + "input": 0.95, + "output": 4, + "cacheRead": 0.16, "cacheWrite": 0 }, "contextWindow": 262144, @@ -83483,8 +85572,8 @@ "image" ], "cost": { - "input": 0.67, - "output": 3.4, + "input": 0.71, + "output": 3.5, "cacheRead": 0.15, "cacheWrite": 0 }, @@ -83806,11 +85895,11 @@ "cost": { "input": 0.049999999999999996, "output": 0.19999999999999998, - "cacheRead": 0.024999999999999998, + "cacheRead": 0.03, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 228000, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -84046,13 +86135,13 @@ "text" ], "cost": { - "input": 0.09999999999999999, - "output": 0.25, - "cacheRead": 0.049999999999999996, + "input": 0.08, + "output": 0.19999999999999998, + "cacheRead": 0.04, "cacheWrite": 0 }, "contextWindow": 1000000, - "maxTokens": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -87550,7 +89639,7 @@ }, "qwen/qwen-2.5-72b-instruct": { "id": "qwen/qwen-2.5-72b-instruct", - "name": "Qwen2.5 72B Instruct", + "name": "Qwen2.5 72B", "api": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", "provider": "openrouter", @@ -87664,8 +89753,8 @@ "text" ], "cost": { - "input": 0.39999999999999997, - "output": 1.2, + "input": 0.26, + "output": 0.78, "cacheRead": 0, "cacheWrite": 0.5 }, @@ -87849,13 +89938,13 @@ "text" ], "cost": { - "input": 0.12, - "output": 0.5, + "input": 0.13, + "output": 0.52, "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 16384, + "maxTokens": 8192, "thinking": { "mode": "effort", "efforts": [ @@ -87922,11 +90011,11 @@ }, "qwen/qwen3-32b": { "id": "qwen/qwen3-32b", - "name": "Qwen3 32B", + "name": "Qwen 3 32b", "api": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", "provider": "openrouter", - "reasoning": true, + "reasoning": false, "input": [ "text" ], @@ -87938,15 +90027,6 @@ }, "contextWindow": 131072, "maxTokens": 16384, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] - }, "supportsComputerUse": false, "supportsComputerUseConfig": false }, @@ -88324,8 +90404,8 @@ "image" ], "cost": { - "input": 0.26, - "output": 1.04, + "input": 0.21, + "output": 1.9, "cacheRead": 0.09999999999999999, "cacheWrite": 0 }, @@ -88378,13 +90458,13 @@ "image" ], "cost": { - "input": 0.15, - "output": 0.6, + "input": 0.13, + "output": 0.52, "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 16384, + "maxTokens": 32768, "supportsComputerUse": false, "supportsComputerUseConfig": false }, @@ -88570,13 +90650,13 @@ "image" ], "cost": { - "input": 0.25, - "output": 1.25, - "cacheRead": 0.25, + "input": 0.22499999999999998, + "output": 1.7999999999999998, + "cacheRead": 0.22499999999999998, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -88601,13 +90681,13 @@ "image" ], "cost": { - "input": 0.5, - "output": 3.5999999999999996, + "input": 0.39, + "output": 2.34, "cacheRead": 0.3, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -88756,13 +90836,13 @@ "image" ], "cost": { - "input": 0.6, - "output": 3.5999999999999996, - "cacheRead": 0.12, + "input": 0.28900000000000003, + "output": 2.4, + "cacheRead": 0.03, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 131072, "thinking": { "mode": "effort", "efforts": [ @@ -88787,7 +90867,7 @@ "image" ], "cost": { - "input": 0.15, + "input": 0.14, "output": 1, "cacheRead": 0.049999999999999996, "cacheWrite": 0 @@ -89067,7 +91147,7 @@ "cacheRead": 0.25, "cacheWrite": 0 }, - "contextWindow": 1000000, + "contextWindow": 1048576, "maxTokens": 262144, "thinking": { "mode": "effort", @@ -89081,6 +91161,37 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "qwen/qwen3.8-27b": { + "id": "qwen/qwen3.8-27b", + "name": "Qwen3.8 27B", + "api": "openrouter", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.44999999999999996, + "output": 3.1999999999999997, + "cacheRead": 0.049999999999999996, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false + }, "qwen/qwen3.8-max": { "id": "qwen/qwen3.8-max", "name": "Qwen3.8 Max", @@ -89452,9 +91563,9 @@ "text" ], "cost": { - "input": 0.063, - "output": 0.21, - "cacheRead": 0.020999999999999998, + "input": 0.18, + "output": 0.6, + "cacheRead": 0.06, "cacheWrite": 0 }, "contextWindow": 262144, @@ -90524,9 +92635,9 @@ "text" ], "cost": { - "input": 0.5, - "output": 2, - "cacheRead": 0.09999999999999999, + "input": 0.55, + "output": 2.2, + "cacheRead": 0.11, "cacheWrite": 0 }, "contextWindow": 204800, @@ -90666,7 +92777,7 @@ }, "z-ai/glm-5": { "id": "z-ai/glm-5", - "name": "GLM-5", + "name": "GLM 5", "api": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", "provider": "openrouter", @@ -90675,13 +92786,13 @@ "text" ], "cost": { - "input": 0.95, - "output": 2.5500000000000003, - "cacheRead": 0.19999999999999998, + "input": 0.6, + "output": 1.92, + "cacheRead": 0.12, "cacheWrite": 0 }, "contextWindow": 204800, - "maxTokens": 131072, + "maxTokens": 128000, "thinking": { "mode": "effort", "efforts": [ @@ -90726,13 +92837,13 @@ "text" ], "cost": { - "input": 0.952, - "output": 2.992, - "cacheRead": 0.17679999999999998, + "input": 0.966, + "output": 3.036, + "cacheRead": 0.1794, "cacheWrite": 0 }, "contextWindow": 204800, - "maxTokens": 131072, + "maxTokens": 128000, "thinking": { "mode": "effort", "efforts": [ @@ -90756,13 +92867,13 @@ "text" ], "cost": { - "input": 0.392, - "output": 1.232, - "cacheRead": 0.0728, + "input": 1.19, + "output": 3.74, + "cacheRead": 0.221, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 131072, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -91452,6 +93563,33 @@ ] } }, + "deepseek-ai/DeepSeek-V4-Pro-0813": { + "id": "deepseek-ai/DeepSeek-V4-Pro-0813", + "name": "DeepSeek V4 Pro 0813", + "api": "openai-completions", + "provider": "together", + "baseUrl": "https://api.together.xyz/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.13, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 384000, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, "essentialai/Rnj-1-Instruct": { "id": "essentialai/Rnj-1-Instruct", "name": "Rnj-1 Instruct", @@ -92269,6 +94407,40 @@ "escapeBuiltinToolNames": true } }, + "umans-deepseek-v4-pro-0813": { + "id": "umans-deepseek-v4-pro-0813", + "name": "Umans DeepSeek V4 Pro", + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "input": [ + "text" + ], + "cost": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.044, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 393215, + "supportsComputerUse": false, + "supportsComputerUseConfig": false, + "compat": { + "escapeBuiltinToolNames": true + } + }, "umans-flash": { "id": "umans-flash", "name": "Umans Flash", @@ -93179,6 +95351,33 @@ ] } }, + "deepseek-v4-pro-0813": { + "id": "deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.73, + "output": 4.95, + "cacheRead": 0.33, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 32768, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } + }, "e2ee-deepseek-v4-flash": { "id": "e2ee-deepseek-v4-flash", "name": "e2ee-deepseek-v4-flash", @@ -93755,6 +95954,36 @@ ] } }, + "gemini-3-7-flash": { + "id": "gemini-3-7-flash", + "name": "Gemini 3.7 Flash", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1.875, + "output": 9.375, + "cacheRead": 0.1875, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, "gemini-3-flash-preview": { "id": "gemini-3-flash-preview", "name": "Gemini 3 Flash Preview", @@ -95508,26 +97737,30 @@ }, "qwen-3-8-2-4t-a95b": { "id": "qwen-3-8-2-4t-a95b", - "name": "qwen-3-8-2-4t-a95b", + "name": "Qwen 3.8 2.4T", "api": "openai-completions", "provider": "venice", "baseUrl": "https://api.venice.ai/api/v1", - "reasoning": false, + "reasoning": true, "input": [ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 2.5, + "output": 7.5, + "cacheRead": 0.3125, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": null, - "supportsComputerUse": false, - "supportsComputerUseConfig": false, - "compat": { - "supportsUsageInStreaming": false + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] } }, "qwen-3-8-max": { @@ -96798,11 +99031,11 @@ }, "alibaba/qwen3.6-27b": { "id": "alibaba/qwen3.6-27b", - "name": "Qwen 3.6 27B", + "name": "Qwen3.6 27B", "api": "anthropic-messages", "provider": "vercel-ai-gateway", "baseUrl": "https://ai-gateway.vercel.sh", - "reasoning": true, + "reasoning": false, "input": [ "text", "image" @@ -96815,16 +99048,6 @@ }, "contextWindow": 256000, "maxTokens": 256000, - "thinking": { - "mode": "budget", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, "supportsComputerUse": false }, "alibaba/qwen3.6-plus": { @@ -96964,7 +99187,7 @@ "cost": { "input": 2, "output": 6, - "cacheRead": 0.25, + "cacheRead": 0.19999999999999998, "cacheWrite": 0 }, "contextWindow": 262144, @@ -96981,6 +99204,37 @@ }, "supportsComputerUse": false }, + "alibaba/qwen3.8-27b": { + "id": "alibaba/qwen3.8-27b", + "name": "Qwen3.8 27B", + "api": "anthropic-messages", + "provider": "vercel-ai-gateway", + "baseUrl": "https://ai-gateway.vercel.sh", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262133, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "supportsComputerUse": false + }, "alibaba/qwen3.8-max": { "id": "alibaba/qwen3.8-max", "name": "Qwen 3.8 Max", @@ -97107,7 +99361,7 @@ }, "anthropic/claude-3-haiku": { "id": "anthropic/claude-3-haiku", - "name": "Claude Haiku 3", + "name": "Claude 3 Haiku", "api": "anthropic-messages", "baseUrl": "https://ai-gateway.vercel.sh", "provider": "vercel-ai-gateway", @@ -98074,9 +100328,9 @@ "text" ], "cost": { - "input": 0.19999999999999998, - "output": 0.39999999999999997, - "cacheRead": 0.04, + "input": 0.13, + "output": 0.26, + "cacheRead": 0.028, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -98104,9 +100358,9 @@ "text" ], "cost": { - "input": 0.19999999999999998, - "output": 0.39999999999999997, - "cacheRead": 0.04, + "input": 0.13, + "output": 0.26, + "cacheRead": 0.028, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -98164,9 +100418,9 @@ "text" ], "cost": { - "input": 0.435, - "output": 0.87, - "cacheRead": 0.0036, + "input": 1.32, + "output": 3.9600000000000004, + "cacheRead": 0.13199999999999998, "cacheWrite": 0 }, "contextWindow": 1000000, @@ -98649,7 +100903,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "anthropic-messages", "provider": "vercel-ai-gateway", "baseUrl": "https://ai-gateway.vercel.sh", @@ -98848,7 +101102,8 @@ "xhigh" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "interfaze/interfaze-beta": { "id": "interfaze/interfaze-beta", @@ -100344,6 +102599,36 @@ }, "supportsComputerUse": false }, + "nvidia/nemotron-3.5-lightning": { + "id": "nvidia/nemotron-3.5-lightning", + "name": "Nvidia Nemotron 3.5 Lightning", + "api": "anthropic-messages", + "provider": "vercel-ai-gateway", + "baseUrl": "https://ai-gateway.vercel.sh", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 131072, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "supportsComputerUse": false + }, "nvidia/nemotron-nano-12b-v2-vl": { "id": "nvidia/nemotron-nano-12b-v2-vl", "name": "Nemotron Nano 12B v2 VL", @@ -102511,7 +104796,8 @@ "baseUrl": "https://ai-gateway.vercel.sh", "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { "input": 2, @@ -103716,7 +106002,7 @@ "grok-2": { "id": "grok-2", "name": "Grok 2", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103730,12 +106016,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-1212": { "id": "grok-2-1212", "name": "Grok 2 (1212)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103749,12 +106039,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-latest": { "id": "grok-2-latest", "name": "Grok 2 Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103768,12 +106062,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-vision": { "id": "grok-2-vision", "name": "Grok 2 Vision", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103788,12 +106086,16 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-vision-1212": { "id": "grok-2-vision-1212", "name": "Grok 2 Vision (1212)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103808,12 +106110,16 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-2-vision-latest": { "id": "grok-2-vision-latest", "name": "Grok 2 Vision Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103828,12 +106134,16 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3": { "id": "grok-3", "name": "Grok 3", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103847,12 +106157,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-fast": { "id": "grok-3-fast", "name": "Grok 3 Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103866,12 +106180,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-fast-latest": { "id": "grok-3-fast-latest", "name": "Grok 3 Fast Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103885,12 +106203,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-latest": { "id": "grok-3-latest", "name": "Grok 3 Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -103904,12 +106226,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 8192 + "maxTokens": 8192, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-3-mini": { "id": "grok-3-mini", "name": "Grok 3 Mini", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -103931,13 +106257,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-3-mini-fast": { "id": "grok-3-mini-fast", "name": "Grok 3 Mini Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -103959,13 +106297,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-3-mini-fast-latest": { "id": "grok-3-mini-fast-latest", "name": "Grok 3 Mini Fast Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -103987,13 +106337,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-3-mini-latest": { "id": "grok-3-mini-latest", "name": "Grok 3 Mini Latest", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104015,13 +106377,25 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-4": { "id": "grok-4", "name": "Grok 4", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104036,20 +106410,15 @@ }, "contextWindow": 256000, "maxTokens": 64000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4-1-fast": { "id": "grok-4-1-fast", "name": "Grok 4.1 Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104065,20 +106434,15 @@ }, "contextWindow": 2000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4-1-fast-non-reasoning": { "id": "grok-4-1-fast-non-reasoning", "name": "Grok 4.1 Fast (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104093,12 +106457,16 @@ "cacheWrite": 0 }, "contextWindow": 2000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4-fast": { "id": "grok-4-fast", "name": "Grok 4 Fast", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104114,20 +106482,15 @@ }, "contextWindow": 2000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4-fast-non-reasoning": { "id": "grok-4-fast-non-reasoning", "name": "Grok 4 Fast (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104142,12 +106505,16 @@ "cacheWrite": 0 }, "contextWindow": 2000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4.20-0309-non-reasoning": { "id": "grok-4.20-0309-non-reasoning", "name": "Grok 4.20 (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104162,12 +106529,16 @@ "cacheWrite": 0 }, "contextWindow": 1000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4.20-0309-reasoning": { "id": "grok-4.20-0309-reasoning", "name": "Grok 4.20 (Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104183,21 +106554,15 @@ }, "contextWindow": 1000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ], - "requiresEffort": true + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4.20-beta-latest-non-reasoning": { "id": "grok-4.20-beta-latest-non-reasoning", "name": "Grok 4.20 Beta (Non-Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104212,12 +106577,16 @@ "cacheWrite": 0 }, "contextWindow": 2000000, - "maxTokens": 30000 + "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-4.20-beta-latest-reasoning": { "id": "grok-4.20-beta-latest-reasoning", "name": "Grok 4.20 Beta (Reasoning)", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104233,21 +106602,15 @@ }, "contextWindow": 2000000, "maxTokens": 30000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ], - "requiresEffort": true + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-4.20-multi-agent-beta-latest": { "id": "grok-4.20-multi-agent-beta-latest", "name": "Grok 4.20 Multi-Agent Beta", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104269,14 +106632,25 @@ "minimal", "low", "medium", - "high" - ] + "high", + "xhigh" + ], + "effortMap": { + "minimal": "low" + } + }, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "supportsReasoningEffort": true, + "omitReasoningEffort": false } }, "grok-4.3": { "id": "grok-4.3", "name": "Grok 4.3", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104292,6 +106666,15 @@ }, "contextWindow": 1000000, "maxTokens": 30000, + "compat": { + "supportsReasoningEffort": true, + "omitReasoningEffort": false, + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + } + }, "thinking": { "mode": "effort", "efforts": [ @@ -104299,13 +106682,16 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } } }, "grok-4.5": { "id": "grok-4.5", "name": "Grok 4.5", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104321,6 +106707,15 @@ }, "contextWindow": 500000, "maxTokens": 500000, + "compat": { + "supportsReasoningEffort": true, + "omitReasoningEffort": false, + "reasoningEffortMap": { + "minimal": "low", + "xhigh": "high", + "max": "high" + } + }, "thinking": { "mode": "effort", "efforts": [ @@ -104328,13 +106723,16 @@ "low", "medium", "high" - ] + ], + "effortMap": { + "minimal": "low" + } } }, "grok-4.6": { "id": "grok-4.6", "name": "Grok 4.6", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104350,20 +106748,31 @@ }, "contextWindow": 500000, "maxTokens": 500000, + "compat": { + "supportsReasoningEffort": true, + "omitReasoningEffort": false, + "reasoningEffortMap": { + "minimal": "low" + } + }, "thinking": { "mode": "effort", "efforts": [ "minimal", "low", "medium", - "high" - ] + "high", + "xhigh" + ], + "effortMap": { + "minimal": "low" + } } }, "grok-beta": { "id": "grok-beta", "name": "Grok Beta", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104377,12 +106786,16 @@ "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } }, "grok-build-0.1": { "id": "grok-build-0.1", "name": "Grok Build 0.1", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104398,20 +106811,15 @@ }, "contextWindow": 256000, "maxTokens": 256000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-code-fast-1": { "id": "grok-code-fast-1", "name": "Grok Code Fast 1", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": true, @@ -104426,20 +106834,15 @@ }, "contextWindow": 256000, "maxTokens": 10000, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high" - ] + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true } }, "grok-vision-beta": { "id": "grok-vision-beta", "name": "Grok Vision Beta", - "api": "openai-completions", + "api": "openai-responses", "provider": "xai", "baseUrl": "https://api.x.ai/v1", "reasoning": false, @@ -104454,7 +106857,11 @@ "cacheWrite": 0 }, "contextWindow": 8192, - "maxTokens": 4096 + "maxTokens": 4096, + "compat": { + "supportsReasoningEffort": false, + "omitReasoningEffort": true + } } }, "xai-oauth": { @@ -104478,13 +106885,9 @@ "contextWindow": 2000000, "maxTokens": 2000000, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104510,13 +106913,9 @@ "contextWindow": 2000000, "maxTokens": 2000000, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104554,13 +106953,12 @@ } }, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": false, "supportsReasoningEffort": true @@ -104591,21 +106989,21 @@ "minimal", "low", "medium", - "high", - "xhigh" + "high" ], "effortMap": { "minimal": "low" } }, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { - "minimal": "low" + "minimal": "low", + "xhigh": "high", + "max": "high" }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": false, "supportsReasoningEffort": true @@ -104636,21 +107034,21 @@ "minimal", "low", "medium", - "high", - "xhigh" + "high" ], "effortMap": { "minimal": "low" } }, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { - "minimal": "low" + "minimal": "low", + "xhigh": "high", + "max": "high" }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": false, "supportsReasoningEffort": true @@ -104675,13 +107073,29 @@ }, "contextWindow": 500000, "maxTokens": 500000, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "effortMap": { + "minimal": "low" + } + }, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, - "omitReasoningEffort": true + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-build": { @@ -104704,13 +107118,9 @@ "contextWindow": 512000, "maxTokens": 512000, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104736,13 +107146,9 @@ "contextWindow": 256000, "maxTokens": 256000, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -104767,13 +107173,9 @@ "contextWindow": 200000, "maxTokens": 200000, "supportsComputerUse": false, - "supportsComputerUseConfig": false, "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, + "includeEncryptedReasoning": true, + "filterReasoningHistory": false, "supportsImageDetailOriginal": false, "omitReasoningEffort": true, "supportsReasoningEffort": false @@ -105719,6 +108121,37 @@ ] } }, + "glm-5.3": { + "id": "glm-5.3", + "name": "GLM-5.3", + "api": "anthropic-messages", + "provider": "zai", + "baseUrl": "https://api.z.ai/api/anthropic", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 131072, + "thinking": { + "mode": "anthropic-budget-effort", + "efforts": [ + "low", + "high", + "max" + ], + "defaultLevel": "max", + "requiresEffort": true + }, + "supportsComputerUse": false, + "supportsComputerUseConfig": false + }, "glm-5v-turbo": { "id": "glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -107338,9 +109771,40 @@ "image" ], "cost": { - "input": 1.5, - "output": 7.5, - "cacheRead": 0.15, + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + }, + "supportsComputerUse": false + }, + "google/gemini-3.7-flash": { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "api": "openai-completions", + "provider": "zenmux", + "baseUrl": "https://zenmux.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -107401,7 +109865,7 @@ }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", + "name": "Gemma 4 26B A4B", "api": "openai-completions", "provider": "zenmux", "baseUrl": "https://zenmux.ai/api/v1", @@ -111961,6 +114425,40 @@ ] } }, + "glm-5.3": { + "id": "glm-5.3", + "name": "GLM-5.3", + "api": "openai-completions", + "provider": "zhipu-coding-plan", + "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 131072, + "compat": { + "thinkingFormat": "zai", + "reasoningContentField": "reasoning_content", + "supportsDeveloperRole": false + }, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ], + "defaultLevel": "max", + "requiresEffort": true + } + }, "glm-5v-turbo": { "id": "glm-5v-turbo", "name": "GLM-5V-Turbo", diff --git a/packages/catalog/src/openai-pricing.ts b/packages/catalog/src/openai-pricing.ts new file mode 100644 index 000000000..adfa7aefb --- /dev/null +++ b/packages/catalog/src/openai-pricing.ts @@ -0,0 +1,29 @@ +import type { TokenCost } from "./types"; + +/** Standard GPT-5.6 Sol rates used by the Daybreak Blue aliases. */ +export const OPENAI_GPT_56_SOL_STANDARD_COST = { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 6.25, +} as const satisfies TokenCost; + +/** Standard GPT-5.6 Cyber rates used by the Daybreak Red aliases. */ +export const OPENAI_GPT_56_CYBER_STANDARD_COST = { + input: 12.5, + output: 75, + cacheRead: 1.25, + cacheWrite: 15.625, +} as const satisfies TokenCost; + +/** Resolve standard rates for Codex-prefixed Daybreak aliases. */ +export function resolveOpenAIDaybreakStandardCost(modelId: string): TokenCost | undefined { + switch (modelId) { + case "gpt-daybreak-blue-latest": + return OPENAI_GPT_56_SOL_STANDARD_COST; + case "gpt-daybreak-red-latest": + return OPENAI_GPT_56_CYBER_STANDARD_COST; + default: + return undefined; + } +} diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index 20af095aa..dc1d2afc3 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -478,13 +478,13 @@ export const CATALOG_PROVIDERS = [ }, { id: "xai", - defaultModel: "grok-4-fast-non-reasoning", + defaultModel: "grok-4.5", envVars: ["XAI_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => xaiModelManagerOptions(config), }, { id: "xai-oauth", - defaultModel: "grok-4.3", + defaultModel: "grok-4.5", envVars: ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => xaiOAuthModelManagerOptions(config), catalogDiscovery: { @@ -522,7 +522,7 @@ export const CATALOG_PROVIDERS = [ }, { id: "zai", - defaultModel: "glm-5.2", + defaultModel: "glm-5.3", envVars: ["ZAI_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => zaiModelManagerOptions(config), catalogDiscovery: { label: "zAI" }, diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index bf985c9a5..8ade9a049 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1,5 +1,6 @@ import { USER_AGENT } from "@oh-my-pi/pi-utils"; import * as logger from "@oh-my-pi/pi-utils/logger"; +import { xaiResponsesReasoningEffortMap } from "../compat/openai"; import { DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, fetchOpenAICompatibleModels, @@ -20,7 +21,17 @@ import { import { resolveModelReference } from "../identity/reference"; import type { ModelManagerOptions } from "../model-manager"; import { type GeneratedProvider, getBundledModels } from "../models"; -import type { Api, FetchImpl, Model, ModelSpec, OpenAICompat, Provider, ThinkingConfig } from "../types"; +import { OPENAI_GPT_56_CYBER_STANDARD_COST, OPENAI_GPT_56_SOL_STANDARD_COST } from "../openai-pricing"; +import type { + Api, + FetchImpl, + LongContextTokenCost, + Model, + ModelSpec, + OpenAICompat, + Provider, + ThinkingConfig, +} from "../types"; import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "../wire/alibaba-token-plan"; import { coreWeaveProjectHeaders } from "../wire/coreweave"; @@ -869,6 +880,7 @@ export function umansModelManagerOptions(config?: UmansModelManagerConfig): Mode // --------------------------------------------------------------------------- const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; +/** GPT-5.6 rates applied when a first-party request exceeds 272K input tokens. */ export const OPENAI_GPT_56_LONG_CONTEXT_COSTS = { luna: { inputThreshold: 272_000, @@ -891,20 +903,7 @@ export const OPENAI_GPT_56_LONG_CONTEXT_COSTS = { cacheRead: 0.4, cacheWrite: 5, }, -} as const; -const OPENAI_GPT_56_SOL_STANDARD_COST = { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, - longContext: OPENAI_GPT_56_LONG_CONTEXT_COSTS.sol, -} as const; -const OPENAI_GPT_56_CYBER_STANDARD_COST = { - input: 12.5, - output: 75, - cacheRead: 1.25, - cacheWrite: 15.625, -} as const; +} as const satisfies Readonly>; export interface OpenAIModelManagerConfig { apiKey?: string; @@ -938,7 +937,10 @@ export const OPENAI_DAYBREAK_CURATED_FALLBACK_MODELS: readonly ModelSpec<"openai baseUrl: OPENAI_API_BASE_URL, reasoning: true, input: ["text", "image"], - cost: OPENAI_GPT_56_SOL_STANDARD_COST, + cost: { + ...OPENAI_GPT_56_SOL_STANDARD_COST, + longContext: OPENAI_GPT_56_LONG_CONTEXT_COSTS.sol, + }, contextWindow: 1_050_000, maxTokens: 128_000, }, @@ -1257,8 +1259,23 @@ export interface XaiModelManagerConfig { fetch?: FetchImpl; } -export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-completions"> { - return createSimpleOpenAICompletionsOptions("xai", "https://api.x.ai/v1", config); +export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-responses"> { + return { + ...createOpenAICompatibleModelManagerOptions({ + api: "openai-responses", + providerId: "xai", + defaultBaseUrl: "https://api.x.ai/v1", + config, + requireApiKey: true, + mapModel: mapWithBundledReference, + }), + // Completions → Responses migration: a fresh authoritative cache written + // by the old resolver stores `api: "openai-completions"` for these ids. + // Without a drop list, `online-if-uncached` skips the network and + // `mergeDynamicModel` lets the cached api win over the new static + // Responses entries until TTL expiry. + dropCachedModelIdsOnStaticMismatch: getBundledModels("xai").map(model => model.id), + }; } export interface XaiOAuthModelManagerConfig { @@ -1316,6 +1333,7 @@ export const XAI_OAUTH_CURATED_MODELS: readonly XAICuratedModel[] = [ }, { id: "grok-4.3", contextWindow: 1_000_000, name: "Grok 4.3", input: ["text", "image"] }, { id: "grok-4.5", contextWindow: 500_000, name: "Grok 4.5", input: ["text", "image"] }, + { id: "grok-4.6", contextWindow: 500_000, name: "Grok 4.6", input: ["text", "image"] }, // grok-4.20-multi-agent-0309 is text-only per the bundled catalog; omit `input` for the default. { id: "grok-4.20-multi-agent-0309", contextWindow: 2_000_000, name: "Grok 4.20 (Multi-Agent)" }, { @@ -1352,21 +1370,47 @@ const XAI_NON_CHAT_PREFIXES = ["grok-imagine-", "grok-stt-", "grok-voice-"] as c function withXaiOAuthCompatDefaults(model: ModelSpec<"openai-responses">): ModelSpec<"openai-responses"> { const compat = { ...(model.compat ?? {}), - includeEncryptedReasoning: model.compat?.includeEncryptedReasoning ?? false, - filterReasoningHistory: model.compat?.filterReasoningHistory ?? true, + includeEncryptedReasoning: model.compat?.includeEncryptedReasoning ?? true, + filterReasoningHistory: model.compat?.filterReasoningHistory ?? false, supportsImageDetailOriginal: model.compat?.supportsImageDetailOriginal ?? false, omitReasoningEffort: model.compat?.omitReasoningEffort ?? !isGrokReasoningEffortCapable(model.id), }; return { ...model, compat }; } -// Hermes-agent parity: only the `minimal -> low` clamp is applied (see -// hermes-agent/agent/transports/codex.py:92 `_effort_clamp = {"minimal": -// "low"}`). Hermes sends `xhigh` to xAI verbatim and we match that contract -// — let xAI decide if the level is valid for the specific Grok model. -// `resolveModelThinking` folds this into `model.thinking.effortMap`, downstream -// of the omitReasoningEffort gate in pi-ai's stream.ts. -const XAI_REASONING_EFFORT_MAP = { minimal: "low" } as const; +// Hermes-agent parity for `minimal -> low` (see hermes-agent/agent/transports/ +// codex.py:92). Multi-agent Grok keeps `xhigh` unmapped (agent-count mode); +// other first-party SKUs clamp leftover `xhigh`/`max` to `high`. +// `resolveModelThinking` folds this into `model.thinking.effortMap`. + +/** + * Bake first-party xAI Responses effort-dial metadata onto a catalog spec. + * + * models.dev marks many Grok SKUs as reasoners and the thinking rebake would + * otherwise emit a default `minimal/low/medium/high` dial. api.x.ai only + * accepts `reasoning.effort` for {@link isGrokReasoningEffortCapable} ids — + * off-allowlist reasoners (`grok-code-fast-1`, `grok-build-0.1`, + * `grok-4.20-0309-reasoning`, …) 400 if the param is sent. SuperGrok + * (`xai-oauth`) already curates this via {@link mergeCuratedIntoModel}; paid + * `xai` rows come from stencil.so and need the same wire facts in the exported + * `models.json` so direct catalog readers do not present an unsupported dial. + * + * Explicit `compat.supportsReasoningEffort` / `omitReasoningEffort` win. + */ +export function applyXaiResponsesThinkingPolicy(model: ModelSpec<"openai-responses">): ModelSpec<"openai-responses"> { + const effortCapable = model.compat?.supportsReasoningEffort ?? isGrokReasoningEffortCapable(model.id); + const compat = { + ...(model.compat ?? {}), + supportsReasoningEffort: effortCapable, + omitReasoningEffort: model.compat?.omitReasoningEffort ?? !effortCapable, + }; + if (effortCapable) { + compat.reasoningEffortMap = { ...xaiResponsesReasoningEffortMap(model.id) }; + } else { + delete compat.reasoningEffortMap; + } + return { ...model, compat }; +} // xai-oauth's /v1/models exposes no per-request output limit on the OAuth // (Grok Build / SuperGrok) surface, so the curated catalog owns `maxTokens` @@ -1381,9 +1425,9 @@ const XAI_REASONING_EFFORT_MAP = { minimal: "low" } as const; // reasoning metadata and fetchOpenAICompatibleModels defaults reasoning to // false). Caller supplies a `base` Model (either a freshly synthesised seed // or a dynamic-fetched entry); the helper layers curated fields on top. -// The `minimal -> low` effort clamp (XAI_REASONING_EFFORT_MAP) is always -// merged in so dynamic-fetched models — which arrive without curated -// compat keys — still get the clamp applyResponsesReasoningParams expects. +// The effort remap from {@link xaiResponsesReasoningEffortMap} is merged +// only onto effort-capable rows. Off-allowlist reasoners omit the wire +// param, so a map on those specs is dead weight. // The effort-dial pair (`supportsReasoningEffort`/`omitReasoningEffort`) is // authoritative: a stale flag on `base` (previous snapshot or dynamic fetch) // must not outlive an allowlist change in identity/family.ts. @@ -1394,13 +1438,17 @@ function mergeCuratedIntoModel( const effortCapable = curated.supportsReasoningEffort ?? isGrokReasoningEffortCapable(curated.id); const compat = { ...(base.compat ?? {}), - reasoningEffortMap: { ...XAI_REASONING_EFFORT_MAP, ...(base.compat?.reasoningEffortMap ?? {}) }, - includeEncryptedReasoning: base.compat?.includeEncryptedReasoning ?? false, - filterReasoningHistory: base.compat?.filterReasoningHistory ?? true, + includeEncryptedReasoning: base.compat?.includeEncryptedReasoning ?? true, + filterReasoningHistory: false, supportsImageDetailOriginal: base.compat?.supportsImageDetailOriginal ?? false, omitReasoningEffort: !effortCapable, supportsReasoningEffort: effortCapable, }; + if (effortCapable) { + compat.reasoningEffortMap = { ...xaiResponsesReasoningEffortMap(curated.id) }; + } else { + delete compat.reasoningEffortMap; + } return { ...base, contextWindow: curated.contextWindow, @@ -1502,7 +1550,7 @@ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<"openai-res cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: curated.contextWindow, maxTokens: curated.contextWindow, - compat: { reasoningEffortMap: XAI_REASONING_EFFORT_MAP }, + compat: { reasoningEffortMap: xaiResponsesReasoningEffortMap(curated.id) }, }; return mergeCuratedIntoModel(base, curated); }); @@ -3634,15 +3682,19 @@ export function basetenModelManagerOptions( const features = Array.isArray(raw.supported_features) ? raw.supported_features : []; const modalities = Array.isArray(raw.input_modalities) ? raw.input_modalities : []; - // Baseten's reasoning router accepts only the high/max - // effort tiers for its GLM-5.2 and gpt-oss routes. - const isEffortReasoning = + // Baseten's discovery flags are not enough to enable OMP reasoning for every + // model. Only models with a verified Baseten reasoning policy are enabled + // here; an unknown model may use a different reasoning wire shape or effort + // vocabulary, which OMP must not guess. + const isSupportedBasetenReasoningModel = + isKimiK3ModelId(defaults.id) || defaults.id === "openai/gpt-oss-120b" || + defaults.id === "deepseek-ai/DeepSeek-V4-Pro" || defaults.id === "zai-org/GLM-5.2" || defaults.id === "zai-org/GLM-5.2-Fast"; - const isBasetenNativeReasoning = isEffortReasoning || defaults.id === "deepseek-ai/DeepSeek-V4-Pro"; const reasoning = - isBasetenNativeReasoning && (features.includes("reasoning") || features.includes("reasoning_effort")); + isSupportedBasetenReasoningModel && + (features.includes("reasoning") || features.includes("reasoning_effort")); const supportsTools = features.includes("tools") ? undefined : false; const vision = modalities.includes("image") || (reference?.input.includes("image") ?? false); @@ -3656,14 +3708,7 @@ export function basetenModelManagerOptions( const contextWindow = toPositiveNumber(raw.context_length, reference?.contextWindow ?? defaults.contextWindow); const maxTokens = toPositiveNumber(raw.max_completion_tokens, reference?.maxTokens ?? defaults.maxTokens); - const baseModel = mapWithBundledReference(entry, defaults, reference); - const thinking = isEffortReasoning - ? { - mode: "effort" as const, - efforts: [Effort.High, Effort.Max], - } - : undefined; return { ...baseModel, @@ -3672,7 +3717,6 @@ export function basetenModelManagerOptions( cost, contextWindow, maxTokens, - ...(thinking ? { thinking } : {}), ...(supportsTools === false ? { supportsTools } : {}), }; }, @@ -5713,6 +5757,15 @@ function openAiCompletionsDescriptor( return simpleModelsDevDescriptor(modelsDevKey, providerId, "openai-completions", baseUrl, options); } +function openAiResponsesDescriptor( + modelsDevKey: string, + providerId: string, + baseUrl: string, + options: Omit = {}, +): ModelsDevProviderDescriptor { + return simpleModelsDevDescriptor(modelsDevKey, providerId, "openai-responses", baseUrl, options); +} + function anthropicMessagesDescriptor( modelsDevKey: string, providerId: string, @@ -5837,7 +5890,9 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CORE: readonly ModelsDevProviderDescriptor defaultContextWindow: 131072, }), // --- xAI --- - openAiCompletionsDescriptor("xai", "xai", "https://api.x.ai/v1"), + openAiResponsesDescriptor("xai", "xai", "https://api.x.ai/v1", { + transformModel: model => applyXaiResponsesThinkingPolicy(model as ModelSpec<"openai-responses">), + }), // --- DeepSeek --- openAiCompletionsDescriptor("deepseek", "deepseek", "https://api.deepseek.com", { // Only ship the v4 family as built-ins; older deepseek-chat / deepseek-reasoner diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index 729ab4d89..5760208f9 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -363,6 +363,13 @@ export interface OpenAICompat { * model id. Default: true. Issue #5606. */ supportsSamplingParams?: boolean; + /** + * Whether presence/frequency penalties and stop sequences may be sent. + * First-party xAI `/v1/responses` rejects penalty fields for every model. + * xAI reasoning models also reject them (and `stop`) on chat completions. + * When unset, auto-detected. Default: true. + */ + supportsPenaltyAndStopParams?: boolean; /** Always send a max-token field when the caller did not provide one. Default: auto-detected (Kimi-family models derive TPM limits from max_tokens). */ alwaysSendMaxTokens?: boolean; /** Whether Responses-API tool-call/result history must be strictly paired. Default: auto-detected (Azure OpenAI, GitHub Copilot). */ @@ -578,6 +585,7 @@ export interface ResolvedOpenAISharedCompat { reasoningEffortMap: Partial>; supportsReasoningParams: boolean; supportsSamplingParams: boolean; + supportsPenaltyAndStopParams: boolean; thinkingFormat: OpenAIReasoningFormat; /** Kimi Code transport selected by live per-model protocol metadata. */ kimiApiFormat?: OpenAICompat["kimiApiFormat"]; @@ -643,6 +651,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & | "reasoningEffortMap" | "supportsReasoningParams" | "supportsSamplingParams" + | "supportsPenaltyAndStopParams" | "thinkingFormat" | "kimiApiFormat" | "reasoningDisableMode" @@ -711,6 +720,12 @@ export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompa strictResponsesPairing: boolean; supportsImageDetailOriginal: boolean; supportsObfuscationOptOut: boolean; + /** + * Whether `reasoning.summary` may be sent. First-party xAI `/v1/responses` + * rejects the field; handlers pass `null` so the wire omits it instead of + * filling `"auto"`. + */ + supportsReasoningSummary: boolean; streamIdleTimeoutMs?: number; vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"]; /** The model sits behind Vercel AI Gateway's Responses endpoint. */ diff --git a/packages/catalog/test/baseten-provider.test.ts b/packages/catalog/test/baseten-provider.test.ts index 85d817f27..2e61990f1 100644 --- a/packages/catalog/test/baseten-provider.test.ts +++ b/packages/catalog/test/baseten-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { basetenModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; @@ -28,6 +29,20 @@ describe("Baseten provider discovery", () => { input_cache_read: "0.00000016", }, }, + { + id: "moonshotai/Kimi-K3", + object: "model", + name: "Kimi K3", + context_length: 1048576, + max_completion_tokens: 262144, + supported_features: ["tools", "json_mode", "structured_outputs", "reasoning_effort"], + input_modalities: ["text", "image"], + pricing: { + prompt: "0.000003", + completion: "0.000015", + input_cache_read: "0.0000003", + }, + }, { id: "deepseek-ai/DeepSeek-V4-Pro", object: "model", @@ -90,6 +105,15 @@ describe("Baseten provider discovery", () => { }, }); + const kimiK3 = models?.find(model => model.id === "moonshotai/Kimi-K3"); + if (!kimiK3) throw new Error("Baseten Kimi K3 was not discovered"); + expect(kimiK3.reasoning).toBe(true); + expect(buildModel(kimiK3).thinking).toMatchObject({ + mode: "effort", + efforts: ["low", "high", "max"], + defaultLevel: "max", + }); + const deepseek = models?.find(model => model.id === "deepseek-ai/DeepSeek-V4-Pro"); expect(deepseek).toBeDefined(); expect(deepseek).toMatchObject({ @@ -110,14 +134,10 @@ describe("Baseten provider discovery", () => { const glmFast = models?.find(model => model.id === "zai-org/GLM-5.2-Fast"); expect(glmFast).toBeDefined(); - expect(glmFast).toMatchObject({ - provider: "baseten", - api: "openai-completions", - reasoning: true, - thinking: { - mode: "effort", - efforts: ["high", "max"], - }, + if (!glmFast) throw new Error("Baseten GLM-5.2 Fast was not discovered"); + expect(buildModel(glmFast).thinking).toMatchObject({ + mode: "effort", + efforts: ["high", "max"], }); }); }); diff --git a/packages/catalog/test/build.test.ts b/packages/catalog/test/build.test.ts index cf9a672d2..db22929aa 100644 --- a/packages/catalog/test/build.test.ts +++ b/packages/catalog/test/build.test.ts @@ -223,12 +223,15 @@ describe("buildModel", () => { }); }); -describe("xAI-OAuth Responses reasoning-effort suppression", () => { - const grokResponsesSpec = (id: string): ModelSpec<"openai-responses"> => ({ +describe("xAI Responses reasoning-effort suppression", () => { + const grokResponsesSpec = ( + id: string, + provider: "xai" | "xai-oauth" = "xai-oauth", + ): ModelSpec<"openai-responses"> => ({ id, name: id, api: "openai-responses", - provider: "xai-oauth", + provider, baseUrl: "https://api.x.ai/v1", reasoning: true, input: ["text"], @@ -248,6 +251,47 @@ describe("xAI-OAuth Responses reasoning-effort suppression", () => { expect(buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.3")).supportsReasoningEffort).toBe(true); }); + it("applies the same Responses dialect to paid xai and xai-oauth", () => { + const paid = buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.3", "xai")); + const oauth = buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.3", "xai-oauth")); + expect(paid.promptCacheSessionHeader).toBe("x-grok-conv-id"); + expect(oauth.promptCacheSessionHeader).toBe("x-grok-conv-id"); + expect(paid.includeEncryptedReasoning).toBe(true); + expect(oauth.includeEncryptedReasoning).toBe(true); + expect(paid.filterReasoningHistory).toBe(false); + expect(oauth.filterReasoningHistory).toBe(false); + expect(paid.supportsImageDetailOriginal).toBe(false); + expect(oauth.supportsImageDetailOriginal).toBe(false); + expect(paid.supportsReasoningEffort).toBe(true); + expect(oauth.supportsReasoningEffort).toBe(true); + expect(paid.reasoningEffortMap).toEqual({ minimal: "low", xhigh: "high", max: "high" }); + expect(oauth.reasoningEffortMap).toEqual({ minimal: "low", xhigh: "high", max: "high" }); + expect( + buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.20-multi-agent-0309", "xai")).reasoningEffortMap, + ).toEqual({ minimal: "low" }); + expect(paid.supportsPenaltyAndStopParams).toBe(false); + expect(oauth.supportsPenaltyAndStopParams).toBe(false); + expect(paid.supportsReasoningSummary).toBe(false); + expect(oauth.supportsReasoningSummary).toBe(false); + }); + + it("suppresses penalty params on every first-party xAI Responses model", () => { + const reasoning = buildOpenAIResponsesCompat(grokResponsesSpec("grok-4.5", "xai")); + const nonReasoning = buildOpenAIResponsesCompat({ + ...grokResponsesSpec("grok-2", "xai"), + reasoning: false, + }); + expect(reasoning.supportsPenaltyAndStopParams).toBe(false); + expect(nonReasoning.supportsPenaltyAndStopParams).toBe(false); + }); + + it("omits effort for paid xai models off the Grok allowlist", () => { + const compat = buildOpenAIResponsesCompat(grokResponsesSpec("grok-code-fast-1", "xai")); + expect(compat.supportsReasoningEffort).toBe(false); + expect(compat.omitReasoningEffort).toBe(true); + expect(buildModel(grokResponsesSpec("grok-code-fast-1", "xai")).thinking).toBeUndefined(); + }); + it("lets an explicit compat.supportsReasoningEffort override the allowlist default", () => { const compat = buildOpenAIResponsesCompat({ ...grokResponsesSpec("grok-build"), @@ -256,7 +300,7 @@ describe("xAI-OAuth Responses reasoning-effort suppression", () => { expect(compat.supportsReasoningEffort).toBe(true); }); - it("does not suppress effort for a non-xai-oauth provider with a grok-like id", () => { + it("does not suppress effort for a non-xAI provider with a grok-like id", () => { const compat = buildOpenAIResponsesCompat({ ...grokResponsesSpec("grok-build"), provider: "openai", diff --git a/packages/catalog/test/codex-discovery.test.ts b/packages/catalog/test/codex-discovery.test.ts index 45cc7cd83..7cf08217e 100644 --- a/packages/catalog/test/codex-discovery.test.ts +++ b/packages/catalog/test/codex-discovery.test.ts @@ -103,7 +103,7 @@ describe("Codex model discovery", () => { expect(legacy?.useResponsesLite).toBeUndefined(); }); - it("falls back to the 372K window for GPT-5.6 SKUs when upstream omits context_window (#5705)", async () => { + it("floors GPT-5.6 luna/sol/terra at the 1M window when upstream omits context_window (#5705)", async () => { const fetchFn: typeof fetch = Object.assign( async () => new Response( @@ -138,12 +138,12 @@ describe("Codex model discovery", () => { }); const sol = result?.models.find(model => model.id === "gpt-5.6-sol"); - expect(sol?.contextWindow).toBe(372_000); + expect(sol?.contextWindow).toBe(1_000_000); const legacy = result?.models.find(model => model.id === "gpt-5.5"); expect(legacy?.contextWindow).toBe(272_000); }); - it("normalizes Codex Daybreak aliases to the GPT-5.6 window and effort ladder", async () => { + it("normalizes Codex Daybreak aliases to GPT-5.6 capabilities and pricing", async () => { const fetchFn: typeof fetch = Object.assign( async () => new Response( @@ -157,6 +157,15 @@ describe("Codex model discovery", () => { input_modalities: ["text", "image"], supported_in_api: true, }, + { + slug: "gpt-daybreak-red-latest", + display_name: "Daybreak Red", + context_window: 400_000, + default_reasoning_level: "high", + supported_reasoning_levels: ["minimal", "low", "medium", "high", "xhigh"], + input_modalities: ["text", "image"], + supported_in_api: true, + }, ], }), ), @@ -168,20 +177,25 @@ describe("Codex model discovery", () => { clientVersion: "0.99.0", fetchFn, }); - const spec = result?.models.find(model => model.id === "gpt-daybreak-blue-latest"); - if (!spec) throw new Error("Expected discovered Daybreak model"); + const blue = result?.models.find(model => model.id === "gpt-daybreak-blue-latest"); + if (!blue) throw new Error("Expected discovered Daybreak Blue model"); + const red = result?.models.find(model => model.id === "gpt-daybreak-red-latest"); + if (!red) throw new Error("Expected discovered Daybreak Red model"); - expect(spec.contextWindow).toBe(372_000); - expect(getSupportedEfforts(buildModel(spec))).toEqual([ + expect(blue.contextWindow).toBe(372_000); + expect(getSupportedEfforts(buildModel(blue))).toEqual([ Effort.Low, Effort.Medium, Effort.High, Effort.XHigh, Effort.Max, ]); + expect(blue.cost).toEqual({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }); + expect(red.contextWindow).toBe(400_000); + expect(red.cost).toEqual({ input: 12.5, output: 75, cacheRead: 1.25, cacheWrite: 15.625 }); }); - it("honors context_window when upstream actively reports it for GPT-5.6 SKUs", async () => { + it("floors stale reported windows for GPT-5.6 luna/sol/terra and honors reports above the floor", async () => { const fetchFn: typeof fetch = Object.assign( async () => new Response( @@ -196,6 +210,15 @@ describe("Codex model discovery", () => { input_modalities: ["text", "image"], supported_in_api: true, }, + { + slug: "gpt-5.6-terra", + display_name: "GPT-5.6-Terra", + context_window: 1_050_000, + default_reasoning_level: "medium", + supported_reasoning_levels: ["low", "medium", "high"], + input_modalities: ["text", "image"], + supported_in_api: true, + }, { slug: "gpt-5.5", display_name: "GPT-5.5", @@ -217,8 +240,13 @@ describe("Codex model discovery", () => { fetchFn, }); + // Registry still reports the pre-1M 272000 for sol; the floor must win. const sol = result?.models.find(model => model.id === "gpt-5.6-sol"); - expect(sol?.contextWindow).toBe(272_000); + expect(sol?.contextWindow).toBe(1_000_000); + // Reports above the floor are honored as-is. + const terra = result?.models.find(model => model.id === "gpt-5.6-terra"); + expect(terra?.contextWindow).toBe(1_050_000); + // Non-floored SKUs keep the actively reported value. const legacy = result?.models.find(model => model.id === "gpt-5.5"); expect(legacy?.contextWindow).toBe(272_000); }); diff --git a/packages/catalog/test/generated-policies.test.ts b/packages/catalog/test/generated-policies.test.ts index 3c88063ba..45674a517 100644 --- a/packages/catalog/test/generated-policies.test.ts +++ b/packages/catalog/test/generated-policies.test.ts @@ -120,9 +120,9 @@ describe("generated model policies", () => { expect(models[4]?.cost.longContext).toBeUndefined(); }); - it("pins GPT-5.6 Codex-transport context window to the 372K hard capacity (#5705)", () => { + it("floors GPT-5.6 Codex-transport context windows at 1M (openai/codex#38917)", () => { const models: ModelSpec[] = [ - // Codex discovery underreports these via DEFAULT_CONTEXT_WINDOW=272000. + // Codex discovery/registry still reports the stale 272000 for these. createSpec({ id: "gpt-5.6-luna", api: "openai-codex-responses", @@ -155,9 +155,9 @@ describe("generated model policies", () => { applyGeneratedModelPolicies(models); - expect(models[0]?.contextWindow).toBe(372000); - expect(models[1]?.contextWindow).toBe(372000); - expect(models[2]?.contextWindow).toBe(372000); + expect(models[0]?.contextWindow).toBe(1_000_000); + expect(models[1]?.contextWindow).toBe(1_000_000); + expect(models[2]?.contextWindow).toBe(1_000_000); expect(models[3]?.contextWindow).toBe(1050000); expect(models[4]?.contextWindow).toBe(272000); }); @@ -240,6 +240,40 @@ describe("generated model policies", () => { expect(models[0]?.maxTokens).toBe(131_072); }); + it("pins zai glm-5.3 to 1M context and derives uniform low/high/max thinking with mandatory reasoning", () => { + const models = [ + createSpec({ + id: "glm-5.3", + api: "anthropic-messages", + provider: "zai", + contextWindow: 200_000, + maxTokens: 8192, + }), + createSpec({ + id: "glm-5.3", + api: "openai-completions", + provider: "zhipu-coding-plan", + contextWindow: 200_000, + maxTokens: 8192, + }), + ]; + + applyGeneratedModelPolicies(models); + + // Context pinning — same 1M tier as glm-5.2 on both GLM coding-plan hosts. + for (const model of models) { + expect(model.contextWindow).toBe(1_000_000); + expect(model.maxTokens).toBe(131_072); + // Uniform wire-exact low/high/max ladder (NOT the host-specific + // high/max scale GLM-5.2 uses on zai/zhipu). + expect(model.thinking?.efforts).toEqual([Effort.Low, Effort.High, Effort.Max]); + // Thinking can no longer be disabled. + expect(model.thinking?.requiresEffort).toBe(true); + // Default effort is `max` per the GLM-5.3 API spec. + expect(model.thinking?.defaultLevel).toBe(Effort.Max); + } + }); + it("pins MiniMax-M3 long-context providers to 1M context", () => { const models = [ createSpec({ @@ -357,11 +391,11 @@ describe("generated model policies", () => { expect(models[0]?.compat?.supportsToolChoice).toBe(false); }); - it("sets OpenCode Go DeepSeek V4 tool-call request compat", () => { - const models: ModelSpec<"openai-completions">[] = [ + it("sets OpenCode Go DeepSeek V4 tool-call request compat for both OpenAI APIs", () => { + const models: ModelSpec[] = [ createSpec({ id: "deepseek-v4-flash", - api: "openai-completions", + api: "openai-responses", provider: "opencode-go", }), createSpec({ @@ -467,6 +501,43 @@ describe("generated model policies", () => { expect(models[2]?.applyPatchToolType).toBeUndefined(); expect(models[3]?.applyPatchToolType).toBeUndefined(); }); + + it("strips paid xAI Responses effort dials for off-allowlist reasoners", () => { + const models: ModelSpec<"openai-responses">[] = [ + createSpec({ + id: "grok-code-fast-1", + api: "openai-responses", + provider: "xai", + thinking: { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, + }), + createSpec({ + id: "grok-4.5", + api: "openai-responses", + provider: "xai", + thinking: { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, + }), + createSpec({ + id: "grok-code-fast-1", + api: "openai-responses", + provider: "openrouter", + thinking: { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, + }), + ]; + + applyGeneratedModelPolicies(models); + + expect(models[0]?.thinking).toBeUndefined(); + expect(models[0]?.compat).toMatchObject({ + supportsReasoningEffort: false, + omitReasoningEffort: true, + }); + expect(models[0]?.compat).not.toHaveProperty("reasoningEffortMap"); + expect(models[1]?.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(models[1]?.compat?.supportsReasoningEffort).toBe(true); + // Non-xAI hosts are outside this policy — no baked no-dial compat. + expect(models[2]?.thinking).toBeDefined(); + expect(models[2]?.compat?.supportsReasoningEffort).toBeUndefined(); + }); }); describe("applyOllamaCloudOutputCap", () => { diff --git a/packages/catalog/test/identity-family.test.ts b/packages/catalog/test/identity-family.test.ts index ffba1b095..52b57dedd 100644 --- a/packages/catalog/test/identity-family.test.ts +++ b/packages/catalog/test/identity-family.test.ts @@ -5,7 +5,9 @@ import { isGeminiModelId, isGlmVisionModelId, isGrokModelId, + isGrokMultiAgentModelId, isGrokReasoningEffortCapable, + isGrokXHighEffortCapable, isKimiK26ModelId, isKimiModelId, isMinimaxM2FamilyModelId, @@ -339,6 +341,7 @@ describe("isGrokReasoningEffortCapable", () => { expect(isGrokReasoningEffortCapable("grok-4.20-multi-agent")).toBe(true); expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.3")).toBe(true); expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.5")).toBe(true); + expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.6")).toBe(true); expect(isGrokReasoningEffortCapable("openrouter/xai/grok-3-mini")).toBe(true); }); @@ -349,3 +352,35 @@ describe("isGrokReasoningEffortCapable", () => { expect(isGrokReasoningEffortCapable("")).toBe(false); }); }); + +describe("isGrokMultiAgentModelId", () => { + test("matches grok-4.20-multi-agent SKUs across namespaces", () => { + expect(isGrokMultiAgentModelId("grok-4.20-multi-agent")).toBe(true); + expect(isGrokMultiAgentModelId("grok-4.20-multi-agent-0309")).toBe(true); + expect(isGrokMultiAgentModelId("xai/grok-4.20-multi-agent-beta-latest")).toBe(true); + }); + + test("rejects other Grok ids", () => { + expect(isGrokMultiAgentModelId("grok-4.5")).toBe(false); + expect(isGrokMultiAgentModelId("grok-4.6")).toBe(false); + expect(isGrokMultiAgentModelId("grok-4.20-0309-reasoning")).toBe(false); + expect(isGrokMultiAgentModelId("")).toBe(false); + }); +}); + +describe("isGrokXHighEffortCapable", () => { + test("matches grok-4.6 and multi-agent SKUs across namespaces", () => { + expect(isGrokXHighEffortCapable("grok-4.6")).toBe(true); + expect(isGrokXHighEffortCapable("xai/grok-4.6")).toBe(true); + expect(isGrokXHighEffortCapable("xai-oauth/grok-4.6")).toBe(true); + expect(isGrokXHighEffortCapable("grok-4.20-multi-agent-0309")).toBe(true); + }); + + test("rejects Grok SKUs that clamp leftover xhigh to high", () => { + expect(isGrokXHighEffortCapable("grok-4.5")).toBe(false); + expect(isGrokXHighEffortCapable("grok-4.3")).toBe(false); + expect(isGrokXHighEffortCapable("grok-3-mini")).toBe(false); + expect(isGrokXHighEffortCapable("grok-build")).toBe(false); + expect(isGrokXHighEffortCapable("")).toBe(false); + }); +}); diff --git a/packages/catalog/test/issue-887-repro.test.ts b/packages/catalog/test/issue-887-repro.test.ts deleted file mode 100644 index 2fac1dbee..000000000 --- a/packages/catalog/test/issue-887-repro.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Repro for #887 — OpenCode Go: Minimax M2.7 (and Qwen3.5/3.6 Plus) return 404 - * because the resolver routes them to anthropic-messages /v1/messages while - * the OpenCode Go gateway only serves them at /v1/chat/completions. - * - * stencil.so declares these ids with `provider.npm = "@ai-sdk/anthropic"`, - * which by default would resolve to anthropic-messages on opencode-go. The - * descriptor must override these specific ids to openai-completions so that - * regenerated models.json keeps the correct routing. - */ -import { describe, expect, test } from "bun:test"; -import { - MODELS_DEV_PROVIDER_DESCRIPTORS, - type ModelsDevModel, - opencodeGoModelManagerOptions, -} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; - -const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; - -describe("opencode-go resolver routes 404-ing ids to openai-completions (issue #887)", () => { - const descriptor = MODELS_DEV_PROVIDER_DESCRIPTORS.find(d => d.providerId === "opencode-go"); - - // Per upstream stencil.so (verified 2026-05-02 against - // https://stencil.so/api.json["opencode-go"].models), these three ids carry - // `provider.npm = "@ai-sdk/anthropic"`. The naive @ai-sdk/anthropic rule - // would route them to /v1/messages on opencode.ai/zen/go which 404s. - const npmAnthropic: ModelsDevModel = { provider: { npm: "@ai-sdk/anthropic" }, tool_call: true }; - - test.each([["minimax-m2.7"], ["qwen3.5-plus"], ["qwen3.6-plus"]])( - "%s resolves to openai-completions on /v1/chat/completions", - modelId => { - const resolved = descriptor?.resolveApi?.(modelId, npmAnthropic); - expect(resolved).toEqual({ api: "openai-completions", baseUrl: OPENCODE_GO_BASE }); - }, - ); - - test("minimax-m2.5 (control: works empirically) also resolves to openai-completions", () => { - // stencil.so currently lists minimax-m2.5 without an explicit provider.npm, - // so it falls through to the default openai-completions resolution. - const m25: ModelsDevModel = { tool_call: true }; - const resolved = descriptor?.resolveApi?.("minimax-m2.5", m25); - expect(resolved).toEqual({ api: "openai-completions", baseUrl: OPENCODE_GO_BASE }); - }); - - test("runtime /v1/models refresh preserves qwen3.7-max Anthropic transport", async () => { - let requestedUrl = ""; - const fetchMock = (async (input: string | Request | URL): Promise => { - requestedUrl = input instanceof Request ? input.url : String(input); - return new Response( - JSON.stringify({ - data: [{ id: "qwen3.7-max", name: "Qwen3.7 Max", context_length: 1000000 }], - }), - { headers: { "content-type": "application/json" } }, - ); - }) as typeof fetch; - - const options = opencodeGoModelManagerOptions({ apiKey: "opencode-test-key", fetch: fetchMock }); - const models = await options.fetchDynamicModels?.(); - const qwenMax = models?.find(model => model.id === "qwen3.7-max"); - - expect(requestedUrl).toBe("https://opencode.ai/zen/go/v1/models"); - expect(qwenMax?.api).toBe("anthropic-messages"); - expect(qwenMax?.baseUrl).toBe("https://opencode.ai/zen/go"); - }); -}); diff --git a/packages/catalog/test/model-thinking.test.ts b/packages/catalog/test/model-thinking.test.ts index e05ec9865..e55e767c0 100644 --- a/packages/catalog/test/model-thinking.test.ts +++ b/packages/catalog/test/model-thinking.test.ts @@ -884,6 +884,65 @@ describe("model thinking runtime helpers", () => { expect(() => requireSupportedEffort(opus46, Effort.XHigh)).toThrow(/not supported/); }); + it("does not expose xhigh on first-party Grok 4.5 Responses models", () => { + const paid = createModel({ + id: "grok-4.5", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + }); + const oauth = createModel({ + id: "grok-4.5", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + }); + + expect(paid.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(oauth.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(() => requireSupportedEffort(paid, Effort.XHigh)).toThrow(/not supported/); + }); + + it("exposes xhigh on first-party Grok 4.6 Responses models", () => { + const paid = createModel({ + id: "grok-4.6", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + }); + const oauth = createModel({ + id: "grok-4.6", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + }); + + expect(paid.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(oauth.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(requireSupportedEffort(paid, Effort.XHigh)).toBe(Effort.XHigh); + expect(paid.compat.reasoningEffortMap?.xhigh).toBeUndefined(); + }); + + it("exposes xhigh on first-party Grok multi-agent Responses models", () => { + const paid = createModel({ + id: "grok-4.20-multi-agent-beta-latest", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + }); + const oauth = createModel({ + id: "grok-4.20-multi-agent-0309", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + }); + + expect(paid.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(oauth.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]); + expect(requireSupportedEffort(paid, Effort.XHigh)).toBe(Effort.XHigh); + expect(paid.compat.reasoningEffortMap?.xhigh).toBeUndefined(); + }); + it("rejects effort requests against un-built reasoning specs", () => { const spec = { id: "broken-reasoner", diff --git a/packages/catalog/test/xai-api-key-responses.test.ts b/packages/catalog/test/xai-api-key-responses.test.ts new file mode 100644 index 000000000..c11849cec --- /dev/null +++ b/packages/catalog/test/xai-api-key-responses.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; +import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; +import { CATALOG_PROVIDERS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { xaiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; + +const XAI_RESPONSES_SPEC: ModelSpec<"openai-responses"> = { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, +}; +const XAI_COMPLETIONS_SPEC: ModelSpec<"openai-completions"> = { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, +}; + +describe("paid xai (XAI_API_KEY) Responses contract", () => { + it("registers xai on the catalog Responses discovery path", () => { + const entry = CATALOG_PROVIDERS.find(provider => provider.id === "xai"); + expect(entry, "xai catalog descriptor").toBeDefined(); + expect(entry!.defaultModel).toBe("grok-4.5"); + expect(entry!.envVars).toContain("XAI_API_KEY"); + const options = xaiModelManagerOptions({ apiKey: "test-key" }); + expect(options.providerId).toBe("xai"); + expect(options.fetchDynamicModels, "live /v1/models overlay").toBeTypeOf("function"); + expect(options.dropCachedModelIdsOnStaticMismatch).toEqual(getBundledModels("xai").map(model => model.id)); + expect(options.dropCachedModelIdsOnStaticMismatch).toContain("grok-4.5"); + }); + + it("bundles every paid xai chat model on openai-responses", () => { + const models = getBundledModels("xai"); + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + expect(model.api, `${model.provider}/${model.id}`).toBe("openai-responses"); + expect(model.baseUrl).toBe("https://api.x.ai/v1"); + } + }); + + it("drops stale Chat Completions cache rows so Responses takes effect immediately", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-xai-completions-cache-")); + const dbPath = path.join(tempDir, "models.db"); + try { + await resolveProviderModels( + { + providerId: "xai", + staticModels: [XAI_COMPLETIONS_SPEC], + fetchDynamicModels: async () => [XAI_COMPLETIONS_SPEC], + cacheDbPath: dbPath, + }, + "online", + ); + + let fetches = 0; + const migrated = await resolveProviderModels( + { + ...xaiModelManagerOptions(), + staticModels: [XAI_RESPONSES_SPEC], + cacheDbPath: dbPath, + fetchDynamicModels: async () => { + fetches += 1; + return [XAI_RESPONSES_SPEC]; + }, + }, + "online-if-uncached", + ); + + expect(fetches).toBe(1); + expect(migrated.models.find(model => model.id === "grok-4.5")?.api).toBe("openai-responses"); + + const offline = await resolveProviderModels( + { + ...xaiModelManagerOptions(), + staticModels: [XAI_RESPONSES_SPEC], + cacheDbPath: dbPath, + fetchDynamicModels: async () => null, + }, + "offline", + ); + expect(offline.models.find(model => model.id === "grok-4.5")?.api).toBe("openai-responses"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/catalog/test/xai-oauth-bundle.test.ts b/packages/catalog/test/xai-oauth-bundle.test.ts new file mode 100644 index 000000000..534cc488c --- /dev/null +++ b/packages/catalog/test/xai-oauth-bundle.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import MODELS_JSON from "@oh-my-pi/pi-catalog/models.json" with { type: "json" }; +import { CATALOG_PROVIDERS, DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { buildXaiOAuthStaticSeed } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; + +// Pins the invariant: bundled `models.json` carries every entry the runtime +// curated catalog (XAI_OAUTH_CURATED_MODELS, surfaced via +// buildXaiOAuthStaticSeed) emits. Without this, editing the curated list +// without regenerating `models.json` silently regresses the boot-time +// default-model resolver — the registry sees the runtime seed only after +// `refresh()`, but interactive boot resolves the persisted default +// synchronously from `#loadModels()`, which reads only `models.json`. +// +// Failure here means: run `bun run gen:models` and commit the diff. +describe("xai-oauth bundled catalog (regression)", () => { + const bundled = + (MODELS_JSON as unknown as Record>>)["xai-oauth"] ?? {}; + const seed = buildXaiOAuthStaticSeed(); + + it("defaults SuperGrok selection to grok-4.5", () => { + const entry = CATALOG_PROVIDERS.find(provider => provider.id === "xai-oauth"); + expect(entry?.defaultModel).toBe("grok-4.5"); + expect(DEFAULT_MODEL_PER_PROVIDER["xai-oauth"]).toBe("grok-4.5"); + expect(bundled["grok-4.5"], "xai-oauth/grok-4.5 must be bundled for the default").toBeDefined(); + }); + + it("bundles every curated id", () => { + const seededIds = seed.map(model => model.id).sort(); + const bundledIds = Object.keys(bundled).sort(); + expect(bundledIds).toEqual(seededIds); + }); + + for (const seededModel of seed) { + it(`matches contract for ${seededModel.id}`, () => { + const bundledEntry = bundled[seededModel.id]; + expect(bundledEntry, `xai-oauth/${seededModel.id} missing from models.json`).toBeDefined(); + expect(bundledEntry.id).toBe(seededModel.id); + expect(bundledEntry.name).toBe(seededModel.name); + expect(bundledEntry.provider).toBe("xai-oauth"); + expect(bundledEntry.api).toBe("openai-responses"); + expect(bundledEntry.contextWindow).toBe(seededModel.contextWindow); + expect(bundledEntry.reasoning).toBe(seededModel.reasoning); + // Input modality must survive both the curated seed and the bundle. + // Without this the static fallback used on offline boot strips + // vision capability silently (Codex PR #1127 review). + expect(bundledEntry.input).toEqual(seededModel.input); + expect(bundledEntry.compat?.supportsReasoningEffort).toBe(seededModel.compat?.supportsReasoningEffort); + }); + } + + // Absolute contract for the user-specified SuperGrok addition. The parity + // loop above can't catch a value typo (e.g. 2_000_000) or a flipped + // reasoning flag — both sides regenerate from the same seed together — so + // pin the literal attributes here. + it("exposes grok-composer-2.5-fast as a non-reasoning 200K text model", () => { + const composer = seed.find(model => model.id === "grok-composer-2.5-fast"); + expect(composer, "grok-composer-2.5-fast must be in the SuperGrok curated seed").toBeDefined(); + expect(composer!.reasoning).toBe(false); + expect(composer!.contextWindow).toBe(200_000); + expect(composer!.input).toEqual(["text"]); + // The bundled models.json entry is byte-identical to the generator's + // deterministic xai-oauth output: gen:models pushes + // buildXaiOAuthStaticSeed() (offline — xai-oauth has no upstream catalog + // source) and applyGeneratedModelPolicies(), so a regen reproduces these + // exact bytes; only unrelated other-provider network churn was excluded + // to keep the diff scoped. Pin its zero-cost invariant (overlay-stable + // for the SuperGrok subscription), which the parity loop above never + // compares. (maxTokens is pinned by the maxTokens-equals-contextWindow + // test below.) + expect(bundled["grok-composer-2.5-fast"]?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + }); + + // The OAuth surface's /v1/models reports no per-request output limit, so the + // curated catalog owns maxTokens — set to mirror each model's contextWindow + // (the openai-responses wire still clamps the actual request to + // OPENAI_MAX_OUTPUT_TOKENS). Pin maxTokens === contextWindow on both the + // static-seed and bundled paths so a null placeholder can + // never silently leak back into the bundle. + it("sets maxTokens equal to contextWindow for every xai-oauth model", () => { + for (const model of seed) { + expect(model.maxTokens, `seed ${model.id} maxTokens`).toBe(model.contextWindow); + expect(bundled[model.id]?.maxTokens, `bundled ${model.id} maxTokens`).toBe(model.contextWindow); + } + }); +}); diff --git a/packages/catalog/test/xai-responses-thinking-policy.test.ts b/packages/catalog/test/xai-responses-thinking-policy.test.ts new file mode 100644 index 000000000..2843552dc --- /dev/null +++ b/packages/catalog/test/xai-responses-thinking-policy.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import MODELS_JSON from "@oh-my-pi/pi-catalog/models.json" with { type: "json" }; +import { + MODELS_DEV_PROVIDER_DESCRIPTORS, + mapModelsDevToModels, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; +import { applyGeneratedModelPolicies } from "../scripts/generated-policies"; + +const XAI_MODELS_DEV_FIXTURE = { + xai: { + models: { + "grok-4.5": { + name: "Grok 4.5", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 500_000, output: 500_000 }, + cost: { input: 2, output: 6, cache_read: 0.3 }, + }, + "grok-4.6": { + name: "Grok 4.6", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 500_000, output: 500_000 }, + cost: { input: 2, output: 6, cache_read: 0.5 }, + }, + "grok-code-fast-1": { + name: "Grok Code Fast 1", + tool_call: true, + reasoning: true, + modalities: { input: ["text"] }, + limit: { context: 256_000, output: 10_000 }, + cost: { input: 0.2, output: 1.5 }, + }, + "grok-build-0.1": { + name: "Grok Build 0.1", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 256_000, output: 256_000 }, + cost: { input: 0, output: 0 }, + }, + "grok-4.20-0309-reasoning": { + name: "Grok 4.20 (Reasoning)", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 2_000_000, output: 64_000 }, + cost: { input: 2, output: 6 }, + }, + "grok-2": { + name: "Grok 2", + tool_call: true, + reasoning: false, + modalities: { input: ["text"] }, + limit: { context: 131_072, output: 8192 }, + cost: { input: 2, output: 10 }, + }, + "grok-4.20-multi-agent-beta-latest": { + name: "Grok 4.20 (Multi-Agent)", + tool_call: true, + reasoning: true, + modalities: { input: ["text"] }, + limit: { context: 2_000_000, output: 64_000 }, + cost: { input: 2, output: 6 }, + }, + }, + }, +}; + +describe("paid xAI Responses thinking policy", () => { + it("bakes the effort-dial allowlist on stencil.so → openai-responses mapping", () => { + const mapped = mapModelsDevToModels(XAI_MODELS_DEV_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "xai", + ); + const byId = Object.fromEntries(mapped.map(model => [model.id, model])); + + expect(byId["grok-4.5"]?.api).toBe("openai-responses"); + expect(byId["grok-4.5"]?.compat).toMatchObject({ + supportsReasoningEffort: true, + omitReasoningEffort: false, + reasoningEffortMap: { minimal: "low" }, + }); + for (const id of ["grok-code-fast-1", "grok-build-0.1", "grok-4.20-0309-reasoning"] as const) { + expect(byId[id]?.reasoning, id).toBe(true); + expect(byId[id]?.compat, id).toMatchObject({ + supportsReasoningEffort: false, + omitReasoningEffort: true, + }); + expect(byId[id]?.compat, id).not.toHaveProperty("reasoningEffortMap"); + } + expect(byId["grok-2"]?.compat).toMatchObject({ + supportsReasoningEffort: false, + omitReasoningEffort: true, + }); + expect(byId["grok-2"]?.compat).not.toHaveProperty("reasoningEffortMap"); + }); + + it("strips stale thinking dials from off-allowlist paid xAI reasoners during generation", () => { + const mapped = mapModelsDevToModels(XAI_MODELS_DEV_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "xai", + ); + // Snapshot-era Completions rows still carry a default effort ladder after the + // api flip; the generator must not re-emit that dial for Responses. + const snapshotStale = mapped.find(model => model.id === "grok-code-fast-1"); + expect(snapshotStale).toBeDefined(); + snapshotStale!.thinking = { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }; + + applyGeneratedModelPolicies(mapped); + const byId = Object.fromEntries(mapped.map(model => [model.id, model])); + + expect(byId["grok-4.5"]?.thinking).toEqual({ + mode: "effort", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High], + effortMap: { minimal: "low" }, + }); + expect(byId["grok-4.6"]?.thinking).toEqual({ + mode: "effort", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + effortMap: { minimal: "low" }, + }); + expect(byId["grok-4.6"]?.compat).toMatchObject({ + supportsReasoningEffort: true, + reasoningEffortMap: { minimal: "low" }, + }); + expect(byId["grok-4.6"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + expect(byId["grok-4.20-multi-agent-beta-latest"]?.thinking).toEqual({ + mode: "effort", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh], + effortMap: { minimal: "low" }, + }); + expect(byId["grok-4.20-multi-agent-beta-latest"]?.compat).toMatchObject({ + supportsReasoningEffort: true, + reasoningEffortMap: { minimal: "low" }, + }); + expect(byId["grok-4.20-multi-agent-beta-latest"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + for (const id of ["grok-code-fast-1", "grok-build-0.1", "grok-4.20-0309-reasoning"] as const) { + expect(byId[id]?.reasoning, id).toBe(true); + expect(byId[id]?.thinking, id).toBeUndefined(); + expect(byId[id]?.compat, id).toMatchObject({ supportsReasoningEffort: false }); + } + }); + + it("exports no-dial rows in the bundled models.json snapshot", () => { + const bundled = + (MODELS_JSON as unknown as Record>>).xai ?? {}; + for (const id of ["grok-code-fast-1", "grok-build-0.1", "grok-4.20-0309-reasoning"] as const) { + expect(bundled[id], `xai/${id} missing from models.json`).toBeDefined(); + expect(bundled[id]?.reasoning, id).toBe(true); + expect(bundled[id]?.thinking, id).toBeUndefined(); + expect(bundled[id]?.compat?.supportsReasoningEffort, id).toBe(false); + } + expect(bundled["grok-4.5"]?.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]); + expect(bundled["grok-4.5"]?.thinking?.efforts).not.toContain(Effort.XHigh); + expect(bundled["grok-4.5"]?.compat?.supportsReasoningEffort).toBe(true); + expect(bundled["grok-4.6"]?.thinking?.efforts).toContain(Effort.XHigh); + expect(bundled["grok-4.6"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + expect(bundled["grok-4.20-multi-agent-beta-latest"]?.thinking?.efforts).toContain(Effort.XHigh); + expect(bundled["grok-4.20-multi-agent-beta-latest"]?.compat).not.toMatchObject({ + reasoningEffortMap: { xhigh: "high" }, + }); + }); +}); diff --git a/packages/catalog/test/zhipu-compat.test.ts b/packages/catalog/test/zhipu-compat.test.ts index b69b9afb7..e6d82df3a 100644 --- a/packages/catalog/test/zhipu-compat.test.ts +++ b/packages/catalog/test/zhipu-compat.test.ts @@ -63,7 +63,7 @@ function zhipuGlm52ByOfficialBaseUrl(): ModelSpec<"openai-completions"> { describe("zhipu-coding-plan descriptor", () => { it("defaults to the same Zhipu-hosted model used by login validation", () => { expect(DEFAULT_MODEL_PER_PROVIDER["zhipu-coding-plan"]).toBe("glm-5.1"); - expect(DEFAULT_MODEL_PER_PROVIDER.zai).toBe("glm-5.2"); + expect(DEFAULT_MODEL_PER_PROVIDER.zai).toBe("glm-5.3"); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 977ce179d..089640459 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,77 @@ ### Added - Added `ExtensionAPI.registerFileWriteFallback(handler)` and `ExtensionAPI.registerFileDeleteFallback(handler)`, letting an extension supply a fallback writer or deleter that is consulted when a native `write`, `edit`, or `apply_patch` byte-write or unlink is denied with a permission error (`EPERM`/`EACCES`/`EROFS`) — for hosts that embed the agent inside a sandbox that denies direct filesystem access but exposes a privileged channel. The brokered path is symlink-resolved so a handler's allowlist sees the real destination, a destination that cannot be resolved is not brokered at all, and `req.sessionId` names the session that issued the mutation so a handler sharing the process-wide registry can enforce policy per session. See [`docs/extensions.md`](../../docs/extensions.md). +### Fixed + +- Fixed `omp stats` and `/stats` dashboards being unreachable from container hosts by accepting an explicit `--host` bind address while preserving the `127.0.0.1` default. + +## [17.3.5] - 2026-08-16 + +### Added + +- Added Extensions tab group to settings schema + +### Changed + +- Routed paid xAI models (XAI_API_KEY / xai/…) through the Responses API used by SuperGrok OAuth instead of Chat Completions, including reliable replay of encrypted reasoning content on follow-up turns. +- Updated the default model for XAI_API_KEY (xai) to grok-4.5, and the default SuperGrok OAuth (xai-oauth) model to grok-4.5. Automatic model selection continues to prefer paid xai/grok-4.5 when only XAI_API_KEY is set, with xai-oauth/grok-4.5 still available explicitly. +- Stopped sending presence/frequency penalties and stop sequences to xAI reasoning models such as grok-4.5, which reject them. + +### Fixed + +- Fixed `hub` job and wait lists hiding stale running subagent registrations that have no turn in flight, ensuring they remain visible so operators can cancel them +- Fixed external thinking scratchpads running alongside native reasoning on xAI Grok 4 and other reasoning-only Responses models that reject `reasoning.effort` +- Fixed llama.cpp model discovery producing a baseUrl without the /v1 prefix for non-Qwen models, causing 404 errors on OpenAI-compatible endpoints. +- Fixed prompt caching on open-weight providers (DeepSeek, Qwen, GLM, …) so tool schemas stay cached across directory changes and midnight rollovers. +- Fixed omp --fork omitting the source session's artifact directory, so CLI-created forks now preserve artifact:// references like interactive /fork. +- Fixed long ask option labels being hard-truncated at the terminal width; labels now wrap onto indented continuation lines. +- Fixed toggling display.showTokenUsage from /settings leaving existing token-usage rows stale until the transcript was rebuilt. +- Fixed mid-run auto-compaction blocking the live loop while waiting on extension handlers, which could hang after a snapcompact or context-full pass. +- Reduced peak memory for persisted subagent revival probes by streaming large file-backed session journals instead of loading them fully. +- Improved responsiveness of streaming edit previews for large diffs by rendering only the visible tail. +- Fixed repeated /btw panels committing transient frames to native scrollback and replaying conversation history after dismissal. +- Clarified that closing browser tool sessions releases managed handles without closing pages in spawned, CDP-connected, or relay browsers. +- Fixed interrupted vibe_wait calls being reported as elapsed timeout windows. +- Improved checkpoint/rewind prompt rendering to stay accurate after a rewind and be more concise. +- Fixed Cursor turns dying with HTTP/2 stream errors (NGHTTP2_INTERNAL_ERROR / NGHTTP2_REFUSED_STREAM) after tool calls already had results, instead of leaving the agent idle until the user typed "continue". +- Fixed mixed-case plugin tool names being lowercased during tool-set refresh, which unmounted them from xd:// whenever MCP tools connected. +- Fixed Exa MCP servers being unmounted when their config explicitly requests tools the native Exa integration does not provide, breaking /mcp reconnect exa. +- Fixed Claude Code custom tool discovery attempting to import non-module files from .claude/tools. +- Fixed Agent Hub parking a mid-spawn child session so subsequent task calls failed with an ownership error and the row could never be revived. +- Fixed the welcome banner displaying a stale model name when the session's active model changes after startup (e.g. after a delayed config load or an explicit /model switch). +- Fixed Nix standalone binaries retaining Bun's build-time package in their runtime closure. +- Fixed birch user/custom message card contrast on dark terminals, where chat bubbles could render light-on-light. +- Fixed hidden tool snapshots preventing long streamed assistant responses from entering terminal scrollback. +- Prevented omp models from loading ambient hook factories while preserving extension-contributed providers. +- Fixed the ask dialog's multi-select mode dead-ending on Enter; Space now toggles options and Enter submits the current selection. +- Fixed workspace diagnostics reporting a clean workspace when its checker crashed without producing output. +- Fixed manual /compact failing outright when a summarization request hit a transient provider overload. +- Fixed transient Anthropic failures (overloaded_error, rate_limit_error, 429/500/502/503/529) aborting or silently degrading side-effect-free background LLM calls such as session title generation, TTS speech enhancement, commit-message generation, thinking/stop classifiers, memory extraction/consolidation, and commit analysis/summary/changelog passes; these now retry with backoff honoring retry-after instead of failing or returning an indistinguishable empty result. +- Fixed the shared headless browser daemon launching from the macOS system Google Chrome bundle, which could cause macOS to route the user's link clicks to the automation daemon and silently swallow them; the daemon now prefers an isolated Chrome for Testing binary on macOS. +- Reclaimed abandoned daemon runtime directories under ~/.omp/run/daemons/, preventing unbounded growth of leftover Chromium profiles and broker state. +- Kept the welcome screen's Tips, LSP Servers, and Recent sessions visible when a long model name still leaves enough terminal width for both columns. +- Fixed focused shimmer animation frames (ultrathink, orchestrate, workflowz) repainting the full TUI too frequently, causing high CPU usage while composing prompts on WSL2. +- Fixed the /debug report bundle including unrelated historic sessions, leaking other sessions' files and bloating archives. +- Fixed adopted keep-alive agents remaining stuck in a running state in the registry after deferred turn settlement, and prevented stale refs from sustaining bare hub wait calls indefinitely. +- Fixed home-relative marketplace catalog paths not being expanded before cache access, preventing updates from writing into a literal ~ directory. +- Fixed broker-owned headless Chromium opening and retaining an unowned blank foreground window on Windows. +- Fixed the auto thinking classifier failing every turn on Anthropic models served through LiteLLM/Vertex due to a thinking-budget mismatch. +- Fixed always-ask approval prompts bypassing edit preview readiness when a built-in tool executes under its wire-level alias, such as edit running as apply_patch. +- Fixed lsp reload crashing non-rust-analyzer language servers by sending them a rust-analyzer-specific request; that request is now gated to rust-analyzer only. +- Fixed browser open failing with "Shared browser daemon unavailable" when HTTP_PROXY/HTTPS_PROXY is set, because liveness probes were incorrectly routed through the proxy. +- Fixed defaultThinkingLevel: auto skipping classification for user-invoked /skill: turns, leaving the effort stuck on pending auto. +- Fixed custom-tool directory discovery recursing into subtrees despite a non-recursive default, which could crash startup when scanning large dependency directories such as Python venvs. +- Repaired torn session JSONL appends after disk-write failures, rewrote malformed resumed files before their next append, retried transient persistence failures, and surfaced failures in the TUI. +- Prevented Anthropic model fallback from replaying model-bound thinking blocks across models, and surfaced immutable-thinking errors without retrying the unchanged invalid turn. +- Fixed empty-stop failure messages always suggesting a context problem even when the provider billed output tokens; the message now reports the billed token count and points at a provider-side filter/translation issue when appropriate. +- Fixed a parked, session-less agent-registry entry with no reviver permanently poisoning its agent id, preventing fresh subagent spawns from reusing that id. +- Made extension tool-call timeouts configurable and paused them during user dialogs. +- Fixed /vibe cancellation leaving an in-flight model turn unaware that Vibe mode and its tools were removed. +- Fixed empty local-model stops lingering on the persisted active branch after retries, preventing them from resurfacing after reload or a mid-retry process kill. +- Fixed the Biome linter client silently dropping every diagnostic due to an outdated JSON output schema; it now supports Biome 2.x's diagnostic format. +- Fixed `hub jobs` and empty `hub wait` snapshots hiding running subagents that have no live turn, which removed the only way to discover and `hub cancel` a stale registration; such agents are listed again and flagged as having no turn in flight. +- Fixed external thinking being offered on xAI reasoning-only Responses models (grok-4 family) that reject `reasoning.effort`, where the private scratchpad ran alongside native reasoning instead of replacing it. +- Fixed the extension tool-call handler timeout rendering outside a titled section in `/settings` by registering its Extensions group on the Tools tab. ## [17.3.4] - 2026-08-14 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index d60a312e0..b37fbc91f 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "17.3.4", + "version": "17.3.5", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/src/auto-thinking/classifier.ts b/packages/coding-agent/src/auto-thinking/classifier.ts index 70a00f3a3..70ac125ad 100644 --- a/packages/coding-agent/src/auto-thinking/classifier.ts +++ b/packages/coding-agent/src/auto-thinking/classifier.ts @@ -14,7 +14,7 @@ * Throws on any failure (no model, no key, unparseable output, abort/timeout); * the caller falls back to a concrete level and continues the turn. */ -import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { type AssistantMessage, completeSimple, Effort, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { prompt } from "@oh-my-pi/pi-utils"; @@ -60,14 +60,24 @@ function difficultySystemPromptFor(ceiling: Effort): string { /** Local classifiers occasionally need more room for chat-template boilerplate. */ const LOCAL_ANSWER_MAX_TOKENS = 16; +/** On-device reasoning classifiers need room for the bucket keyword after the `` preamble. */ +const LOCAL_REASONING_MAX_TOKENS = 1024; /** - * Online classifier budget. Sized to survive backends that ignore - * `disableReasoning` (e.g. Qwen3 via llama.cpp catalogued `reasoning: false` - * but still emitting thinking): the classifier keyword needs to land after any - * unavoidable thinking preamble. `maxTokens` is a hard cap — non-thinking - * completions still return in a handful of tokens (issue #4355). + * Online classifier budget. Sized against two independent constraints: + * - Backends that ignore `disableReasoning` still emit a thinking preamble + * (e.g. Qwen3 via llama.cpp catalogued `reasoning: false` but still thinking; + * Anthropic via LiteLLM/Vertex, whose `openai-completions` route downgrades a + * disabled request to the lowest reasoning effort instead of turning thinking + * off). The classifier keyword must have room to land after that preamble + * (issue #4355). + * - Anthropic-dialect proxies reject `max_tokens <= thinking.budget_tokens`. The + * pinned lowest effort maps to at least Anthropic's 1024-token minimum budget, + * so the cap MUST comfortably exceed 1024 or every classifier call 400s with + * `max_tokens must be greater than thinking.budget_tokens` (issue #8610). + * `maxTokens` is a hard cap — non-thinking completions still return in a handful + * of tokens. */ -const REASONING_SAFE_MAX_TOKENS = 1024; +const ONLINE_REASONING_SAFE_MAX_TOKENS = 4096; export interface ClassifyDifficultyDeps { settings: Settings; @@ -113,21 +123,25 @@ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps, ceili } // Resolve metadata after getApiKey so the session-sticky credential is recorded first. const metadata = deps.metadataResolver?.(model.provider); - const maxTokens = REASONING_SAFE_MAX_TOKENS; + const maxTokens = ONLINE_REASONING_SAFE_MAX_TOKENS; - const response = await completeSimple( - model, - { - systemPrompt: [difficultySystemPromptFor(ceiling)], - messages: [{ role: "user", content: input, timestamp: Date.now() }], - }, - { - apiKey: deps.registry.resolver(model, deps.sessionId), - maxTokens, - disableReasoning: true, - metadata, - signal: deps.signal, - }, + const response = await retryTransientCompletion( + () => + completeSimple( + model, + { + systemPrompt: [difficultySystemPromptFor(ceiling)], + messages: [{ role: "user", content: input, timestamp: Date.now() }], + }, + { + apiKey: deps.registry.resolver(model, deps.sessionId), + maxTokens, + disableReasoning: true, + metadata, + signal: deps.signal, + }, + ), + { signal: deps.signal }, ); if (response.stopReason === "error") { @@ -147,7 +161,7 @@ async function classifyLocal(input: string, modelKey: string, deps: ClassifyDiff throw new Error(`auto-thinking: unsupported local classifier model: ${modelKey}`); } const maxTokens = isTinyMemoryReasoningModelKey(modelKey) - ? Math.max(LOCAL_ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) + ? Math.max(LOCAL_ANSWER_MAX_TOKENS, LOCAL_REASONING_MAX_TOKENS) : LOCAL_ANSWER_MAX_TOKENS; const builtPrompt = prompt.render(difficultyLocalPrompt, { prompt: input }); const text = await tinyModelClient.complete(modelKey, builtPrompt, { diff --git a/packages/coding-agent/src/cli/models-cli.ts b/packages/coding-agent/src/cli/models-cli.ts index 0e35cda65..18ac2345a 100644 --- a/packages/coding-agent/src/cli/models-cli.ts +++ b/packages/coding-agent/src/cli/models-cli.ts @@ -304,7 +304,7 @@ export async function runModelsListing(options: RunModelsListingOptions): Promis cwd, eventBus, disableExtensionDiscovery ? undefined : disabledExtensionIds, - { ambient: !disableExtensionDiscovery }, + { ambient: !disableExtensionDiscovery, includeAmbientHooks: false }, ); const extensionRunner = extensionsResult.extensions.length > 0 diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index fc453afbf..7f99557da 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -5,7 +5,7 @@ */ import { truncateToWidth } from "@oh-my-pi/pi-tui/utils"; -import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; +import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; import chalk from "@oh-my-pi/pi-utils/chalk"; import { openPath } from "../utils/open"; @@ -57,45 +57,11 @@ function shortenSessionFile(p: string): string { export interface StatsCommandArgs { port: number; + host: string; json: boolean; summary: boolean; } -// ============================================================================= -// Argument Parser -// ============================================================================= - -/** - * Parse stats subcommand arguments. - * Returns undefined if not a stats command. - */ -export function parseStatsArgs(args: string[]): StatsCommandArgs | undefined { - if (args.length === 0 || args[0] !== "stats") { - return undefined; - } - - const result: StatsCommandArgs = { - port: 3847, - json: false, - summary: false, - }; - - for (let i = 1; i < args.length; i++) { - const arg = args[i]; - if (arg === "--json" || arg === "-j") { - result.json = true; - } else if (arg === "--summary" || arg === "-s") { - result.summary = true; - } else if ((arg === "--port" || arg === "-p") && i + 1 < args.length) { - result.port = parseInt(args[++i], 10); - } else if (arg.startsWith("--port=")) { - result.port = parseInt(arg.split("=")[1], 10); - } - } - - return result; -} - function formatCost(n: number): string { if (n < 0.01) return `$${n.toFixed(4)}`; if (n < 1) return `$${n.toFixed(3)}`; @@ -112,9 +78,8 @@ function normalizePremiumRequests(n: number): number { export async function runStatsCommand(cmd: StatsCommandArgs): Promise { // Lazy import to avoid loading stats module when not needed - const { getDashboardStats, syncAllSessions, getTotalMessageCount, startServer, closeDb } = await import( - "@oh-my-pi/omp-stats" - ); + const { closeDb, formatStatsDashboardUrl, getDashboardStats, getTotalMessageCount, startServer, syncAllSessions } = + await import("@oh-my-pi/omp-stats"); // Sync session files first const progress = createSyncProgressReporter(); @@ -136,8 +101,8 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise { } // Start the dashboard server - const { hostname, port } = await startServer(cmd.port); - const url = `http://${hostname}:${port}`; + const { hostname, port } = await startServer(cmd.port, cmd.host); + const url = formatStatsDashboardUrl(hostname, port); console.log(chalk.green(`Dashboard available at: ${url}`)); // Open browser @@ -197,34 +162,3 @@ async function printStatsSummary(): Promise { console.log(""); } - -// ============================================================================= -// Help -// ============================================================================= - -export function printStatsHelp(): void { - console.log(`${chalk.bold(`${APP_NAME} stats`)} - AI Usage Statistics Dashboard - -${chalk.bold("Usage:")} - ${APP_NAME} stats [options] - -${chalk.bold("Options:")} - -p, --port Port for the dashboard server (default: 3847) - -j, --json Output stats as JSON and exit - -s, --summary Print summary to console and exit - -h, --help Show this help message - -${chalk.bold("Examples:")} - ${APP_NAME} stats # Start dashboard server - ${APP_NAME} stats --json # Print stats as JSON - ${APP_NAME} stats --summary # Print summary to console - ${APP_NAME} stats --port 8080 # Start on custom port - -${chalk.bold("Metrics:")} - - Total requests and error rate - - Token usage (input, output, cache) - - Cost breakdown - - Average duration and time to first token (TTFT) - - Tokens per second throughput -`); -} diff --git a/packages/coding-agent/src/commands/stats.ts b/packages/coding-agent/src/commands/stats.ts index a52800c80..626ce8b64 100644 --- a/packages/coding-agent/src/commands/stats.ts +++ b/packages/coding-agent/src/commands/stats.ts @@ -4,13 +4,15 @@ import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { statsHelp as commandHelp } from "../cli/command-help"; -import { runStatsCommand, type StatsCommandArgs } from "../cli/stats-cli"; -import { initTheme } from "../modes/theme/theme"; +import type { StatsCommandArgs } from "../cli/stats-cli"; +import * as statsCli from "../cli/stats-cli"; +import * as theme from "../modes/theme/theme"; export default class Stats extends Command { static description = commandHelp.description; static flags = { port: Flags.integer({ char: "p", description: "Port for the dashboard server", default: 3847 }), + host: Flags.string({ description: "Host to bind", default: "127.0.0.1" }), json: Flags.boolean({ char: "j", description: "Output stats as JSON", default: false }), summary: Flags.boolean({ char: "s", description: "Print summary to console", default: false }), }; @@ -20,11 +22,12 @@ export default class Stats extends Command { const cmd: StatsCommandArgs = { port: flags.port, + host: flags.host ?? "127.0.0.1", json: flags.json, summary: flags.summary, }; - await initTheme(); - await runStatsCommand(cmd); + await theme.initTheme(); + await statsCli.runStatsCommand(cmd); } } diff --git a/packages/coding-agent/src/commit/analysis/conventional.ts b/packages/coding-agent/src/commit/analysis/conventional.ts index 4f8c4ee75..6256c62bf 100644 --- a/packages/coding-agent/src/commit/analysis/conventional.ts +++ b/packages/coding-agent/src/commit/analysis/conventional.ts @@ -1,6 +1,6 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple } from "@oh-my-pi/pi-ai"; +import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" }; import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" }; @@ -50,15 +50,21 @@ export async function generateConventionalAnalysis({ diff, }); - const response = await completeSimple( - model, - { - systemPrompt: [prompt.render(analysisSystemPrompt)], - messages: [{ role: "user", content: userContent, timestamp: Date.now() }], - tools: [ConventionalAnalysisTool], - }, - { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, + const response = await retryTransientCompletion(() => + completeSimple( + model, + { + systemPrompt: [prompt.render(analysisSystemPrompt)], + messages: [{ role: "user", content: userContent, timestamp: Date.now() }], + tools: [ConventionalAnalysisTool], + }, + { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, + ), ); + if (response.stopReason === "error") { + throw new Error(response.errorMessage ?? "provider error"); + } + return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool); } diff --git a/packages/coding-agent/src/commit/analysis/summary.ts b/packages/coding-agent/src/commit/analysis/summary.ts index fba232a40..23d5c26d2 100644 --- a/packages/coding-agent/src/commit/analysis/summary.ts +++ b/packages/coding-agent/src/commit/analysis/summary.ts @@ -1,7 +1,7 @@ import { type } from "@oh-my-pi/omptype"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { completeSimple, retryTransientCompletion, validateToolCall } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import summarySystemPrompt from "../../commit/prompts/summary-system.md" with { type: "text" }; import summaryUserPrompt from "../../commit/prompts/summary-user.md" with { type: "text" }; @@ -52,16 +52,22 @@ export async function generateSummary({ stat, }); - const response = await completeSimple( - model, - { - systemPrompt: [systemPrompt], - messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }], - tools: [SummaryTool], - }, - { apiKey, maxTokens: 200, reasoning: toReasoningEffort(thinkingLevel) }, + const response = await retryTransientCompletion(() => + completeSimple( + model, + { + systemPrompt: [systemPrompt], + messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }], + tools: [SummaryTool], + }, + { apiKey, maxTokens: 200, reasoning: toReasoningEffort(thinkingLevel) }, + ), ); + if (response.stopReason === "error") { + throw new Error(response.errorMessage ?? "provider error"); + } + return parseSummaryFromResponse(response, commitType, scope); } diff --git a/packages/coding-agent/src/commit/changelog/generate.ts b/packages/coding-agent/src/commit/changelog/generate.ts index 921881e33..27752b79a 100644 --- a/packages/coding-agent/src/commit/changelog/generate.ts +++ b/packages/coding-agent/src/commit/changelog/generate.ts @@ -1,7 +1,7 @@ import { type } from "@oh-my-pi/omptype"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { completeSimple, retryTransientCompletion, validateToolCall } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import changelogSystemPrompt from "../../commit/prompts/changelog-system.md" with { type: "text" }; import changelogUserPrompt from "../../commit/prompts/changelog-user.md" with { type: "text" }; @@ -55,16 +55,22 @@ export async function generateChangelogEntries({ stat, diff, }); - const response = await completeSimple( - model, - { - systemPrompt: [prompt.render(changelogSystemPrompt)], - messages: [{ role: "user", content: userContent, timestamp: Date.now() }], - tools: [changelogTool], - }, - { apiKey, maxTokens: 1200, reasoning: toReasoningEffort(thinkingLevel) }, + const response = await retryTransientCompletion(() => + completeSimple( + model, + { + systemPrompt: [prompt.render(changelogSystemPrompt)], + messages: [{ role: "user", content: userContent, timestamp: Date.now() }], + tools: [changelogTool], + }, + { apiKey, maxTokens: 1200, reasoning: toReasoningEffort(thinkingLevel) }, + ), ); + if (response.stopReason === "error") { + throw new Error(response.errorMessage ?? "provider error"); + } + const parsed = parseChangelogResponse(response); return { entries: dedupeEntries(parsed.entries) }; } diff --git a/packages/coding-agent/src/commit/map-reduce/map-phase.ts b/packages/coding-agent/src/commit/map-reduce/map-phase.ts index 7c268baf3..ff014370c 100644 --- a/packages/coding-agent/src/commit/map-reduce/map-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/map-phase.ts @@ -1,6 +1,6 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple } from "@oh-my-pi/pi-ai"; +import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import fileObserverSystemPrompt from "../../commit/prompts/file-observer-system.md" with { type: "text" }; import fileObserverUserPrompt from "../../commit/prompts/file-observer-user.md" with { type: "text" }; @@ -66,7 +66,7 @@ export async function runMapPhase({ messages: [{ role: "user", content: userContent, timestamp: Date.now() }] as Message[], }; - const response = await withRetry( + const response = await retryTransientCompletion( () => completeSimple(model, request, { apiKey, @@ -74,8 +74,7 @@ export async function runMapPhase({ reasoning: toReasoningEffort(thinkingLevel), signal: AbortSignal.timeout(timeoutMs), }), - maxRetries, - retryBackoffMs, + { maxAttempts: maxRetries, baseDelayMs: retryBackoffMs }, ); const observations = parseObservations(response); @@ -176,18 +175,3 @@ async function runWithConcurrency( await Promise.all(runners); return results; } - -async function withRetry(fn: () => Promise, attempts: number, backoffMs: number): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - return await fn(); - } catch (error) { - lastError = error; - if (attempt < attempts - 1) { - await Bun.sleep(backoffMs * (attempt + 1)); - } - } - } - throw lastError; -} diff --git a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts index b0caccd0d..eafac3ff0 100644 --- a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts @@ -1,6 +1,6 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple } from "@oh-my-pi/pi-ai"; +import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" }; import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" }; @@ -35,15 +35,21 @@ export async function runReducePhase({ stat, scope_candidates: scopeCandidates, }); - const response = await completeSimple( - model, - { - systemPrompt: [prompt.render(reduceSystemPrompt)], - messages: [{ role: "user", content: userContent, timestamp: Date.now() }], - tools: [ReduceTool], - }, - { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, + const response = await retryTransientCompletion(() => + completeSimple( + model, + { + systemPrompt: [prompt.render(reduceSystemPrompt)], + messages: [{ role: "user", content: userContent, timestamp: Date.now() }], + tools: [ReduceTool], + }, + { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, + ), ); + if (response.stopReason === "error") { + throw new Error(response.errorMessage ?? "provider error"); + } + return parseConventionalAnalysisResponse(response, ReduceTool); } diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts index 5caafa822..ca0f2427e 100644 --- a/packages/coding-agent/src/config/model-discovery.ts +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -645,7 +645,7 @@ export async function discoverLlamaCppModels( name: id, api: providerConfig.api, provider: providerConfig.provider, - baseUrl, + baseUrl: ensureLlamaCppV1BaseUrl(baseUrl), reasoning: false, input: item.input ?? serverMetadata?.input ?? ["text"], imageInputDecoder: "stb", @@ -1018,7 +1018,7 @@ export async function discoverProxyModels( return discovered; } -function normalizeLlamaCppBaseUrl(baseUrl?: string): string { +export function normalizeLlamaCppBaseUrl(baseUrl?: string): string { const defaultBaseUrl = "http://127.0.0.1:8080"; const raw = baseUrl || defaultBaseUrl; try { @@ -1033,7 +1033,7 @@ function normalizeLlamaCppBaseUrl(baseUrl?: string): string { // ensureLlamaCppV1BaseUrl appends the OpenAI-compatible `/v1` prefix a // chat-completions request needs; native discovery keeps the bare root, which // serves `/models` and `/props` but not `/chat/completions`. -function ensureLlamaCppV1BaseUrl(baseUrl: string): string { +export function ensureLlamaCppV1BaseUrl(baseUrl: string): string { return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; } diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index f2dd6f1b5..738812061 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -61,9 +61,11 @@ import { type DiscoveryProviderConfig, discoverLlamaCppModelRuntimeMetadata, discoverModelsByProviderType, + ensureLlamaCppV1BaseUrl, getImplicitOllamaBaseUrl, getOllamaContextLengthOverride, normalizeLiteLLMDiscoveryBaseUrl, + normalizeLlamaCppBaseUrl, } from "./model-discovery"; import { AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS, @@ -577,7 +579,7 @@ export class ModelRegistry { const withConfigModels = this.#mergeCustomModels(resolvedDefaults, select(this.#customModelOverlays)); const combined = this.#mergeCustomModels(withConfigModels, select(this.#runtimeModelOverlays)); const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides); - return this.#applyLlamaCppQwenThinkingToModels(this.#applyRuntimeProviderOverrides(withModelOverrides)); + return this.#applyLlamaCppModelFixups(this.#applyRuntimeProviderOverrides(withModelOverrides)); } #composeStaticModels(providerFilter?: ReadonlySet): Model[] { @@ -1064,9 +1066,7 @@ export class ModelRegistry { const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays); const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays); const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides); - this.#unprojectedModels = this.#applyLlamaCppQwenThinkingToModels( - this.#applyRuntimeProviderOverrides(withModelOverrides), - ); + this.#unprojectedModels = this.#applyLlamaCppModelFixups(this.#applyRuntimeProviderOverrides(withModelOverrides)); this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels); } @@ -1446,20 +1446,28 @@ export class ModelRegistry { }); } - // #applyLlamaCppQwenThinkingToModels re-runs applyLlamaCppQwenThinking as the - // outermost transform for llama.cpp-provider models, after discovery merges, - // cache fallbacks, and provider/transport overrides have run. It is - // idempotent, so it restores the routed Qwen model's chat-completions api, - // `/v1` runtime base URL, and disable dialect even when a configured `baseUrl` - // override (which wins in mergeDiscoveredModel) or a fallback to a pre-fix - // cached row would otherwise leave the old spec in place. - #applyLlamaCppQwenThinkingToModels(models: Model[]): Model[] { + // #applyLlamaCppModelFixups is the outermost transform for llama.cpp-provider + // models, after discovery merges, cache fallbacks, and provider/transport + // overrides have run. It applies Qwen-specific fixes (api, reasoning, compat) + // and ensures all non-transport models have the `/v1` prefix in their baseUrl, + // even when a configured override or stale cache row would strip it. + #applyLlamaCppModelFixups(models: Model[]): Model[] { const llamaCppProviders = new Set(); for (const provider of this.#discoverableProviders) { if (provider.discovery.type === "llama.cpp") llamaCppProviders.add(provider.provider); } if (llamaCppProviders.size === 0) return models; - return models.map(model => (llamaCppProviders.has(model.provider) ? applyLlamaCppQwenThinking(model) : model)); + return models.map(model => { + if (!llamaCppProviders.has(model.provider)) return model; + const withFixups = applyLlamaCppQwenThinking(model); + if (!withFixups.transport && !withFixups.baseUrl.endsWith("/v1")) { + return buildModel({ + ...withFixups, + baseUrl: ensureLlamaCppV1BaseUrl(normalizeLlamaCppBaseUrl(withFixups.baseUrl)), + }); + } + return withFixups; + }); } #mergeProviderOverride(baseOverride: ProviderOverride | undefined, override: ProviderOverride): ProviderOverride { @@ -1667,17 +1675,22 @@ export class ModelRegistry { * * Side-effect-free and synchronous: a command-backed key (`!cmd`) counts as * configured by its presence alone — the program is NOT executed — and OAuth - * tokens are NOT refreshed (`authStorage.hasAuth`). This is what keeps the + * tokens are NOT refreshed (`authStorage.hasResolvableAuth`). This is what keeps the * model-switch pre-flight off the event loop's hot path; the real key * (command execution + OAuth refresh) is resolved lazily per request via * {@link ModelRegistry.resolver}. + * + * Cross-provider env aliases count here (`xai-oauth` can borrow `XAI_API_KEY`) + * so an explicit `xai-oauth/…` selector does not fail with "No API key". + * Default-model availability still uses {@link AuthStorage.hasAuth}, which + * ignores that alias so SuperGrok is not auto-selected from a paid key. */ hasConfiguredAuth(model: Model): boolean { const keyConfig = this.#customProviderApiKeys.get(model.provider); return ( isCommandConfigValue(keyConfig) || this.#keylessProviders.has(model.provider) || - this.authStorage.hasAuth(model.provider) + this.authStorage.hasResolvableAuth(model.provider) ); } @@ -2097,7 +2110,7 @@ export class ModelRegistry { transportOverride, ); this.#runtimeProviderOverrides.set(providerName, nextRuntimeOverride); - this.#unprojectedModels = this.#applyLlamaCppQwenThinkingToModels( + this.#unprojectedModels = this.#applyLlamaCppModelFixups( this.#unprojectedModels.map(model => { if (model.provider !== providerName) return model; return this.#applyProviderTransportOverrideToModel(model, transportOverride); diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 95325f2c2..49426538d 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -152,6 +152,7 @@ export const TAB_GROUPS: Record = { "Output Limits", "Execution", "Discovery & MCP", + "Extensions", "Developer", ], tasks: ["Modes", "Subagents", "Isolation", "Commands & Skills"], @@ -5494,6 +5495,18 @@ export const SETTINGS_SCHEMA = { "commit.changelogMaxDiffChars": { type: "number", default: 120000 }, + "extensionHandlers.toolCallTimeoutMs": { + type: "number", + default: 30_000, + ui: { + tab: "tools", + group: "Extensions", + label: "Tool Call Handler Timeout (ms)", + description: + "Positive finite active-work timeout for extension tool_call handlers; invalid values use 30000ms, and time awaiting OMP-owned dialogs does not count", + }, + }, + "dev.autoqa": { type: "boolean", default: true, diff --git a/packages/coding-agent/src/debug/report-bundle.ts b/packages/coding-agent/src/debug/report-bundle.ts index 4329c0954..df09371b1 100644 --- a/packages/coding-agent/src/debug/report-bundle.ts +++ b/packages/coding-agent/src/debug/report-bundle.ts @@ -3,6 +3,8 @@ * * Creates a .tar.gz archive with session data, logs, system info, and optional profiling data. */ + +import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { WorkProfile } from "@oh-my-pi/pi-natives"; @@ -66,8 +68,8 @@ export interface DebugLogSource { * * Bundle contents: * - session.jsonl: Current session transcript - * - artifacts/: Session artifacts directory - * - subagents/: Subagent sessions + artifacts + * - artifacts/: Current session's artifacts subtree (recursive), including any + * subagent session transcripts nested under it * - logs.txt: Recent log entries * - system.json: OS, arch, CPU, memory, versions * - env.json: Sanitized environment variables @@ -130,14 +132,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise< // Session file might not exist yet } - // Artifacts directory (same path without .jsonl) + // Artifacts subtree (same path without .jsonl). Recursing captures the + // current session's nested subagent transcripts and their artifacts while + // staying inside this session's own directory — unrelated co-located + // sessions in the sessions root are never touched (#8648). const artifactsDir = options.sessionFile.slice(0, -6); await addDirectoryToArchive(data, files, artifactsDir, "artifacts"); - - // Look for subagent sessions in the same directory - const sessionDir = path.dirname(options.sessionFile); - const sessionBasename = path.basename(options.sessionFile, ".jsonl"); - await addSubagentSessions(data, files, sessionDir, sessionBasename); } // CPU profile @@ -172,68 +172,34 @@ export async function createReportBundle(options: ReportBundleOptions): Promise< return { path: outputPath, files }; } -/** Add all files from a directory to the archive */ +/** Recursively add every file under a directory to the archive. */ async function addDirectoryToArchive( data: Record, files: string[], dirPath: string, archivePrefix: string, ): Promise { + let entries: Dirent[]; try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile()) continue; - const filePath = path.join(dirPath, entry.name); - const archivePath = `${archivePrefix}/${entry.name}`; - try { - const content = await Bun.file(filePath).text(); - data[archivePath] = content; - files.push(archivePath); - } catch { - // Skip files we can't read - } - } + entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch { // Directory doesn't exist + return; } -} - -/** Find and add subagent session files */ -async function addSubagentSessions( - data: Record, - files: string[], - sessionDir: string, - parentBasename: string, -): Promise { - // Subagent sessions are named with task IDs in the same directory - // They follow the pattern: {timestamp}_{sessionId}.jsonl - // We look for any sessions created after the parent session - try { - const entries = await fs.readdir(sessionDir, { withFileTypes: true }); - const sessionFiles = entries - .filter(e => e.isFile() && e.name.endsWith(".jsonl") && e.name !== `${parentBasename}.jsonl`) - .map(e => e.name); - - // Limit to most recent 10 subagent sessions - const sortedFiles = sessionFiles.sort().slice(-10); - - for (const filename of sortedFiles) { - const filePath = path.join(sessionDir, filename); - const archivePath = `subagents/${filename}`; - try { - const content = await Bun.file(filePath).text(); - data[archivePath] = content; - files.push(archivePath); - - // Also add artifacts for this subagent session - const artifactsDir = filePath.slice(0, -6); - await addDirectoryToArchive(data, files, artifactsDir, `subagents/${filename.slice(0, -6)}`); - } catch { - // Skip files we can't read - } + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + const archivePath = `${archivePrefix}/${entry.name}`; + if (entry.isDirectory()) { + await addDirectoryToArchive(data, files, entryPath, archivePath); + continue; + } + if (!entry.isFile()) continue; + try { + data[archivePath] = await Bun.file(entryPath).text(); + files.push(archivePath); + } catch { + // Skip files we can't read } - } catch { - // Directory doesn't exist } } diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts index 86d7e2dd6..50d4dea38 100644 --- a/packages/coding-agent/src/discovery/claude-plugins.ts +++ b/packages/coding-agent/src/discovery/claude-plugins.ts @@ -379,8 +379,9 @@ async function loadTools(ctx: LoadContext): Promise> { roots.map(async root => { const toolsDir = path.join(root.path, "tools"); return loadFilesFromDir(ctx, toolsDir, PROVIDER_ID, root.scope, { + extensions: ["ts", "js"], transform: (name, _content, filePath, source) => { - const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, ""); + const toolName = name.replace(/\.(ts|js)$/, ""); return { name: toolName, path: filePath, diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index 6786c77b7..6f3b7c624 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -406,8 +406,9 @@ async function loadTools(ctx: LoadContext): Promise> { const userToolsDir = path.join(userBase, "tools"); const userResult = await loadFilesFromDir(ctx, userToolsDir, PROVIDER_ID, "user", { + extensions: ["ts", "js"], transform: (name, _content, path, source) => { - const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, ""); + const toolName = name.replace(/\.(ts|js)$/, ""); return { name: toolName, path, @@ -425,8 +426,9 @@ async function loadTools(ctx: LoadContext): Promise> { const projectToolsDir = path.join(projectBase, "tools"); const projectResult = await loadFilesFromDir(ctx, projectToolsDir, PROVIDER_ID, "project", { + extensions: ["ts", "js"], transform: (name, _content, path, source) => { - const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, ""); + const toolName = name.replace(/\.(ts|js)$/, ""); return { name: toolName, path, diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 01456ca83..7a14a9169 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -512,6 +512,11 @@ export async function loadFilesFromDir( gitignore: true, hidden: false, fileType: FileType.File, + // Thread the caller's non-recursive intent explicitly: the native glob + // defaults `recursive` to true and rewrites `*.{ts,js}` -> `**/*.{ts,js}`, + // which would walk the entire subtree (e.g. a venv's site-packages under + // ~/.codex/tools) and import arbitrary frontend assets as tools (#8552). + recursive, }); matches = result.matches; } catch { diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index e0b1422a4..666d60563 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -412,6 +412,42 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string } return preview.trimEnd(); } + +interface StreamingDiffTail { + content: string; + hidden: boolean; +} + +/** + * Select the trailing physical lines that fit the live preview budget. + * + * Walk backward from the end instead of splitting the complete diff. This keeps + * allocation and scanning proportional to the visible suffix. One physical line + * may exceed the budget, but it is still kept so the newest change is visible. + */ +function sliceStreamingDiffTail(diff: string, innerWidth: number, budget: number): StreamingDiffTail { + let end = diff.length; + while (end > 0 && diff.charCodeAt(end - 1) === 10) end--; + if (end === 0) return { content: "", hidden: false }; + + const rowLimit = Math.max(1, budget); + let start = end; + let cursor = end; + let visualRows = 0; + while (cursor >= 0) { + const newline = cursor > 0 ? diff.lastIndexOf("\n", cursor - 1) : -1; + const lineStart = newline + 1; + const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(diff.slice(lineStart, cursor)), innerWidth).length); + if (visualRows > 0 && visualRows + lineRows > rowLimit) break; + visualRows += lineRows; + start = lineStart; + if (newline < 0) break; + cursor = newline; + } + + return { content: diff.slice(start, end), hidden: start > 0 }; +} + function formatStreamingDiff( diff: string, rawPath: string, @@ -442,26 +478,14 @@ function formatStreamingDiff( // its Myers alignment is not monotonic in payload length, so a hunk-aware // window stutters as rows move between hunks. Expanded widens the window // to the viewport; the full diff appears once the result finalizes. - const allLines = diff.replace(/\n+$/u, "").split("\n"); - let visualUsed = 0; - let cut = allLines.length; - for (let i = allLines.length - 1; i >= 0; i--) { - const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(allLines[i]!), innerWidth).length); - if (visualUsed + lineRows > budget && visualUsed > 0) break; - visualUsed += lineRows; - cut = i; - } - const hiddenLines = cut; - const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines; + const tail = sliceStreamingDiffTail(diff, innerWidth, budget); let rendered = "\n\n"; - if (hiddenLines > 0) { - const hiddenHunks = getDiffStats(allLines.slice(0, hiddenLines).join("\n")).hunks; - const remainder: string[] = []; - if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`); - remainder.push(`${hiddenLines} more lines`); - rendered += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`; + if (tail.hidden) { + // Exact hidden line/hunk counts require scanning the discarded prefix, + // which would make every streaming update scale with the complete diff. + rendered += `${uiTheme.fg("dim", "… (content above)")}\n`; } - rendered += renderDiffColored(visible.join("\n"), { filePath: rawPath }); + rendered += renderDiffColored(tail.content, { filePath: rawPath }); return rendered; }); // The animated glyph rides this trailing line — inside the transcript's @@ -864,6 +888,7 @@ function renderSingleFileResult( let diffSectionRenderDiffFn: ((t: string, o?: { filePath?: string }) => string) | undefined; const diffSectionCache = createRenderedStringCache(); + const statsSuffixCache = createRenderedStringCache(); return framedBlock(uiTheme, width => { const { expanded, renderContext } = options; @@ -887,7 +912,11 @@ function renderSingleFileResult( // Change stats ride inline on the header bar next to the path. const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined; const headerDiff = isError ? undefined : details?.diff || previewDiff; - const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : ""; + const statsSuffix = headerDiff + ? cachedRenderedString(statsSuffixCache, uiTheme, false, "", headerDiff, () => + formatDiffStatsSuffix(headerDiff, uiTheme), + ) + : ""; const header = renderEditHeader(width, uiTheme, { icon: isError ? "error" : "success", iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined, diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index a46a18336..b0fb283d3 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -624,6 +624,8 @@ async function discoverHooksInPackageRoot(root: string): Promise { export interface DiscoverExtensionPathOptions { /** Include ambient native extensions, hooks, and installed plugins. */ ambient?: boolean; + /** Include ambient hook factories. Disable for read-only catalog commands. */ + includeAmbientHooks?: boolean; } export async function discoverExtensionPaths( @@ -676,11 +678,13 @@ export async function discoverExtensionPaths( // scans only this invocation's configured package roots; it must not consult // settings, installed packages, or process-global CLI injection state. if (ambient) { - const hooks = await loadCapability(hookCapability.id, loadOptions); - for (const hookPath of hooks.items - .map(hook => hook.path) - .filter(hookPath => isExtensionFile(path.basename(hookPath)))) { - addPath(hookPath); + if (options.includeAmbientHooks !== false) { + const hooks = await loadCapability(hookCapability.id, loadOptions); + for (const hookPath of hooks.items + .map(hook => hook.path) + .filter(hookPath => isExtensionFile(path.basename(hookPath)))) { + addPath(hookPath); + } } } else { for (const configuredPath of configuredPaths) { diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 1ab887270..60e8189d2 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -93,6 +93,10 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void { extensionHandlerTimeoutMs = timeoutMs; } +function normalizeHandlerTimeout(timeoutMs: number): number { + return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : EXTENSION_HANDLER_TIMEOUT_MS; +} + /** * Dedicated cap for `session_shutdown` handlers. The generic 30s budget is * appropriate for events extensions can observe (e.g. `session_start`, @@ -118,6 +122,11 @@ function handlerTimeoutForEvent(eventType: string): number { const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout"); const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted"); +interface HandlerTimeoutBudget { + pause(): void; + resume(): void; +} + function attachHandlerSignal( dialogOptions: ExtensionUIDialogOptions | undefined, handlerSignal: AbortSignal, @@ -128,22 +137,57 @@ function attachHandlerSignal( return { ...dialogOptions, signal: AbortSignal.any([dialogOptions.signal, handlerSignal]) }; } -function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSignal): ExtensionUIContext { +function createHandlerUIContext( + ui: ExtensionUIContext, + handlerSignal: AbortSignal, + timeoutBudget?: HandlerTimeoutBudget, +): ExtensionUIContext { const askDialog = ui.askDialog; + const runDialog = async (dialog: () => Promise): Promise => { + timeoutBudget?.pause(); + try { + return await dialog(); + } finally { + timeoutBudget?.resume(); + } + }; const dialogMethods = { select: (title, options, dialogOptions) => - ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal)), + runDialog(() => ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal))), confirm: (title, message, dialogOptions) => - ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal)), + runDialog(() => ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal))), input: (title, placeholder, dialogOptions) => - ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal)), + runDialog(() => ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal))), askDialog: askDialog ? (questions, dialogOptions) => - askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal)) + runDialog(() => askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal))) : undefined, + custom: async (factory, options) => { + let customSettled = false; + let componentReady = false; + try { + return await ui.custom( + async (...args) => { + const component = await factory(...args); + if (!customSettled) { + timeoutBudget?.pause(); + componentReady = true; + } + return component; + }, + { + ...options, + signal: options?.signal ? AbortSignal.any([options.signal, handlerSignal]) : handlerSignal, + }, + ); + } finally { + customSettled = true; + if (componentReady) timeoutBudget?.resume(); + } + }, editor: (title, prefill, dialogOptions, editorOptions) => - ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions), - } satisfies Pick; + runDialog(() => ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions)), + } satisfies Pick; const delegatedMethods = new Map(); return new Proxy(ui, { @@ -168,10 +212,14 @@ function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSign * `pi.setModel()` and then reading `ctx.model` would see a stale model. * Prototype delegation keeps every getter live while overriding `ui`. */ -function createHandlerContext(ctx: ExtensionContext, handlerSignal: AbortSignal): ExtensionContext { +function createHandlerContext( + ctx: ExtensionContext, + handlerSignal: AbortSignal, + timeoutBudget?: HandlerTimeoutBudget, +): ExtensionContext { const scoped: ExtensionContext = Object.create(ctx); Object.defineProperty(scoped, "ui", { - value: createHandlerUIContext(ctx.ui, handlerSignal), + value: createHandlerUIContext(ctx.ui, handlerSignal, timeoutBudget), enumerable: true, configurable: true, }); @@ -191,7 +239,7 @@ function createHandlerContext(ctx: ExtensionContext, handlerSignal: AbortSignal) * can `clearTimeout` on the winning branch. */ async function raceHandlerWithTimeout( - work: (handlerSignal: AbortSignal) => Promise | T, + work: (handlerSignal: AbortSignal, timeoutBudget: HandlerTimeoutBudget) => Promise | T, timeoutMs: number, signal?: AbortSignal, ): Promise { @@ -204,13 +252,52 @@ async function raceHandlerWithTimeout( >(); const onAbort = () => resolveInterrupt(EXTENSION_HANDLER_ABORTED); signal?.addEventListener("abort", onAbort, { once: true }); - const timer = setTimeout(() => { + let timer: Timer | undefined; + let remainingMs = timeoutMs; + let activeSince = performance.now(); + let pauseDepth = 0; + let settled = false; + const clearTimer = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + const expire = () => { + if (settled) return; + settled = true; + clearTimer(); timeoutController.abort(new DOMException(`Handler timed out after ${timeoutMs}ms`, "TimeoutError")); resolveInterrupt(EXTENSION_HANDLER_TIMEOUT); - }, timeoutMs); + }; + const armTimer = () => { + if (settled || pauseDepth > 0) return; + activeSince = performance.now(); + timer = setTimeout(expire, Math.max(0, remainingMs)); + }; + const settle = () => { + if (settled) return; + settled = true; + clearTimer(); + }; + const timeoutBudget: HandlerTimeoutBudget = { + pause: () => { + if (settled) return; + pauseDepth++; + if (pauseDepth !== 1) return; + remainingMs = Math.max(0, remainingMs - (performance.now() - activeSince)); + clearTimer(); + if (remainingMs <= 0) expire(); + }, + resume: () => { + if (settled || pauseDepth === 0) return; + pauseDepth--; + if (pauseDepth === 0) armTimer(); + }, + }; + armTimer(); try { if (signal?.aborted) return EXTENSION_HANDLER_ABORTED; - const workPromise = Promise.resolve(work(handlerSignal)); + const workPromise = Promise.resolve(work(handlerSignal, timeoutBudget)); const result = await Promise.race([workPromise, interruptPromise]); if (result === EXTENSION_HANDLER_TIMEOUT) { await Promise.race([ @@ -223,7 +310,7 @@ async function raceHandlerWithTimeout( } return result; } finally { - clearTimeout(timer); + settle(); signal?.removeEventListener("abort", onAbort); } } @@ -1155,23 +1242,33 @@ export class ExtensionRunner { ext: Extension, timeoutMs: number, onFailure?: (kind: "timeout" | "error", message: string) => TResult, + outerSignal?: AbortSignal, ): Promise { - const signal = + // `session_stop` carries its own signal on the event; `tool_call` receives + // the outer dispatch signal (loop request or wrapper execute) so an abort + // while a handler awaits a human dialog cancels the dialog and settles the + // gate without executing the underlying tool. Compose whichever apply. + const sessionStopSignal = event.type === "session_stop" && "signal" in event && event.signal instanceof AbortSignal ? event.signal : undefined; + const signals = [outerSignal, sessionStopSignal].filter((s): s is AbortSignal => s !== undefined); + const signal = signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals); if (signal?.aborted) return undefined; const registrationScope: ToolRegistrationScope = { pending: new Set(), closed: false }; let handlerResult: TResult | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED | undefined; let handlerFailure: { error: unknown } | undefined; try { handlerResult = await raceHandlerWithTimeout( - async handlerSignal => { + async (handlerSignal, budget) => { registrationScope.signal = handlerSignal; let result: TResult | undefined; try { result = await this.#toolRegistrationScope.run(registrationScope, () => - handler(event, createHandlerContext(ctx, handlerSignal)), + handler( + event, + createHandlerContext(ctx, handlerSignal, event.type === "tool_call" ? budget : undefined), + ), ); } catch (error) { handlerFailure = { error }; @@ -1332,8 +1429,8 @@ export class ExtensionRunner { /** * Emit a `tool_call` event to every subscribed extension before the tool executes. * - * Each handler is bounded by `extensionHandlerTimeoutMs` (default 30s). This - * matches the timeout policy already applied to `emitToolResult` and every + * Each handler is bounded by `extensionHandlers.toolCallTimeoutMs` (default + * 30s). This matches the timeout policy already applied to `emitToolResult` and every * other handler routed through `#runHandlerWithTimeout`; without it a single * hung extension (unresolved `await`, network call with no timeout) would * park `ExtensionToolWrapper.execute` indefinitely and freeze tool @@ -1344,9 +1441,11 @@ export class ExtensionRunner { * pre-execution gate — an unresponsive extension MUST NOT be treated as * silent consent to run the tool. */ - async emitToolCall(event: ToolCallEvent): Promise { + async emitToolCall(event: ToolCallEvent, signal?: AbortSignal): Promise { const ctx = this.createContext(); - const timeoutMs = extensionHandlerTimeoutMs; + const timeoutMs = normalizeHandlerTimeout( + this.settings?.get("extensionHandlers.toolCallTimeoutMs") ?? extensionHandlerTimeoutMs, + ); let result: ToolCallEventResult | undefined; for (const ext of this.extensions) { @@ -1367,6 +1466,7 @@ export class ExtensionRunner { ? `Extension ${ext.path} timed out after ${timeoutMs}ms` : `Extension ${ext.path} failed: ${message}`, }), + signal, ); if (handlerResult) { @@ -1378,6 +1478,9 @@ export class ExtensionRunner { } } + if (signal?.aborted) { + return { block: true, reason: `Tool execution was cancelled while an extension handler was pending` }; + } return result; } diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 0c9ddd028..da80737d3 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -235,6 +235,8 @@ export interface ExtensionCustomOptions { overlayOptions?: OverlayOptions | (() => OverlayOptions); /** Invoked with the overlay handle once the overlay is created (overlay mode only). */ onHandle?: (handle: OverlayHandle) => void; + /** Abort the custom UI and reject its promise. */ + signal?: AbortSignal; } /** Wrap the current autocomplete provider with additional behavior (pi-compatible). */ diff --git a/packages/coding-agent/src/extensibility/extensions/wrapper.ts b/packages/coding-agent/src/extensibility/extensions/wrapper.ts index 39bbeb517..a4aa75dc8 100644 --- a/packages/coding-agent/src/extensibility/extensions/wrapper.ts +++ b/packages/coding-agent/src/extensibility/extensions/wrapper.ts @@ -206,15 +206,18 @@ export class ExtensionToolWrapper this.runner.waitForToolApprovalPreview(toolCallId)); } diff --git a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts index 49603e015..5c5eae914 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts @@ -134,6 +134,10 @@ export const getModels = getBundledModels; * * Legacy `/compat` callers pass {@link SimpleStreamOptions}; routing through * `streamSimple` preserves option normalization before provider dispatch. + * + * Transient-failure retry (overload, rate-limit, 5xx) is the **caller's + * responsibility**. Oneshot callers that collect the full result before acting + * should wrap with `retryTransientCompletion` from `@oh-my-pi/pi-ai`. */ export function streamSimpleOpenAIResponses( model: Model<"openai-responses">, diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts index 79002fd8c..72733e9f3 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts @@ -11,6 +11,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils"; +import { expandTilde } from "../../../tools/path-utils"; import { normalizePluginRuntimeConfig } from "../runtime-config"; import type { PluginRuntimeConfig } from "../types"; @@ -107,12 +108,11 @@ export class MarketplaceManager { } const sourceType = classifySource(source); - const normalizedSource = - sourceType === "local" - ? path.resolve(source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source) - : source; + const normalizedSource = sourceType === "local" ? path.resolve(expandTilde(source)) : source; - const catalogPath = path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json"); + const catalogPath = path.resolve( + expandTilde(path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json")), + ); // Persist the fetched catalog so subsequent reads don't require re-fetching. await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); @@ -174,11 +174,13 @@ export class MarketplaceManager { await promoteCloneToCache(clonePath, this.#opts.marketplacesCacheDir, catalog.name); } - // Overwrite cached catalog - await Bun.write(existing.catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + // Overwrite the cached catalog and migrate legacy home-relative registry entries. + const catalogPath = path.resolve(expandTilde(existing.catalogPath)); + await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); const updatedEntry: MarketplaceRegistryEntry = { ...existing, + catalogPath, updatedAt: new Date().toISOString(), }; @@ -893,14 +895,13 @@ export class MarketplaceManager { } async #readCatalog(entry: MarketplaceRegistryEntry): Promise { + const catalogPath = path.resolve(expandTilde(entry.catalogPath)); try { - const content = await Bun.file(entry.catalogPath).text(); - return parseMarketplaceCatalog(content, entry.catalogPath); + const content = await Bun.file(catalogPath).text(); + return parseMarketplaceCatalog(content, catalogPath); } catch (err) { if (isEnoent(err)) { - throw new Error( - `Marketplace catalog not found at ${entry.catalogPath}. Try: /marketplace update ${entry.name}`, - ); + throw new Error(`Marketplace catalog not found at ${catalogPath}. Try: /marketplace update ${entry.name}`); } throw err; } @@ -918,14 +919,10 @@ export class MarketplaceManager { */ #resolveMarketplaceRoot(entry: MarketplaceRegistryEntry): string { if (entry.sourceType === "local") { - // expandHome already happened in fetcher; resolve to ensure absolute. - const expanded = entry.sourceUri.startsWith("~/") - ? path.join(os.homedir(), entry.sourceUri.slice(2)) - : entry.sourceUri; - return path.resolve(expanded); + return path.resolve(expandTilde(entry.sourceUri)); } // For git/github/url sources, the catalog lives at /marketplace.json // under marketplacesCacheDir//; parent = // - return path.dirname(entry.catalogPath); + return path.dirname(path.resolve(expandTilde(entry.catalogPath))); } } diff --git a/packages/coding-agent/src/irc/bus.ts b/packages/coding-agent/src/irc/bus.ts index 4f6aab6f7..0eec9e57b 100644 --- a/packages/coding-agent/src/irc/bus.ts +++ b/packages/coding-agent/src/irc/bus.ts @@ -276,7 +276,7 @@ export class IrcBus { if (liveness) { const { registry, senderId } = liveness; const hasRunningSender = (from?: string): boolean => - registry.listVisibleTo(senderId).some(ref => ref.status === "running" && (!from || ref.id === from)); + registry.listVisibleTo(senderId).some(ref => registry.isRunning(ref) && (!from || ref.id === from)); const check = filter.from ? () => hasRunningSender(filter.from) : () => hasRunningSender(); unsubscribeLiveness = registry.onChange(() => { if (!check()) { diff --git a/packages/coding-agent/src/launch/broker.ts b/packages/coding-agent/src/launch/broker.ts index 51d700fda..285d16034 100644 --- a/packages/coding-agent/src/launch/broker.ts +++ b/packages/coding-agent/src/launch/broker.ts @@ -8,7 +8,7 @@ import { hostHasInheritableConsole } from "../eval/py/spawn-options"; import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output"; import { workerEnvFromParent } from "../subprocess/worker-client"; import { daemonBrokerEndpoint } from "./paths"; -import { hasLiveDaemonProjectPresence } from "./presence"; +import { hasLiveDaemonProjectPresence, pruneDeadDaemonRuntimeDirs } from "./presence"; import { DAEMON_IDLE_GRACE_ENV, DAEMON_PROJECT_DIR_ENV, @@ -1384,6 +1384,13 @@ export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStar const lease = await acquireBrokerLease(runtimeDir); if (!lease) return; setProcessName("omp daemon broker"); + // Reclaim sibling daemon scopes left behind by dead brokers (issue #8674). + // Detached and non-throwing so it never delays clients connecting to us. + void pruneDeadDaemonRuntimeDirs(runtimeDir).catch(error => { + logger.warn("Daemon runtime prune failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim(); if (!token) throw new Error("Daemon broker token is empty"); const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs); diff --git a/packages/coding-agent/src/launch/presence.ts b/packages/coding-agent/src/launch/presence.ts index e63a0e852..46fdf72e3 100644 --- a/packages/coding-agent/src/launch/presence.ts +++ b/packages/coding-agent/src/launch/presence.ts @@ -1,9 +1,19 @@ +import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils"; +import { isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils"; import { daemonRuntimeDir } from "./paths"; const CLIENTS_DIR = "clients"; +const BROKER_PID_FILE = "broker.pid"; +const GLOBAL_DAEMON_DIR = "global"; +/** + * Grace before a dead daemon runtime dir becomes prune-eligible. Guards against + * deleting a scope whose owning omp process is mid-startup (token written, broker + * not yet spawned, presence not yet registered). The leak this reclaims is a + * weeks-scale accumulation, so a few minutes of slack costs nothing. + */ +const DAEMON_RUNTIME_STALE_GRACE_MS = 5 * 60_000; /** Handle keeping one omp process registered in a project daemon scope. */ export interface DaemonProjectPresence { @@ -80,3 +90,69 @@ export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise< } return live; } + +/** Whether a runtime dir's recorded broker PID is still alive. */ +async function hasLiveDaemonBroker(runtimeDir: string): Promise { + let raw: unknown; + try { + raw = await Bun.file(path.join(runtimeDir, BROKER_PID_FILE)).json(); + } catch { + return false; // Missing or malformed broker.pid => no owning broker. + } + if (typeof raw !== "object" || raw === null || !("pid" in raw) || typeof raw.pid !== "number") { + return false; + } + try { + process.kill(raw.pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Remove sibling project daemon runtime directories whose broker is dead and + * whose client-presence set is empty, reclaiming the disk that short-lived + * project directories leave behind (issue #8674). + * + * Best-effort and non-throwing: a scope is deleted only when its `broker.pid` + * is absent/dead, no live client presence remains, and it has been untouched + * for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir` + * and the machine-global daemon container are always skipped. + */ +export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise { + const root = path.dirname(currentRuntimeDir); + if (path.basename(root) === GLOBAL_DAEMON_DIR) return; + const current = path.resolve(currentRuntimeDir); + let entries: Dirent[]; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if (!isEnoent(error)) { + logger.warn("Failed to scan daemon runtime root for pruning", { + root, + error: error instanceof Error ? error.message : String(error), + }); + } + return; + } + const now = Date.now(); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue; + const dir = path.join(root, entry.name); + if (path.resolve(dir) === current) continue; + try { + const stat = await fs.stat(dir); + if (now - stat.mtimeMs < DAEMON_RUNTIME_STALE_GRACE_MS) continue; + if (await hasLiveDaemonBroker(dir)) continue; + if (await hasLiveDaemonProjectPresence(dir)) continue; + await fs.rm(dir, { recursive: true, force: true }); + } catch (error) { + if (isEnoent(error)) continue; + logger.warn("Failed to prune dead daemon runtime dir", { + dir, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index 80c2d452f..49f1e7fe6 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -62,13 +62,32 @@ export function setIdleTimeout(ms: number | null | undefined): void { } } +/** + * Whether a client may be reaped by the idle checker. + * + * A client with in-flight requests is *busy*, never idle. `lastActivity` is + * stamped when a request is written, not while it is outstanding, so a single + * request that runs longer than the idle timeout used to look like silence: + * the checker tore the client down mid-flight and `shutdownClientInstance` + * rejected the caller's still-pending promise with "LSP client shutdown" + * (issue #8390). Requests that settle refresh `lastActivity`, so a client + * becomes eligible again only after the final one lands and the full idle + * window then elapses. + * + * Exported for tests; the idle checker is the only production caller. + */ +export function isIdleClient(client: LspClient, now: number, timeoutMs: number): boolean { + if (client.pendingRequests.size > 0) return false; + return now - client.lastActivity > timeoutMs; +} + function startIdleChecker(): void { if (idleCheckInterval) return; idleCheckInterval = setInterval(() => { if (!idleTimeoutMs) return; const now = Date.now(); for (const [key, client] of Array.from(clients.entries())) { - if (now - client.lastActivity > idleTimeoutMs) { + if (isIdleClient(client, now, idleTimeoutMs)) { void shutdownClient(key); } } @@ -746,7 +765,13 @@ function commandBasename(command: string): string { return separator === -1 ? command : command.slice(separator + 1); } -function isRustAnalyzerClient(client: LspClient): boolean { +/** + * True when this client speaks the rust-analyzer protocol, detected by the + * command basename (`rust-analyzer[.exe]`) of the configured or resolved + * binary. Callers use it to gate rust-analyzer-only requests such as + * `rust-analyzer/reloadWorkspace` (see {@link reloadServer}). + */ +export function isRustAnalyzerClient(client: LspClient): boolean { return ( commandBasename(client.config.command) === "rust-analyzer" || (client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false) @@ -1456,15 +1481,22 @@ export async function sendRequest( } } - // Register pending request with timeout wrapper + // Register pending request with timeout wrapper. + // Settling stamps `lastActivity`: the idle window must be measured from when + // the exchange finished, not from when it started. Without this a request + // that outlives the timeout would leave the client instantly reapable the + // moment it lands, so the next idle sweep would kill a server that had just + // answered (issue #8390). client.pendingRequests.set(id, { resolve: result => { if (timeout) clearTimeout(timeout); + client.lastActivity = Date.now(); cleanup(); resolve(result); }, reject: err => { if (timeout) clearTimeout(timeout); + client.lastActivity = Date.now(); cleanup(); reject(err); }, diff --git a/packages/coding-agent/src/lsp/clients/biome-client.ts b/packages/coding-agent/src/lsp/clients/biome-client.ts index 2e1cfbbf6..9ee45fdc5 100644 --- a/packages/coding-agent/src/lsp/clients/biome-client.ts +++ b/packages/coding-agent/src/lsp/clients/biome-client.ts @@ -14,14 +14,21 @@ interface BiomeJsonOutput { diagnostics: BiomeDiagnostic[]; } +/** + * A single diagnostic from Biome's `--reporter=json` output (Biome 2.x). + * + * Positions are 1-indexed `{ line, column }` pairs; the path is a plain string + * relative to the CLI's cwd. Older releases used byte-offset `span`s, which + * this client no longer parses. + */ interface BiomeDiagnostic { category: string; // e.g., "lint/correctness/noUnusedVariables" - severity: "error" | "warning" | "info" | "hint"; - description: string; + severity: string; // "error" | "warning" | "info" | "hint" + message: string; location?: { - path?: { file: string }; - span?: [number, number]; // [startOffset, endOffset] in bytes - sourceCode?: string; + path?: string; + start?: { line: number; column: number }; + end?: { line: number; column: number }; }; } @@ -29,43 +36,6 @@ interface BiomeDiagnostic { // Helpers // ============================================================================= -/** - * Convert byte offsets to line:column positions in a single pass over the source. - */ -function offsetsToPositions(source: string, offsets: number[]): Map { - const sorted = [...new Set(offsets)].sort((a, b) => a - b); - const result = new Map(); - let line = 1; - let column = 1; - let byteIndex = 0; - let next = 0; - - for (const ch of source) { - if (next >= sorted.length) break; - const cp = ch.codePointAt(0) as number; - const byteLen = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; - while (next < sorted.length && byteIndex + byteLen > sorted[next]) { - result.set(sorted[next], { line, column }); - next++; - } - if (ch === "\n") { - line++; - column = 1; - } else { - column++; - } - byteIndex += byteLen; - } - - // Offsets at or past end-of-file map to the final position. - while (next < sorted.length) { - result.set(sorted[next], { line, column }); - next++; - } - - return result; -} - /** * Parse Biome severity to LSP DiagnosticSeverity. */ @@ -176,8 +146,6 @@ export class BiomeClient implements LinterClient { * Parse Biome's JSON output into LSP Diagnostics. */ #parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - let parsed: BiomeJsonOutput; try { parsed = JSON.parse(jsonOutput); @@ -186,61 +154,36 @@ export class BiomeClient implements LinterClient { cwd: this.cwd, file: targetFile, }); - return diagnostics; + return []; } + const emitted = parsed.diagnostics ?? []; const target = path.resolve(targetFile); - const relevant: BiomeDiagnostic[] = []; - // Batch all span offsets per source text so each source is scanned once - // instead of twice per diagnostic. - const offsetsBySource = new Map(); - for (const diag of parsed.diagnostics ?? []) { + const diagnostics: Diagnostic[] = []; + // Biome's JSON reporter is experimental and may reshape its output in + // patch releases. Track whether any diagnostic carried a usable location + // so a schema drift surfaces as a warning instead of a silent empty list. + let sawUsableLocation = false; + + for (const diag of emitted) { const location = diag.location; - if (!location?.path?.file) continue; + const filePath = location?.path; + if (!filePath) continue; + sawUsableLocation = true; - // Resolve file path - const diagFile = path.isAbsolute(location.path.file) - ? location.path.file - : path.join(this.cwd, location.path.file); + // Biome reports paths relative to its cwd. + const diagFile = path.isAbsolute(filePath) ? filePath : path.join(this.cwd, filePath); - // Only include diagnostics for the target file - if (path.resolve(diagFile) !== target) { - continue; - } + // Only include diagnostics for the target file. + if (path.resolve(diagFile) !== target) continue; - relevant.push(diag); - if (location.span && location.sourceCode) { - const offsets = offsetsBySource.get(location.sourceCode); - if (offsets) offsets.push(location.span[0], location.span[1]); - else offsetsBySource.set(location.sourceCode, [location.span[0], location.span[1]]); - } - } - - const positionsBySource = new Map>(); - for (const [source, offsets] of offsetsBySource) { - positionsBySource.set(source, offsetsToPositions(source, offsets)); - } - - for (const diag of relevant) { - const location = diag.location; - let startLine = 1; - let startColumn = 1; - let endLine = 1; - let endColumn = 1; - - if (location?.span && location.sourceCode) { - const positions = positionsBySource.get(location.sourceCode); - const startPos = positions?.get(location.span[0]); - const endPos = positions?.get(location.span[1]); - if (startPos) { - startLine = startPos.line; - startColumn = startPos.column; - } - if (endPos) { - endLine = endPos.line; - endColumn = endPos.column; - } - } + // Biome positions are 1-indexed; LSP ranges are 0-indexed. + const start = location.start; + const end = location.end ?? start; + const startLine = start?.line ?? 1; + const startColumn = start?.column ?? 1; + const endLine = end?.line ?? startLine; + const endColumn = end?.column ?? startColumn; diagnostics.push({ range: { @@ -248,12 +191,23 @@ export class BiomeClient implements LinterClient { end: { line: endLine - 1, character: endColumn - 1 }, }, severity: parseSeverity(diag.severity), - message: diag.description, + message: diag.message, source: "biome", code: diag.category, }); } + // Non-empty output whose diagnostics all lacked a recognizable location + // means the reporter schema changed out from under us — warn loudly + // instead of masking the regression as "no lint issues". + if (emitted.length > 0 && !sawUsableLocation) { + warnBiomeOnce( + `schema:${this.cwd}`, + "Biome diagnostics had no recognizable location; reporter schema may have changed", + { cwd: this.cwd, file: targetFile, count: emitted.length }, + ); + } + return diagnostics; } diff --git a/packages/coding-agent/src/lsp/servers.ts b/packages/coding-agent/src/lsp/servers.ts index ef14e8350..71e628637 100644 --- a/packages/coding-agent/src/lsp/servers.ts +++ b/packages/coding-agent/src/lsp/servers.ts @@ -4,6 +4,7 @@ import { getActiveClients, getActiveOrPendingClient, getOrCreateClient, + isRustAnalyzerClient, type LspServerStatus, notifySaved, sendNotification, @@ -256,17 +257,22 @@ export function isMethodNotFoundError(err: unknown): boolean { export async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise { throwIfAborted(signal); - // rust-analyzer exposes a real reload request. Every other server rejects it - // with method-not-found — that alone justifies the generic fallback. A caller - // cancel or tool timeout must propagate, never be mistaken for an unsupported - // method and swallowed into a bogus "Restarted" (issue #6369). - try { - await sendRequest(client, "rust-analyzer/reloadWorkspace", null, signal); - return `Reloaded ${serverName}`; - } catch (err) { - throwIfAborted(signal); - if (!isMethodNotFoundError(err)) throw err; - // Method not supported — fall through to the generic reload. + // rust-analyzer exposes a real reload request. Only rust-analyzer implements + // it, so gate the request on the binary (or registered name) rather than + // probing every server: some servers (Roslyn) crash the whole process on an + // unknown method instead of replying with method-not-found — killing the + // server `lsp reload` was meant to refresh (issue #8571, dotnet/roslyn#84890). + // A caller cancel or tool timeout must propagate, never be mistaken for an + // unsupported method and swallowed into a bogus "Restarted" (issue #6369). + if (isRustAnalyzerClient(client) || serverName === "rust-analyzer") { + try { + await sendRequest(client, "rust-analyzer/reloadWorkspace", undefined, signal); + return `Reloaded ${serverName}`; + } catch (err) { + throwIfAborted(signal); + if (!isMethodNotFoundError(err)) throw err; + // Method not supported — fall through to the generic reload. + } } // workspace/didChangeConfiguration is a notification per spec; sending it // as a request hangs until the tool deadline on servers that route it to diff --git a/packages/coding-agent/src/lsp/tool.ts b/packages/coding-agent/src/lsp/tool.ts index e4daa01e7..84357c100 100644 --- a/packages/coding-agent/src/lsp/tool.ts +++ b/packages/coding-agent/src/lsp/tool.ts @@ -21,6 +21,7 @@ import { ensureFileOpen, getActiveClients, getOrCreateClient, + isRustAnalyzerClient, type LspServerStatus, refreshFile, sendNotification, @@ -1075,10 +1076,7 @@ export class LspTool implements AgentTool = { web_search_exa: true }; + +/** + * Parse the comma-separated `tools` restriction from an Exa MCP config. + * Returns `null` when the config does not restrict its tool set. + */ +function getRequestedExaMcpTools(config: MCPServerConfig): string[] | null { + const raw = (() => { + if (config.type === "http" || config.type === "sse") { + const httpConfig = config as { url?: string }; + if (!httpConfig.url) return undefined; + try { + return new URL(httpConfig.url).searchParams.get("tools") ?? undefined; + } catch { + return undefined; + } + } + if (!config.type || config.type === "stdio") { + const stdioConfig = config as { args?: string[] }; + const args = stdioConfig.args ?? []; + for (let i = 0; i < args.length; i++) { + if (/^--?tools$/i.test(args[i])) return args[i + 1]; + const match = args[i].match(/(?:^|[\s?&])tools=([^&\s]+)/i) ?? args[i].match(/--?tools[=\s]([^\s]+)/i); + if (match) return match[1]; + } + } + return undefined; + })(); + if (!raw) return null; + const tools = raw + .split(",") + .map(tool => tool.trim()) + .filter(tool => tool.length > 0); + return tools.length > 0 ? tools : null; +} + /** Result of filtering Exa MCP servers */ export interface ExaFilterResult { /** Configs with Exa servers removed */ @@ -236,7 +273,9 @@ export interface ExaFilterResult { /** * Filter out Exa MCP servers and extract their API keys. - * Since we have native Exa integration, we don't need the MCP server. + * Since we have native Exa integration, we don't need the MCP server — + * unless the config explicitly requests Exa tools the native integration + * does not provide (e.g. `web_fetch_exa`, `web_search_advanced_exa`). */ export function filterExaMCPServers( configs: Record, @@ -248,17 +287,22 @@ export function filterExaMCPServers( for (const [name, config] of Object.entries(configs)) { if (isExaMCPServer(name, config)) { - // Extract API key before filtering + // Extract API key for the native Exa integration even when the MCP + // server is kept below for its extra tools. const apiKey = extractExaApiKey(config); if (apiKey) { exaApiKeys.push(apiKey); } - } else { - filtered[name] = config; - if (sources[name]) { - filteredSources[name] = sources[name]; + const requested = getRequestedExaMcpTools(config); + const hasExtraTools = requested?.some(tool => !NATIVE_EXA_MCP_TOOLS[tool.toLowerCase()]) ?? false; + if (!hasExtraTools) { + continue; } } + filtered[name] = config; + if (sources[name]) { + filteredSources[name] = sources[name]; + } } return { configs: filtered, exaApiKeys, sources: filteredSources }; diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index a82bc3c8f..f8afce865 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -3,7 +3,7 @@ import type * as fsNode from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { type ApiKey, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { type ApiKey, completeSimple, Effort, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; import { getAgentDbPath, getMemoriesDir, isEnoent, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils"; @@ -750,18 +750,20 @@ async function runStage1Job(options: { response_items_json: truncatedItems, }); - const response = await completeSimple( - model, - { - systemPrompt: [stageOneSystemTemplate], - messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }], - }, - { - apiKey, - metadata: options.metadata, - maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))), - reasoning: clampThinkingLevelForModel(model, Effort.Low), - }, + const response = await retryTransientCompletion(() => + completeSimple( + model, + { + systemPrompt: [stageOneSystemTemplate], + messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }], + }, + { + apiKey, + metadata: options.metadata, + maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))), + reasoning: clampThinkingLevelForModel(model, Effort.Low), + }, + ), ); if (response.stopReason === "error") { @@ -887,18 +889,20 @@ async function runConsolidationModel(options: { rollout_summaries: truncateByApproxTokens(rolloutSummaries, 12_000), }); - const response = await completeSimple( - model, - { - systemPrompt: [consolidationSystemTemplate], - messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }], - }, - { - apiKey, - metadata: options.metadata, - maxTokens: 8192, - reasoning: clampThinkingLevelForModel(model, Effort.Medium), - }, + const response = await retryTransientCompletion(() => + completeSimple( + model, + { + systemPrompt: [consolidationSystemTemplate], + messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }], + }, + { + apiKey, + metadata: options.metadata, + maxTokens: 8192, + reasoning: clampThinkingLevelForModel(model, Effort.Medium), + }, + ), ); if (response.stopReason === "error") { throw new Error(response.errorMessage || "phase2 model error"); diff --git a/packages/coding-agent/src/mnemopi/backend.ts b/packages/coding-agent/src/mnemopi/backend.ts index f72101818..a84a8b359 100644 --- a/packages/coding-agent/src/mnemopi/backend.ts +++ b/packages/coding-agent/src/mnemopi/backend.ts @@ -1,6 +1,6 @@ import { rm } from "node:fs/promises"; import * as path from "node:path"; -import { type ApiKeyResolver, completeSimple } from "@oh-my-pi/pi-ai"; +import { type ApiKeyResolver, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts"; import type { Mnemopi } from "@oh-my-pi/pi-mnemopi"; import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose"; @@ -545,16 +545,18 @@ async function resolveMnemopiProviderOptions( }); return null; } - const message = await completeSimple( - model, - { - messages: [{ role: "user", content: prompt, timestamp: Date.now() }], - }, - { - apiKey: modelRegistry.resolver(model, sessionId), - maxTokens: opts?.maxTokens, - temperature: opts?.temperature, - }, + const message = await retryTransientCompletion(() => + completeSimple( + model, + { + messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + }, + { + apiKey: modelRegistry.resolver(model, sessionId), + maxTokens: opts?.maxTokens, + temperature: opts?.temperature, + }, + ), ); return message.content .filter( diff --git a/packages/coding-agent/src/modes/components/ask-dialog.ts b/packages/coding-agent/src/modes/components/ask-dialog.ts index 80ed90e3c..d7517da04 100644 --- a/packages/coding-agent/src/modes/components/ask-dialog.ts +++ b/packages/coding-agent/src/modes/components/ask-dialog.ts @@ -4,6 +4,7 @@ import { Markdown, type MarkdownTheme, matchesKey, + padding, renderInlineMarkdown, replaceTabs, ScrollView, @@ -12,6 +13,7 @@ import { Text, type TUI, truncateToWidth, + visibleWidth, wrapTextWithAnsi, } from "@oh-my-pi/pi-tui"; import type { @@ -315,8 +317,18 @@ function renderRowLabel( const cursor = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " "; const label = renderInlineMarkdown(rowItem.label, mdTheme, t => theme.fg(color, t)); const noteMarker = state.note && state.noteRowKey === rowItem.key ? theme.fg("success", " ✎ note") : ""; - const firstLine = `${cursor}${marker}${label}${noteMarker}`; - const lines = [truncateToWidth(firstLine, width, Ellipsis.Unicode)]; + // `width` is already the inner content width consumed by row(); when a + // scrollbar is needed, renderRows() calls this again with one less column. + // Keep the cursor, option marker, first wrapped label line, and optional + // note marker within that budget so the outer fit() never truncates them. + const noteWidth = noteMarker ? visibleWidth(noteMarker) : 0; + const labelWidth = Math.max(1, width - visibleWidth(cursor) - visibleWidth(marker) - noteWidth); + const wrappedLabel = wrapTextWithAnsi(label, labelWidth); + const indent = padding(visibleWidth(cursor) + visibleWidth(marker)); + const lines = [`${cursor}${marker}${wrappedLabel[0] ?? ""}${noteMarker}`]; + for (let i = 1; i < wrappedLabel.length; i++) { + lines.push(`${indent}${wrappedLabel[i] ?? ""}`); + } if (rowItem.kind === "option") { const option = question.options[rowItem.optionIndex ?? -1]; if (option?.description?.trim()) { @@ -602,7 +614,9 @@ export class AskDialogComponent implements Component { return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`; } const question = this.#questions[this.#currentQuestionIndex()]; - const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note"; + // Enter advances in multi-question dialogs and submits single-question ones. + const enterAction = this.#questions.length > 1 ? "next" : "submit"; + const action = question?.multi ? `Space toggle · Enter ${enterAction}` : "Enter select · n note"; const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : ""; if (this.#questionCanPage && indicator) { return `${action} · ↑/↓${tabs} · ${cancel} · ${pageKeysLabel()} ${indicator}`; @@ -680,8 +694,14 @@ export class AskDialogComponent implements Component { const option = question.options[rowItem.optionIndex ?? -1]; if (!option) return; if (question.multi) { - // Multi is toggle-only: Enter and Space both toggle, and the - // answer is confirmed from the Submit tab. + if (isEnter) { + // Enter confirms the current selection without toggling the + // focused option; Space toggles. Advances to the next question + // (submitting only for a single-question dialog), matching + // single-select Enter (#8252). + this.#advanceAfterQuestion(); + return; + } if (state.selectedOptions.has(option.label)) { state.selectedOptions.delete(option.label); clearNoteIfRow(state, rowItem.key); diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 0fb863e69..37ccd198f 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -734,10 +734,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac * region (a later block streams below it), or — while it is still the * live tail — its head rows were committed because the frame outgrew the * viewport. Committed rows are immutable, so from that point every further - * partial snapshot is dropped. Rows restyle static gray only when nothing - * is committed yet; otherwise the bytes stay exactly as painted. One-way — - * blocks never re-enter the live region. Returns whether the block is - * frozen. + * partial snapshot is dropped. A hidden, wholly uncommitted block keeps + * accepting snapshots so revealing it starts from current progress. Rows + * restyle static gray only when nothing is committed yet; otherwise the + * bytes stay exactly as painted. One-way — blocks never re-enter the live + * region. Returns whether the block is frozen. */ #maybeFreezeBackgroundTask(): boolean { if (this.#backgroundTaskFrozen) return true; @@ -745,7 +746,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac const asyncState = (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state; if (asyncState !== "running") return false; const uncommitted = this.#liveRegion.isBlockUncommitted?.(this) ?? true; - if (uncommitted && this.#liveRegion.isBlockInLiveRegion(this)) return false; + if (uncommitted && (!this.#toolActivityVisible || this.#liveRegion.isBlockInLiveRegion(this))) return false; this.#backgroundTaskFrozen = true; this.#updateSpinnerAnimation(); if (uncommitted) { @@ -825,10 +826,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac * Reports `false` while it can still visually change so the * {@link TranscriptContainer} keeps it inside the repaintable live region: * a foreground tool awaiting its result, or one streaming partial output. + * Hidden blocks render no rows, so they cannot gate later streaming content. + * Visibility toggles reset and replay the transcript before revealing them. * A final (non-partial) result, a background-async tool the agent has moved - * past, or an explicit {@link seal} flips it to `true`. + * past, or an explicit {@link seal} also flips it to `true`. */ isTranscriptBlockFinalized(): boolean { + if (!this.#toolActivityVisible) return true; if (this.#sealed) return true; if (this.#result === undefined) return false; // A displaceable snapshot stays live: its rows are kept out of native diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index fdb4dfc3e..9ec8ba5b3 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -248,7 +248,10 @@ export class WelcomeComponent implements Component { visibleWidth(this.modelName), visibleWidth(this.providerName), ); - const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35))); + const desiredLeftCol = Math.max( + Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35))), + leftMinContentWidth, + ); const dualLeftCol = dualContentWidth >= minRightCol + 1 ? Math.min(desiredLeftCol, dualContentWidth - minRightCol) diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index c26a9310d..fce13e198 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -1053,53 +1053,73 @@ export class ExtensionUiController { const savedText = this.ctx.editor.getText(); const keybindings = KeybindingsManager.inMemory(); - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve, reject } = Promise.withResolvers(); let component: (Component & { dispose?(): void }) | undefined; let overlayHandle: OverlayHandle | undefined; let closed = false; + let editorReplaced = false; - const close = (result: T) => { - if (closed) return; - closed = true; + const cleanup = () => { component?.dispose?.(); overlayHandle?.hide(); overlayHandle = undefined; - if (!options?.overlay) { + if (editorReplaced) { this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.editor.setText(savedText); } this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); - resolve(result); }; + const finish = (settle: () => void) => { + if (closed) return; + closed = true; + options?.signal?.removeEventListener("abort", onAbort); + try { + cleanup(); + } finally { + settle(); + } + }; + const fail = (error: unknown) => finish(() => reject(error)); + const onAbort = () => fail(options?.signal?.reason ?? new DOMException("Dialog aborted", "AbortError")); + const close = (result: T) => finish(() => resolve(result)); - Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then(c => { - if (closed) { - c.dispose?.(); - return; - } - component = c; - if (options?.overlay) { - const overlayOptions = - typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions; - overlayHandle = this.ctx.ui.showOverlay( - component, - overlayOptions ?? { - anchor: "bottom-center", - width: "100%", - maxHeight: "100%", - margin: 0, - }, - ); - options.onHandle?.(overlayHandle); - return; - } - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(component); - this.ctx.ui.setFocus(component); - this.ctx.ui.requestRender(); - }); + if (options?.signal?.aborted) { + fail(options.signal.reason ?? new DOMException("Dialog aborted", "AbortError")); + return promise; + } + options?.signal?.addEventListener("abort", onAbort, { once: true }); + + Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)) + .then(c => { + if (closed) { + c.dispose?.(); + return; + } + component = c; + if (options?.overlay) { + const overlayOptions = + typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions; + overlayHandle = this.ctx.ui.showOverlay( + component, + overlayOptions ?? { + anchor: "bottom-center", + width: "100%", + maxHeight: "100%", + margin: 0, + }, + ); + options.onHandle?.(overlayHandle); + return; + } + editorReplaced = true; + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(component); + this.ctx.ui.setFocus(component); + this.ctx.ui.requestRender(); + }) + .catch(fail); return promise; } diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index d2c5cd788..61f00c31c 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -562,6 +562,12 @@ export class SelectorController { this.ctx.rebuildChatFromMessages(); this.ctx.ui.resetDisplay(); break; + case "display.showTokenUsage": + // Rebuild reruns usage-row detection under the new setting; resetDisplay + // retires rows already committed to native scrollback. + this.ctx.rebuildChatFromMessages(); + this.ctx.ui.resetDisplay(); + break; case "tui.tight": setTuiTight(value as boolean); this.ctx.ui.invalidate(); diff --git a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts index 4e9ea758f..1bbb13266 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -111,6 +111,7 @@ export class TanCommandController { let jobId = ""; try { const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, { + copyArtifacts: false, suppressBreadcrumb: true, sessionFile: cloneFile, }); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 88c0d14dc..ec6eb9c6b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -50,6 +50,7 @@ import { logger, postmortem, prompt, + sanitizeText, setProjectDir, } from "@oh-my-pi/pi-utils"; import chalk from "@oh-my-pi/pi-utils/chalk"; @@ -390,16 +391,20 @@ export interface InteractiveModeOptions { /** * Anchored live-region container for the HUD/status rows between the transcript * and the editor (working loader, todo + subagent HUDs, transient notification - * panels). While it has content every row is live: it reports a seam at 0 so the - * engine never commits these anchored, rebuilt-in-place rows to native - * scrollback — otherwise stale duplicates pile up above the live copy on short - * terminals once the loader sits below a tall HUD. The transcript's own seam, + * panels). While it has content every row is live: it reports a seam at 0 and + * pins that live region so the engine never commits these anchored, + * rebuilt-in-place rows to native scrollback — otherwise stale duplicates pile + * up above the live copy on short terminals once the loader sits below a tall HUD. The transcript's own seam, * when present, sits higher and wins (topmost-seam merge in TUI.render). */ class AnchoredLiveContainer extends Container implements NativeScrollbackLiveRegion { getNativeScrollbackLiveRegionStart(): number | undefined { return this.children.length > 0 ? 0 : undefined; } + + isNativeScrollbackLiveRegionPinned(): boolean { + return true; + } } /** @@ -814,7 +819,7 @@ export class InteractiveMode implements InteractiveModeContext { this.editor.onAutocompleteUpdate = () => { this.ui.requestRender(); }; - this.editor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor)); + this.editor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(this.editor)); this.#syncEditorMaxHeight(); this.#resizeHandler = () => { this.#syncEditorMaxHeight(); @@ -1108,6 +1113,15 @@ export class InteractiveMode implements InteractiveModeContext { // before initHooksAndCustomTools/#reconcileModeFromSession/#enterPlanMode — // all of which can reach setSessionName during init. this.#eventBusUnsubscribers.push( + this.sessionManager.onPersistenceError(error => { + const detail = truncateToWidth( + replaceTabs(sanitizeText(error.message)).replace(/[\r\n]+/g, " "), + TRUNCATE_LENGTHS.LINE, + ); + this.showWarning( + `Session persistence failed: ${detail}. Unsaved entries remain in memory; persistence will retry on the next entry.`, + ); + }), this.sessionManager.onSessionNameChanged(() => { setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd()); this.#handleSessionAccentInputsChanged(); @@ -1177,6 +1191,9 @@ export class InteractiveMode implements InteractiveModeContext { this.#eventBusUnsubscribers.push( this.session.subscribe(event => { + if (event.type === "model_changed") { + this.#updateWelcomeModel(); + } void this.#handleGoalSessionEvent(event); }), onStatusLineSessionAccentChanged(() => { @@ -1184,6 +1201,11 @@ export class InteractiveMode implements InteractiveModeContext { this.#handleSessionAccentInputsChanged(); }), ); + // Resync the welcome banner to the live model: init-time reconciliations + // (#reconcileModeFromSession, #enterPlanMode for plan.defaultOnStartup) + // can change the model before this subscription exists, so the + // model_changed events they emit are never observed by the handler above. + this.#updateWelcomeModel(); this.#eventBusUnsubscribers.push( onModelRolesChanged(() => { void this.#reapplyPlanModeModelOnRoleChange(); @@ -3509,10 +3531,18 @@ export class InteractiveMode implements InteractiveModeContext { if (!this.vibeModeEnabled) { return; } - const ownerScope = this.#vibeModeOwnerScope; - const killed = await VibeSessionRegistry.global().killAll(this.#vibeParentSession(), ownerScope); - await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []); - this.session.setVibeModeState(undefined); + // Tear down with the queued-message drain suppressed: aborting the active + // turn would otherwise let a queued user steer/follow-up restart on the + // still-live Vibe tools before this teardown removes them (issue #8326). + let killed = 0; + await this.session.runModeExitTeardown(async () => { + if (this.session.isStreaming) { + await this.session.abort(); + } + killed = await VibeSessionRegistry.global().killAll(this.#vibeParentSession(), this.#vibeModeOwnerScope); + await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []); + this.session.setVibeModeState(undefined); + }); this.vibeModeEnabled = false; this.#vibeModePreviousTools = undefined; this.#vibeModeOwnerScope = undefined; @@ -4252,7 +4282,7 @@ export class InteractiveMode implements InteractiveModeContext { nextEditor.onAutocompleteUpdate = () => { this.ui.requestRender(); }; - nextEditor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor)); + nextEditor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(nextEditor)); nextEditor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth)); nextEditor.setMaxHeight(this.#computeEditorMaxHeight()); if (this.historyStorage) { @@ -4430,6 +4460,15 @@ export class InteractiveMode implements InteractiveModeContext { ); } + #updateWelcomeModel(): void { + if (!this.#welcomeComponent) { + return; + } + + this.#welcomeComponent.setModel(this.session.model?.name ?? "Unknown", this.session.model?.provider ?? "Unknown"); + this.ui.requestRender(); + } + #updateWelcomeLspServers(): void { if (!this.#welcomeComponent) { return; diff --git a/packages/coding-agent/src/modes/theme/defaults/birch.json b/packages/coding-agent/src/modes/theme/defaults/birch.json index eaab2e29e..853800898 100644 --- a/packages/coding-agent/src/modes/theme/defaults/birch.json +++ b/packages/coding-agent/src/modes/theme/defaults/birch.json @@ -33,9 +33,9 @@ "thinkingText": "warmGray", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", - "userMessageText": "", + "userMessageText": "forestFloor", "customMessageBg": "customMsgBg", - "customMessageText": "", + "customMessageText": "forestFloor", "customMessageLabel": "#5a7a52", "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", diff --git a/packages/coding-agent/src/prompts/system/checkpoint-active-notice.md b/packages/coding-agent/src/prompts/system/checkpoint-active-notice.md new file mode 100644 index 000000000..140edb6de --- /dev/null +++ b/packages/coding-agent/src/prompts/system/checkpoint-active-notice.md @@ -0,0 +1,5 @@ + +Exploration checkpoint active. +- MUST `rewind` with findings once exploration is done. +- MUST `rewind` before yielding. + diff --git a/packages/coding-agent/src/prompts/system/date-cwd-reminder.md b/packages/coding-agent/src/prompts/system/date-cwd-reminder.md new file mode 100644 index 000000000..ac76906ed --- /dev/null +++ b/packages/coding-agent/src/prompts/system/date-cwd-reminder.md @@ -0,0 +1,3 @@ + +Today: {{date}}; current working directory: '{{cwd}}'. Do not repeat this information in your reply. + diff --git a/packages/coding-agent/src/prompts/system/project-prompt.md b/packages/coding-agent/src/prompts/system/project-prompt.md index a35e4a0c0..fc88ef222 100644 --- a/packages/coding-agent/src/prompts/system/project-prompt.md +++ b/packages/coding-agent/src/prompts/system/project-prompt.md @@ -47,7 +47,6 @@ Additional workspace directories. This CURRENT workspace state supersedes worksp {{/each}} {{/if}} -Today: {{date}}; current working directory: '{{cwd}}'. - Each response MUST advance the task; completion only stopping condition. diff --git a/packages/coding-agent/src/prompts/system/rewind-report.md b/packages/coding-agent/src/prompts/system/rewind-report.md index 98b1d2705..d74f7d21f 100644 --- a/packages/coding-agent/src/prompts/system/rewind-report.md +++ b/packages/coding-agent/src/prompts/system/rewind-report.md @@ -1,6 +1,4 @@ -Checkpoint: complete; exploratory branch rewound. -Context: branch summary and retained report below. -NEVER call `rewind` again for this checkpoint; continue from retained report. +Checkpoint called and rewound. Report retained below. Need explore again → new `checkpoint`. Report: {{report}} diff --git a/packages/coding-agent/src/prompts/tools/browser.md b/packages/coding-agent/src/prompts/tools/browser.md index 08daf0085..a6db08cc2 100644 --- a/packages/coding-agent/src/prompts/tools/browser.md +++ b/packages/coding-agent/src/prompts/tools/browser.md @@ -21,6 +21,7 @@ Drives real Chromium tab; full puppeteer access via JS. - `app.path` → NEVER tamper with a real desktop app (no stealth patches). - `app.relay: true` → drive the user's own Chrome tabs via the omp browser relay (auto-started; needs the OMP Browser Relay extension installed). `app.target` picks a tab by URL/title substring; without it the visible tab is adopted without stealing focus. +- `close` releases the named tool session. It closes tool-owned headless pages and owned cmux surfaces, but NEVER closes pages in CDP-connected or relay browsers. Spawned-browser pages remain open unless `kill: true` terminates their process. - Selectors: CSS + puppeteer `aria/…`, `text/…`, `xpath/…`, `pierce/…`. Playwright-only pseudos (`:has-text()`, `:visible`) are REJECTED. diff --git a/packages/coding-agent/src/prompts/tools/rewind.md b/packages/coding-agent/src/prompts/tools/rewind.md index 7d2afec1d..a95fca21f 100644 --- a/packages/coding-agent/src/prompts/tools/rewind.md +++ b/packages/coding-agent/src/prompts/tools/rewind.md @@ -1,13 +1 @@ -End active checkpoint; rewind context to it, replacing intermediate exploration with your report. - -Call immediately after investigative work started by `checkpoint`. - -Requirements: -- `report` MUST be concise, factual, actionable; include key findings, decisions, unresolved risks. -- AVOID raw scratch logs unless essential. -- MUST call before yielding if checkpoint active. - -Behavior: -- No active checkpoint → error. Checkpoint already rewound → continue from retained report; NEVER retry. -- Success → session rewinds, retains your report as context, closes checkpoint. -- Successful rewind final for that checkpoint; repeat calls error. +End the active checkpoint; rewind context to it, replacing intermediate exploration with your report. diff --git a/packages/coding-agent/src/registry/agent-lifecycle.ts b/packages/coding-agent/src/registry/agent-lifecycle.ts index cdc0116a5..0f5606990 100644 --- a/packages/coding-agent/src/registry/agent-lifecycle.ts +++ b/packages/coding-agent/src/registry/agent-lifecycle.ts @@ -174,6 +174,40 @@ export class AgentLifecycleManager { ); } + /** + * Reclaim a provably-dead parked corpse so a fresh spawn can reuse its id. + * Refuses live, adopted, in-flight, or cold-revivable refs. For a parked ref + * restored from disk, the persisted factory is consulted before removal + * because cold revivers are created lazily by {@link ensureLive}. + * + * Only refs in the registry this manager owns are touched; the transcript + * stays readable at `history://`. Returns true when the corpse was + * unregistered. + */ + async reclaimDeadCorpse(id: string, expected: AgentRef): Promise { + const ref = this.#registry.get(id); + if (ref !== expected || ref.status !== "parked" || ref.session) return false; + if (this.#adopted.has(id) || this.#parks.has(id) || this.#revivals.has(id)) return false; + + const persistedFactory = ref.sessionFile ? this.#persistedReviverFactory : undefined; + if (persistedFactory) { + try { + if (await persistedFactory(ref)) return false; + } catch (error) { + logger.warn("AgentLifecycleManager.reclaimDeadCorpse: persisted reviver probe failed", { + id, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + // The factory awaited I/O; another lifecycle operation may now own or + // have replaced this ref. Revalidate every reclaim invariant. + if (this.#registry.get(id) !== ref || ref.status !== "parked" || ref.session) return false; + if (this.#adopted.has(id) || this.#parks.has(id) || this.#revivals.has(id)) return false; + } + return this.#registry.unregister(id, ref); + } + /** * True when this manager owns `registry` — i.e. its adopt/park/revive state * describes that registry's refs. Lets a caller holding a specific registry diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index 500aef6e3..15e395bf3 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -9,6 +9,7 @@ * revival) and are only removed on explicit release/teardown. */ +import { logger } from "@oh-my-pi/pi-utils"; import type { AgentSession } from "../session/agent-session"; import { oneLineLabel } from "../task/types"; @@ -135,6 +136,11 @@ export class AgentRegistry { return expected === undefined || ref === expected || ref.session === expected; } + #rejectStatusUpdate(id: string, status: AgentStatus, reason: string): false { + logger.debug("Agent registry status update rejected", { id, status, reason }); + return false; + } + register(input: RegisterInput): AgentRef { const now = Date.now(); const ref: AgentRef = { @@ -181,10 +187,15 @@ export class AgentRegistry { setStatus(id: string, status: AgentStatus, expected?: AgentRefExpectation): boolean { const ref = this.#refs.get(id); - if (!ref || !this.#matchesExpected(ref, expected)) return false; + if (!ref) return this.#rejectStatusUpdate(id, status, "missing-ref"); + if (!this.#matchesExpected(ref, expected)) { + return this.#rejectStatusUpdate(id, status, "session-ownership-changed"); + } // `aborted` is terminal: delayed progress/revival work from the killed // generation must never transition the tombstone back to a live status. - if (ref.status === "aborted") return status === "aborted"; + if (ref.status === "aborted") { + return status === "aborted" || this.#rejectStatusUpdate(id, status, "aborted-is-terminal"); + } if (ref.status === status) return true; ref.status = status; // Activity describes current work; it is meaningless once the agent @@ -270,6 +281,20 @@ export class AgentRegistry { ); } + /** Whether a ref's claimed running state is corroborated by its attached live session. */ + isRunning(ref: AgentRef): boolean { + if (ref.status !== "running") return false; + return ref.session?.isStreaming === true; + } + + /** Mirror a session's authoritative run-state notifications into its owned registry ref. */ + syncSessionStatus(id: string, session: AgentSession): () => void { + const unsubscribe = session.subscribeRunState(status => { + this.setStatus(id, status, session); + }); + return unsubscribe; + } + onChange(listener: RegistryListener): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 7f6043e3e..a8eb48d1e 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -25,6 +25,8 @@ interface PersistedAgentMetadata { createdAt?: number; lastActivity?: number; history?: AgentHistorySummary; + /** True when the file is only a SessionManager header (no session_init, no messages). */ + incomplete?: boolean; } interface PersistedTranscript { @@ -244,6 +246,8 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise { return await extensionRunner.emitBeforeProviderRequest(payload, model); @@ -3771,7 +3796,12 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro transformProviderContext: async (context, transformModel) => { let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context; transformed = clampProviderContextImages(transformed, transformModel); - return await normalizeProviderContextImagesForModel(transformed, transformModel); + transformed = await normalizeProviderContextImagesForModel(transformed, transformModel); + return withDateCwdReminder( + transformed, + formatLocalCalendarDate(), + normalizePromptPath(sessionManager.getCwd()), + ); }, thinkingBudgets: agent.thinkingBudgets, temperature: agent.temperature, diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 54182a25a..50f3be5b8 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -211,8 +211,6 @@ export interface AgentSessionConfig { toolNames: string[], tools: Map, ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>; - /** Local calendar date provider used by prompt-cache invalidation. */ - getLocalCalendarDate?: () => string; /** Tools mounted under `xd://`, for `/tools` display. */ getXdevToolEntries?: () => Array<{ name: string; summary: string }>; /** `xd://` presentation state backed by the canonical tool map. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cd5679440..cb490b418 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -161,6 +161,7 @@ import type { PlanModeState } from "../plan-mode/state"; import goalModeContextPrompt from "../prompts/goals/goal-mode-context.md" with { type: "text" }; import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" }; import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" }; +import checkpointActiveNoticeTemplate from "../prompts/system/checkpoint-active-notice.md" with { type: "text" }; import interruptedThinkingTemplate from "../prompts/system/interrupted-thinking.md" with { type: "text" }; import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" }; import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" }; @@ -285,6 +286,7 @@ import { import { type BashExecutionMessage, buildReplanTitleContext, + CHECKPOINT_ACTIVE_REMINDER_TYPE, type CustomMessage, type CustomMessagePayload, convertToLlm, @@ -297,6 +299,7 @@ import { type InterruptedThinkingDetails, isEmptyErrorTurn, isUserInterruptAbort, + isUserInvokedSkillPrompt, logProviderTurnError, normalizeCustomMessagePayload, type PythonExecutionMessage, @@ -328,7 +331,7 @@ import { SessionMaintenance, type SessionMaintenanceHost, } from "./session-maintenance"; -import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager"; +import { cleanupEmptyMoveSession, copySessionArtifacts, type SessionManager } from "./session-manager"; import { SessionMemory, type SessionMemoryHost } from "./session-memory"; import { buildSessionMetadata } from "./session-metadata"; import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./session-provider-boundary"; @@ -488,6 +491,7 @@ export class AgentSession { /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */ #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined }; #eventListeners: AgentSessionEventListener[] = []; + #runStateListeners = new Set<(state: "running" | "idle") => void>(); #commandMetadataChangedListeners: CommandMetadataChangedListener[] = []; #sessionChangeCallbacks = new Set<() => void>(); #observedSessionId: string | undefined; @@ -597,6 +601,7 @@ export class AgentSession { #usageFallbackConfirmer: UsageFallbackConfirmer | undefined; #usagePreflightAbortControllers = new Set(); #queuedMessageDrainBlocked = false; + #modeExitDrainSuppressionDepth = 0; #usagePreflightReadyForNextModelCall = false; #usagePreflightReadyModel: Model | undefined; #detachUsageBeforeQueueDequeue: (() => void) | undefined; @@ -771,7 +776,9 @@ export class AgentSession { * the agent responds to the peer. Skip only when a queued steer/follow-up will itself drive a * resume turn whose aside poll already consumes these (no double-wake). */ #resumeStrandedIrcAsides(): void { - if (this.#isDisposed || this.isStreaming || !this.#irc.hasPending()) return; + if (this.#modeExitDrainSuppressionDepth > 0 || this.#isDisposed || this.isStreaming || !this.#irc.hasPending()) { + return; + } if (this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) return; const records = this.#irc.drainPending(); if (this.#planModeState?.enabled) { @@ -800,6 +807,10 @@ export class AgentSession { * because #canAutoContinueForFollowUp suppresses follow-up auto-resume while a user interrupt is * in effect, even though the wake left a provider-valid tail. */ #wakeForIrc(records: CustomMessage[]): void { + if (this.#modeExitDrainSuppressionDepth > 0) { + this.#irc.deferWake(records); + return; + } // Park only a *blocked* follow-up (one a user interrupt is intentionally holding); an // already-resumable follow-up can ride the wake turn normally without reordering. const parkedFollowUps = @@ -1295,7 +1306,6 @@ export class AgentSession { presentationPinnedToolNames: config.presentationPinnedToolNames, ensureWriteRegistered: config.ensureWriteRegistered, rebuildSystemPrompt: config.rebuildSystemPrompt, - getLocalCalendarDate: config.getLocalCalendarDate, getMcpServerInstructions: config.getMcpServerInstructions, xdev: config.xdev, setActiveToolNames: config.setActiveToolNames, @@ -1384,7 +1394,7 @@ export class AgentSession { // Pre-scheduling tool_call wiring: extension handlers run at arg-prep // time so a block/revision lands before concurrency resolution, // tool_execution_start, and the wrapper's approval gate. - this.agent.beforeToolCall = ctx => this.#beforeToolCall(ctx); + this.agent.beforeToolCall = (ctx, signal) => this.#beforeToolCall(ctx, signal); this.agent.providerSessionState = this.#providerSessionState; this.#syncAgentSessionId(); this.#todo.syncFromBranch(); @@ -1518,7 +1528,7 @@ export class AgentSession { planReferencePath: () => this.#planReferencePath, nonMessageTokenSource: () => this, memoryBackendSession: () => this, - emitSessionEvent: event => this.#emitSessionEvent(event), + emitSessionEvent: (event, options) => this.#emitSessionEvent(event, options), emitNotice: (level, message, source) => this.emitNotice(level, message, source), schedulePostPromptTask: (task, options) => this.#schedulePostPromptTask(task, options), scheduleAgentContinue: options => this.#scheduleAgentContinue(options), @@ -1968,6 +1978,18 @@ export class AgentSession { } } + #emitRunState(state: "running" | "idle"): void { + for (const listener of this.#runStateListeners) { + try { + listener(state); + } catch (error) { + logger.warn("AgentSession run-state listener threw", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + /** * Emit a UI-only notice to the session. Surfaces in interactive mode as a * `showWarning` / `showError` / `showStatus` line; non-interactive modes @@ -2063,7 +2085,7 @@ export class AgentSession { */ #subscriberEmitGate: Promise = Promise.resolve(); - async #emitSessionEvent(event: AgentSessionEvent): Promise { + async #emitSessionEvent(event: AgentSessionEvent, options: { detachExtensions?: boolean } = {}): Promise { if (event.type === "message_update") { this.#emit(event); void this.#queueExtensionEvent(event); @@ -2076,7 +2098,17 @@ export class AgentSession { const { promise: gate, resolve: releaseGate } = Promise.withResolvers(); this.#subscriberEmitGate = gate; try { - await this.#emitExtensionEvent(event); + const extensionEmit = this.#emitExtensionEvent(event); + if (options.detachExtensions) { + void extensionEmit.catch(error => { + logger.warn("Detached session event extension emit failed", { + type: event.type, + error: error instanceof Error ? error.message : String(error), + }); + }); + } else { + await extensionEmit; + } await previousGate; // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal; @@ -2341,6 +2373,34 @@ export class AgentSession { } } + /** + * Builds the transient checkpoint-active reminder for a successful + * checkpoint tool result, or undefined otherwise. The reminder is queued as + * steering synchronously in the message_end handler (before any await), so + * the agent loop folds it into the next provider call and persists it through + * its normal custom-message event. Because the entry sits after the checkpoint + * entry, the rewind branch cut drops it from the active path. + */ + #checkpointActiveReminderFor( + message: AgentMessage, + ): CustomMessage<{ goal?: string; startedAt?: string }> | undefined { + if (message.role !== "toolResult" || message.isError) return undefined; + const semanticResult = semanticToolResult(message.toolName, message); + if (semanticResult?.toolName !== "checkpoint") return undefined; + const details = isRecord(semanticResult.details) ? semanticResult.details : undefined; + const goal = details ? stringProperty(details, "goal") : undefined; + const startedAt = details ? stringProperty(details, "startedAt") : undefined; + return { + role: "custom", + customType: CHECKPOINT_ACTIVE_REMINDER_TYPE, + content: prompt.render(checkpointActiveNoticeTemplate), + display: false, + details: { goal, startedAt }, + attribution: "agent", + timestamp: Date.now(), + }; + } + #persistMessageEnd(message: AgentMessage): void { if (message.role === "hookMessage" || message.role === "custom") { // Prewalk's plan nudge is a one-run steering instruction. Persisting it would @@ -2438,6 +2498,7 @@ export class AgentSession { // turn: state-based lookups take over again. if (event.type === "agent_start") { this.#prunedTerminalRefusal = undefined; + this.#emitRunState("running"); } // Step the mid-run todo counter synchronously, BEFORE any await in this // handler. The agent loop's next-turn `getAsideMessages` poll can run @@ -2498,6 +2559,35 @@ export class AgentSession { this.agent.appendMessage(interruptedThinkingMessage); } + // message_end listeners are fire-and-forget, and the agent loop runs against + // a context cloned at prompt start. Appending only to agent.state would not + // reach the next provider call in this tool loop. Queue the reminder as + // steering before any await so the loop drains it at the next step boundary; + // its normal custom-message event persists it after the checkpoint entry, + // allowing the rewind branch cut to drop it from the active path. + const checkpointReminder = + event.type === "message_end" && event.message.role === "toolResult" + ? this.#checkpointActiveReminderFor(event.message) + : undefined; + if (checkpointReminder) { + // Set #checkpointState synchronously too: the reminder is now visible to + // the very next provider call, so a model that immediately calls `rewind` + // must find an active checkpoint in RewindTool.execute(). The entry id is + // backfilled post-await (see the toolResult handler) once the checkpoint + // toolResult entry is persisted; #applyRewind runs on a later rewind turn, + // never before that backfill. + this.#checkpointState = { + checkpointMessageCount: this.agent.state.messages.length, + checkpointEntryId: null, + startedAt: + (checkpointReminder.details && stringProperty(checkpointReminder.details, "startedAt")) ?? + new Date().toISOString(), + }; + this.#pendingRewindReport = undefined; + this.#lastCompletedRewind = undefined; + this.agent.steer(checkpointReminder); + } + const messageEndPersistence = event.type === "message_end" ? this.#createMessageEndPersistenceSlot(event.message) : undefined; @@ -2704,15 +2794,24 @@ export class AgentSession { ); } if (semanticResult?.toolName === "checkpoint" && !isError) { - const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null; - this.#checkpointState = { - checkpointMessageCount: this.agent.state.messages.length, - checkpointEntryId, - startedAt: - (semanticDetails && stringProperty(semanticDetails, "startedAt")) ?? new Date().toISOString(), - }; - this.#pendingRewindReport = undefined; - this.#lastCompletedRewind = undefined; + // Backfill the checkpoint entry id now that the toolResult entry is + // persisted. #checkpointState was set synchronously pre-await (with a + // null entry id) so an immediate `rewind` still finds an active + // checkpoint; locate the toolResult's own entry by identity since the + // transient reminder entry follows it (last-entry would branch-cut the + // reminder instead of leaving it on the active path). + const entries = this.sessionManager.getEntries(); + let checkpointEntryId: string | null = null; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "message" && entry.message === event.message) { + checkpointEntryId = entry.id; + break; + } + } + if (this.#checkpointState) { + this.#checkpointState.checkpointEntryId = checkpointEntryId; + } } if (semanticResult?.toolName === "rewind" && !isError && this.#checkpointState) { const detailReport = semanticDetails ? (stringProperty(semanticDetails, "report")?.trim() ?? "") : ""; @@ -2733,6 +2832,7 @@ export class AgentSession { // maintenance can emit agent_end, so preserve the state at settle entry. const ttsrAbortPendingAtAgentEnd = this.#ttsr.abortPending; const emitAgentEndNotification = async (options?: { willContinue?: boolean }) => { + this.#emitRunState("idle"); // Public agent_end is held out of the eager display pass and emitted // here after maintenance routing, tagged isTerminal so subscribers can // tell final settles from scheduled continuations. @@ -3297,7 +3397,7 @@ export class AgentSession { * emit a second event (nested xd:// device dispatches and direct non-loop * execution still emit there). */ - async #beforeToolCall(ctx: BeforeToolCallContext): Promise { + async #beforeToolCall(ctx: BeforeToolCallContext, signal?: AbortSignal): Promise { const runner = this.#extensionRunner; if (!runner?.hasHandlers("tool_call")) return undefined; const metadata = ctx.toolCall.providerMetadata; @@ -3315,12 +3415,15 @@ export class AgentSession { ? { actions: computer.actions, pendingSafetyChecks: computer.pendingSafetyChecks } : ctx.args; runner.markToolCallEmitted(ctx.toolCall.id, ctx.tool.name); - const callResult = await runner.emitToolCall({ - type: "tool_call", - toolName: ctx.tool.name, - toolCallId: ctx.toolCall.id, - input: normalizeToolEventInput(ctx.tool.name, resolveToolEventInput(ctx.tool, eventArgs)), - }); + const callResult = await runner.emitToolCall( + { + type: "tool_call", + toolName: ctx.tool.name, + toolCallId: ctx.toolCall.id, + input: normalizeToolEventInput(ctx.tool.name, resolveToolEventInput(ctx.tool, eventArgs)), + }, + signal, + ); if (callResult?.block) { return { block: true, reason: callResult.reason || "Tool execution was blocked by an extension" }; } @@ -3608,6 +3711,15 @@ export class AgentSession { }; } + /** + * Observe authoritative run-state transitions before public `agent_end` + * deferral, for lifecycle owners that must not remain stale while prompts unwind. + */ + subscribeRunState(listener: (state: "running" | "idle") => void): () => void { + this.#runStateListeners.add(listener); + return () => this.#runStateListeners.delete(listener); + } + /** Register cleanup that runs when this AgentSession adopts a different session ID. */ registerSessionChangeCallback(callback: () => void): () => void { this.#sessionChangeCallbacks.add(callback); @@ -3970,6 +4082,7 @@ export class AgentSession { this.#unsubscribeModelRoles = undefined; } this.#eventListeners = []; + this.#runStateListeners.clear(); this.#sessionChangeCallbacks.clear(); // A dispose triggered mid-turn (Ctrl-C / timeout / hard-killed subagent) @@ -5568,10 +5681,14 @@ export class AgentSession { } // Auto thinking: classify this real user turn and set the effective level - // before the model request. Synthetic/tool-continuation turns (developer/ - // custom roles) and non-auto sessions are skipped. Never blocks the turn — - // failures fall back to a concrete level inside the helper. - if (this.isAutoThinking && message.role === "user") { + // before the model request. A user-invoked `/skill:` arrives as a + // user-attributed skill custom message whose expanded body is the task + // prompt, so it counts as a user turn. Synthetic/tool-continuation turns + // (developer roles), agent-originated or autoloaded skill injections, and + // non-auto sessions are skipped. Never blocks the turn — failures fall + // back to a concrete level inside the helper. + const isUserTurn = message.role === "user" || (message.role === "custom" && isUserInvokedSkillPrompt(message)); + if (this.isAutoThinking && isUserTurn) { await this.#models.applyAutoThinkingLevel(expandedText, generation); if (this.#promptGeneration !== generation) { return; @@ -5836,6 +5953,33 @@ export class AgentSession { this.#scheduleIdleQueueDrain(); } + /** + * Run a mode-exit `teardown` (abort the active turn, swap the toolset, clear + * mode state) with queued-message auto-resume suppressed, then re-arm the + * drain so a queued user turn resumes cleanly once the previous toolset is + * back. + * + * `abort()`'s stranded-queue drain runs from its own `finally`; without this + * guard a queued steer/follow-up behind the aborted turn would start a fresh + * `agent.continue()` during the teardown's `await`s — while the exiting mode's + * tools/context are still live — and then have those tools removed underneath + * it, reintroducing the mode's stale-tool failure on the restarted turn + * (issue #8326). Suppressing the drain across teardown guarantees the queued + * turn resumes only after teardown, so it runs as a clean non-mode turn. + */ + async runModeExitTeardown(teardown: () => Promise): Promise { + this.#modeExitDrainSuppressionDepth++; + try { + await teardown(); + } finally { + this.#modeExitDrainSuppressionDepth--; + if (this.#modeExitDrainSuppressionDepth === 0) { + this.#scheduleIdleQueueDrain(); + this.#resumeStrandedIrcAsides(); + } + } + } + async #queueUserMessage( text: string, images: ImageContent[] | undefined, @@ -5884,6 +6028,7 @@ export class AgentSession { #scheduleQueuedMessageDrain(): void { if ( this.#queuedMessageDrainScheduled || + this.#modeExitDrainSuppressionDepth > 0 || this.#queuedMessageDrainBlocked || !this.#canAutoContinueForFollowUp() || !this.agent.hasQueuedMessages() @@ -5894,7 +6039,11 @@ export class AgentSession { this.#scheduleAgentContinue({ shouldContinue: () => { this.#queuedMessageDrainScheduled = false; - return this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(); + return ( + this.#modeExitDrainSuppressionDepth === 0 && + this.#canAutoContinueForFollowUp() && + this.agent.hasQueuedMessages() + ); }, onSkip: () => { this.#queuedMessageDrainScheduled = false; @@ -6701,24 +6850,7 @@ export class AgentSession { // under a fresh id, so the work already produced is still this session's. this.#recovery.reanchorServedAttribution(previousSessionId); - // Copy artifacts directory if it exists - const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6); - const newArtifactDir = forkResult.newSessionFile.slice(0, -6); - - try { - const oldDirStat = await fs.promises.stat(oldArtifactDir); - if (oldDirStat.isDirectory()) { - await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true }); - } - } catch (err) { - if (!isEnoent(err)) { - logger.warn("Failed to copy artifacts during fork", { - oldArtifactDir, - newArtifactDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } + await copySessionArtifacts(forkResult.oldSessionFile, forkResult.newSessionFile); // Update agent session ID this.#freshProviderSessionId = undefined; diff --git a/packages/coding-agent/src/session/date-cwd-reminder.ts b/packages/coding-agent/src/session/date-cwd-reminder.ts new file mode 100644 index 000000000..e49e97d29 --- /dev/null +++ b/packages/coding-agent/src/session/date-cwd-reminder.ts @@ -0,0 +1,77 @@ +/** + * Date/cwd reminder injection. + * + * The system prompt must stay byte-stable so open-weight chat templates that + * render tool schemas *after* the system content keep their prefix cache + * (#7404). The per-request date/cwd line used to live at the tail of the + * system prompt (`project-prompt.md`), which invalidated the whole tool array + * on every directory change or day rollover. It now rides on the first user + * turn of each provider request instead: built at request time (never stored + * in the session), deterministic per `(date, cwd)`, so the bytes are stable + * for the lifetime of a session/day and refresh automatically at midnight. + */ +import type { Context, Message } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; +import dateCwdReminderTemplate from "../prompts/system/date-cwd-reminder.md" with { type: "text" }; + +/** Renders the reminder text for the given local calendar date and cwd. */ +export function renderDateCwdReminder(date: string, cwd: string): string { + return prompt.render(dateCwdReminderTemplate, { date, cwd }).trim(); +} + +/** + * Prepends `reminder` to the content of the first user message in `messages`, + * returning a new array. The input is never mutated. Returns the input + * unchanged when there is no user message to attach to, when the first user + * message already carries the exact reminder, or when an identical input was + * injected before with the same reminder. + * + * The memo is required by the append-only context path: it syncs the converted + * message objects into its log and reuses them across requests, and callers + * assert that identity is preserved for the stable prefix. Re-injecting the + * same pristine first user message with the same reminder must hand back the + * same injected message object, not a fresh clone. Keyed on the pristine + * message object (the append-only log hands back fresh array copies every + * turn, so array identity is not stable), with entries garbage-collected + * alongside the messages they belong to. + */ +const injectCache = new WeakMap(); + +export function injectDateCwdReminder(messages: Message[], reminder: string): Message[] { + const index = messages.findIndex(message => message.role === "user"); + if (index < 0) return messages; + const first = messages[index]!; + if (typeof first.content === "string") { + if (first.content.startsWith(reminder)) return messages; + } else if (first.content[0]?.type === "text" && first.content[0].text === reminder) { + return messages; + } + const cached = injectCache.get(first); + if (cached !== undefined && cached.reminder === reminder) { + const out = messages.slice(); + out[index] = cached.injected; + return out; + } + const content = + typeof first.content === "string" + ? `${reminder}\n\n${first.content}` + : ([{ type: "text", text: reminder }, ...first.content] as Message["content"]); + const injected = { ...first, content } as Message; + injectCache.set(first, { reminder, injected }); + const out = messages.slice(); + out[index] = injected; + return out; +} + +/** + * Applies the date/cwd reminder to a provider `Context`, keeping the system + * prompt byte-stable for prompt caching. Skips NULL_PROMPT-style contexts + * (empty system prompt) so a no-prompt session stays byte-for-byte unchanged. + */ +export function withDateCwdReminder(context: Context, date: string, cwd: string): Context { + if (!context.systemPrompt || context.systemPrompt.length === 0) return context; + if (context.messages.length === 0) return context; + const reminder = renderDateCwdReminder(date, cwd); + const messages = injectDateCwdReminder(context.messages, reminder); + return messages === context.messages ? context : { ...context, messages }; +} diff --git a/packages/coding-agent/src/session/irc-bridge.ts b/packages/coding-agent/src/session/irc-bridge.ts index 831fa2e76..0e04d277d 100644 --- a/packages/coding-agent/src/session/irc-bridge.ts +++ b/packages/coding-agent/src/session/irc-bridge.ts @@ -51,6 +51,11 @@ export class IrcBridge { return records; } + /** Queues records whose idle wake must wait for a session transition to finish. */ + deferWake(records: CustomMessage[]): void { + this.#asides.push(...records); + } + /** Surfaces and consumes queued incoming records before automatic injection. */ drainInboxMessages(agentId: string, opts?: { from?: string; limit?: number }): IrcMessage[] { const messages: IrcMessage[] = []; diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 031c25b24..4926ad5f9 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -328,6 +328,9 @@ export type NormalizedCustomMessagePayload = Pick< /** Custom message type for hidden interrupted-thinking continuity context. */ export const INTERRUPTED_THINKING_MESSAGE_TYPE = "interrupted-thinking"; +/** Custom message type for the transient checkpoint-active reminder. */ +export const CHECKPOINT_ACTIVE_REMINDER_TYPE = "checkpoint-active-reminder"; + /** Metadata persisted with a hidden interrupted-thinking continuity message. */ export interface InterruptedThinkingDetails { interruptedAt: number; @@ -1085,7 +1088,8 @@ function customMessageContentToLlmContent(content: CustomMessage["content"]): (T return typeof content === "string" ? [{ type: "text", text: content }] : content; } -function isUserInvokedSkillPrompt(message: CustomMessage): boolean { +/** True for a `/skill:` prompt the user invoked directly (attribution `user`), as opposed to an agent/autoload injection. */ +export function isUserInvokedSkillPrompt(message: CustomMessage): boolean { return message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user"; } diff --git a/packages/coding-agent/src/session/session-loader.ts b/packages/coding-agent/src/session/session-loader.ts index 2c2f0fab2..c909a7ca3 100644 --- a/packages/coding-agent/src/session/session-loader.ts +++ b/packages/coding-agent/src/session/session-loader.ts @@ -2,14 +2,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils"; import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store"; import { buildSessionContext } from "./session-context"; -import { - type FileEntry, - type RawFileEntry, - SESSION_TITLE_SLOT_BYTES, - type SessionEntry, - type SessionHeader, - type SessionTitleSlotEntry, -} from "./session-entries"; +import type { FileEntry, RawFileEntry, SessionEntry, SessionHeader } from "./session-entries"; import { migrateToCurrentVersion } from "./session-migrations"; import { isImageBlock, isImageDataPayload } from "./session-persistence"; import { FileSessionStorage, type SessionStorage } from "./session-storage"; @@ -33,6 +26,15 @@ export interface VisitEntriesFromFileStreamOptions { yieldEveryBytes?: number; /** Yield to the macrotask queue after this many entries have been visited. */ yieldEveryEntries?: number; + /** Called once for every malformed JSONL record skipped by the stream. */ + onMalformedRecord?: () => void; +} + +/** Parsed session entries plus corruption metadata needed by writable loaders. */ +export interface SessionLoadResult { + entries: FileEntry[]; + titleSlot: SessionTitleUpdate | undefined; + malformedRecords: number; } function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } { @@ -42,31 +44,35 @@ function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpda return { body: content.slice(newlineIndex + 1), slot }; } -function foldTitleSlot(entries: FileEntry[], slot: SessionTitleUpdate | undefined): FileEntry[] { - if (!slot || entries.length === 0) return entries; - const header = entries[0] as SessionHeader; - if (header.type !== "session" || typeof header.id !== "string") return entries; +function isValidSessionHeader(entry: FileEntry | undefined): entry is SessionHeader { + return entry?.type === "session" && typeof entry.id === "string"; +} + +function applyTitleSlot(entry: FileEntry | undefined, slot: SessionTitleUpdate | undefined): void { + if (!slot || !isValidSessionHeader(entry)) return; if (slot.title && slot.title.length > 0) { - header.title = slot.title; + entry.title = slot.title; } else { - delete header.title; + delete entry.title; } if (slot.source) { - header.titleSource = slot.source; + entry.titleSource = slot.source; } else { - delete header.titleSource; + delete entry.titleSource; } - return entries; } /** Parse session JSONL while stripping and folding the optional fixed title slot. */ -export function parseSessionContent(content: string): { - entries: FileEntry[]; - titleSlot: SessionTitleUpdate | undefined; -} { +export function parseSessionContent(content: string): SessionLoadResult { const { body, slot } = splitTitleSlot(content); - const entries = parseJsonlLenient(body) as FileEntry[]; - return { entries: foldTitleSlot(entries, slot), titleSlot: slot }; + let malformedRecords = 0; + const entries = parseJsonlLenient(body, { + onMalformedRecord: () => { + malformedRecords++; + }, + }) as FileEntry[]; + applyTitleSlot(entries[0], slot); + return { entries, titleSlot: slot, malformedRecords }; } /** Parse session JSONL and visit each entry without retaining prior entries. */ @@ -77,6 +83,7 @@ export async function visitEntriesFromFileStream( ): Promise { let titleSlot: SessionTitleUpdate | undefined; let sawFirstLine = false; + let sawFirstEntry = false; let bytesSinceYield = 0; let entriesSinceYield = 0; let recordsSeen = 0; @@ -121,8 +128,13 @@ export async function visitEntriesFromFileStream( stopped = true; break; } + const entry = value as FileEntry; + if (!sawFirstEntry) { + sawFirstEntry = true; + applyTitleSlot(entry, titleSlot); + } try { - if (visit(value as FileEntry) === false) { + if (visit(entry) === false) { stopped = true; break; } @@ -143,6 +155,15 @@ export async function visitEntriesFromFileStream( // Malformed record: skip past the next newline and continue. const nextNewline = buffer.indexOf(0x0a, read); if (nextNewline === -1) break; // rest of the bad line not yet received + let nonWhitespace = false; + for (let index = read; index < nextNewline; index++) { + const byte = buffer[index]; + if (byte !== 0x09 && byte !== 0x0d && byte !== 0x20) { + nonWhitespace = true; + break; + } + } + if (nonWhitespace) options.onMalformedRecord?.(); recordsSeen++; buffer = buffer.subarray(nextNewline + 1); if (recordsSeen >= maxRecords) { @@ -203,64 +224,85 @@ export async function visitEntriesFromFileStream( } /** Exported for testing — the ≥8MiB streaming path (works on any file size). */ -export async function loadEntriesFromFileStream(filePath: string): Promise<{ - entries: FileEntry[]; - titleSlot: SessionTitleUpdate | undefined; -}> { +export async function loadEntriesFromFileStream(filePath: string): Promise { const entries: FileEntry[] = []; - const titleSlot = await visitEntriesFromFileStream(filePath, entry => { - entries.push(entry); - }); - return { entries: foldTitleSlot(entries, titleSlot), titleSlot }; + let malformedRecords = 0; + const titleSlot = await visitEntriesFromFileStream( + filePath, + entry => { + entries.push(entry); + }, + { + onMalformedRecord: () => { + malformedRecords++; + }, + }, + ); + return { entries, titleSlot, malformedRecords }; } -/** Read only the fixed-size head window to detect a physical title slot. */ -export async function readTitleSlotFromFile( - filePath: string, - storage: SessionStorage = new FileSessionStorage(), -): Promise { - let head: string; - try { - [head] = await storage.readTextSlices(filePath, SESSION_TITLE_SLOT_BYTES, 0); - } catch (err) { - if (isEnoent(err)) return undefined; - throw err; - } - const newlineIndex = head.indexOf("\n"); - if (newlineIndex < 0) return undefined; - return parseTitleSlotLine(head.slice(0, newlineIndex)); -} /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): FileEntry[] { return parseSessionContent(content).entries; } -/** Exported for testing */ +function shouldStreamEntries(storage: SessionStorage, size: number): boolean { + return storage instanceof FileSessionStorage && size >= STREAM_LOAD_THRESHOLD_BYTES; +} + +async function loadWithKnownSize(filePath: string, storage: SessionStorage, size: number): Promise { + const loaded = shouldStreamEntries(storage, size) + ? await loadEntriesFromFileStream(filePath) + : parseSessionContent(await storage.readText(filePath)); + return isValidSessionHeader(loaded.entries[0]) ? loaded : { ...loaded, entries: [] }; +} + +/** Load and validate a session while retaining malformed-record diagnostics. */ +export async function loadSessionFile( + filePath: string, + storage: SessionStorage = new FileSessionStorage(), +): Promise { + try { + return await loadWithKnownSize(filePath, storage, storage.statSync(filePath).size); + } catch (err) { + if (isEnoent(err)) return { entries: [], titleSlot: undefined, malformedRecords: 0 }; + throw err; + } +} + +/** Load the valid entries from a session file, skipping malformed records. */ export async function loadEntriesFromFile( filePath: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { - let loaded: { entries: FileEntry[]; titleSlot: SessionTitleUpdate | undefined }; - try { - const stat = storage.statSync(filePath); - loaded = - storage instanceof FileSessionStorage && stat.size >= STREAM_LOAD_THRESHOLD_BYTES - ? await loadEntriesFromFileStream(filePath) - : parseSessionContent(await storage.readText(filePath)); - } catch (err) { - if (isEnoent(err)) return []; - throw err; - } - const { entries } = loaded; + return (await loadSessionFile(filePath, storage)).entries; +} - // Validate session header - if (entries.length === 0) return entries; - const header = entries[0] as SessionHeader; - if (header.type !== "session" || typeof header.id !== "string") { - return []; +/** + * Visit session entries, using bounded streaming for large file-backed journals. + * Small files and non-file backends keep the existing full-load path. + */ +export async function visitEntriesFromFile( + filePath: string, + visit: (entry: FileEntry) => void | boolean, + storage: SessionStorage = new FileSessionStorage(), +): Promise { + const size = storage.statSync(filePath).size; + if (shouldStreamEntries(storage, size)) { + let sawFirstEntry = false; + await visitEntriesFromFileStream(filePath, entry => { + if (!sawFirstEntry) { + sawFirstEntry = true; + if (!isValidSessionHeader(entry)) return false; + } + return visit(entry); + }); + return; } - return entries; + for (const entry of (await loadWithKnownSize(filePath, storage, size)).entries) { + if (visit(entry) === false) return; + } } /** diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 4a62968fb..7466d6f38 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -196,7 +196,7 @@ export interface SessionMaintenanceHost { planReferencePath(): string; nonMessageTokenSource(): NonMessageTokenSource; memoryBackendSession(): MemoryBackendOperationContext["session"]; - emitSessionEvent(event: AgentSessionEvent): Promise; + emitSessionEvent(event: AgentSessionEvent, options?: { detachExtensions?: boolean }): Promise; emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void; schedulePostPromptTask( task: (signal: AbortSignal) => Promise, @@ -247,7 +247,7 @@ export interface SessionMaintenanceHost { dropImages(): Promise<{ removed: number }>; runHandoff(customInstructions?: string, options?: SessionHandoffOptions): Promise; removeAssistantMessageFromActiveContext(message: AssistantMessage): void; - dropPersistedAssistantTurn(message: AssistantMessage): Promise; + dropPersistedAssistantTurn(message: AssistantMessage): Promise; runRecoveryCompactionWithRollback( reason: "overflow" | "incomplete", message: AssistantMessage, @@ -313,6 +313,15 @@ export class SessionMaintenance { set skipPostTurnMaintenanceAssistantTimestamp(timestamp: number | undefined) { this.#skipPostTurnMaintenanceAssistantTimestamp = timestamp; } + + /** + * Emit a compaction lifecycle event. Mid-turn callers detach only extension + * fan-out for post-commit events; ordered subscriber delivery still completes + * before maintenance returns. + */ + #emitLifecycleEvent(event: AgentSessionEvent, detach: boolean): Promise { + return this.#host.emitSessionEvent(event, detach ? { detachExtensions: true } : undefined); + } /** * Append plan-read protection to a prune/shake config so the active plan * file survives compaction alongside skill reads (the config defaults @@ -1150,6 +1159,7 @@ export class SessionMaintenance { suppressHandoff: true, triggerContextTokens: contextTokens, phase: "mid_turn", + detachPostCommit: true, }); if (result.automaticContinuationBlocked) { this.#midTurnCompactionDeadEnds.add(activeMessages); @@ -2166,6 +2176,8 @@ export class SessionMaintenance { suppressHandoff?: boolean; phase?: CodexCompactionContext["phase"]; terminalTextAnswer?: boolean; + /** Mid-turn: splice history then return; do not await UI/extension fan-out. */ + detachPostCommit?: boolean; } = {}, ): Promise { const compactionSettings = this.#host.settings.getGroup("compaction"); @@ -2191,6 +2203,7 @@ export class SessionMaintenance { terminalTextAnswer, options.triggerContextTokens, suppressContinuation, + options.detachPostCommit === true, ); if (outcome !== "fallback") return outcome; fallbackFromShake = true; @@ -2252,7 +2265,8 @@ export class SessionMaintenance { // for any listener — and for input routed during this emit's event-loop yield: // a message typed as the compaction loader appears must land in the compaction // queue, not the core steering queue (which handoff's agent.reset() would wipe). - await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action }); + const startEvent = { type: "auto_compaction_start" as const, reason, action }; + await this.#emitLifecycleEvent(startEvent, false); if (action === "handoff") { let handoffSwitchCancelled = false; const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS; @@ -2266,13 +2280,16 @@ export class SessionMaintenance { if (!handoffResult) { const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled; if (aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", { @@ -2281,13 +2298,16 @@ export class SessionMaintenance { action = "context-full"; } if (handoffResult) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + }, + options.detachPostCommit === true, + ); const continuationScheduled = !autoCompactionSignal.aborted && this.#host.scheduleCompactionContinuation({ @@ -2304,27 +2324,33 @@ export class SessionMaintenance { } if (!this.#model) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - skipped: true, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + skipped: true, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } const availableModels = this.#host.modelRegistry.getAvailable(); if (availableModels.length === 0) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - skipped: true, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + skipped: true, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } @@ -2411,17 +2437,20 @@ export class SessionMaintenance { // compaction entry — surface it as a real (non-skipped) result so // the TUI rebuilds the transcript instead of treating the pass as // a benign no-op. - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: frameRescueResult && { - ...frameRescueResult, - preserveData: snapcompact.stripPreservedArchive(frameRescueResult.preserveData), + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: frameRescueResult && { + ...frameRescueResult, + preserveData: snapcompact.stripPreservedArchive(frameRescueResult.preserveData), + }, + aborted: false, + willRetry: false, + skipped: frameRescueResult === undefined, }, - aborted: false, - willRetry: false, - skipped: frameRescueResult === undefined, - }); + options.detachPostCommit === true, + ); let continuationScheduled = false; if (frameRescueCreatedHeadroom) { continuationScheduled = this.#host.scheduleCompactionContinuation({ @@ -2469,13 +2498,16 @@ export class SessionMaintenance { })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } @@ -2652,6 +2684,11 @@ export class SessionMaintenance { providerSessionState: this.#host.providerSessionState, preferWebsockets: this.#host.preferWebsockets, codexCompaction, + // This loop already retries the whole compaction attempt on + // transient errors, so the summarization oneshots must not + // retry too — the budgets would multiply and each outer + // wait would stack on top of an inner backoff. + oneshotRetry: false, }, ); break; @@ -2763,13 +2800,16 @@ export class SessionMaintenance { } if (autoCompactionSignal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } @@ -2804,11 +2844,20 @@ export class SessionMaintenance { | undefined; if (this.#host.extensionRunner && savedCompactionEntry) { - await this.#host.extensionRunner.emit({ + const compactEmit = this.#host.extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, }); + if (options.detachPostCommit) { + void compactEmit.catch(error => { + logger.warn("Detached session_compact emit failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } else { + await compactEmit; + } } const result: CompactionResult = { @@ -2910,7 +2959,10 @@ export class SessionMaintenance { } } - await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); + await this.#emitLifecycleEvent( + { type: "auto_compaction_end", action, result, aborted: false, willRetry }, + options.detachPostCommit === true, + ); if (retryFits) { this.#host.scheduleAgentContinue({ delayMs: 100, generation }); @@ -2931,29 +2983,35 @@ export class SessionMaintenance { return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; } catch (error) { if (autoCompactionSignal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + options.detachPostCommit === true, + ); return COMPACTION_CHECK_NONE; } const errorMessage = error instanceof Error ? error.message : "compaction failed"; - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : reason === "incomplete" - ? `Incomplete response recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : reason === "incomplete" + ? `Incomplete response recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, + }, + options.detachPostCommit === true, + ); } finally { if (this.#autoCompactionAbortController === autoCompactionAbortController) { this.#autoCompactionAbortController = undefined; @@ -2980,6 +3038,7 @@ export class SessionMaintenance { terminalTextAnswer: boolean, triggerContextTokens?: number, suppressContinuation = false, + detachPostCommit = false, ): Promise { const action = "shake"; this.#autoCompactionAbortController?.abort(); @@ -2987,16 +3046,19 @@ export class SessionMaintenance { this.#autoCompactionAbortController = controller; const signal = controller.signal; try { - await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action }); + await this.#emitLifecycleEvent({ type: "auto_compaction_start", reason, action }, false); const result = await this.#host.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal }); if (signal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + detachPostCommit, + ); return COMPACTION_CHECK_NONE; } const reclaimed = result.toolResultsDropped + result.blocksDropped > 0; @@ -3039,25 +3101,31 @@ export class SessionMaintenance { const errorMessage = reclaimed ? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.` : "Auto-shake found nothing eligible to drop; falling back to context-full compaction."; - await this.#host.emitSessionEvent({ + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + skipped: !reclaimed, + errorMessage, + }, + detachPostCommit, + ); + return "fallback"; + } + await this.#emitLifecycleEvent( + { type: "auto_compaction_end", action, result: undefined, aborted: false, - willRetry: false, + willRetry, skipped: !reclaimed, - errorMessage, - }); - return "fallback"; - } - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry, - skipped: !reclaimed, - }); + }, + detachPostCommit, + ); let continuationScheduled = false; if (willRetry) { @@ -3096,25 +3164,31 @@ export class SessionMaintenance { }; } catch (error) { if (signal.aborted) { - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: true, - willRetry: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: true, + willRetry: false, + }, + detachPostCommit, + ); return COMPACTION_CHECK_NONE; } const message = error instanceof Error ? error.message : "shake failed"; - await this.#host.emitSessionEvent({ - type: "auto_compaction_end", - action, - result: undefined, - aborted: false, - willRetry: false, - errorMessage: message, - skipped: false, - }); + await this.#emitLifecycleEvent( + { + type: "auto_compaction_end", + action, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: message, + skipped: false, + }, + detachPostCommit, + ); // Overflow still needs recovery even if shake threw. return reason === "overflow" ? "fallback" : COMPACTION_CHECK_NONE; } finally { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 37a50703d..0bb9fb3e4 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -59,7 +59,13 @@ import { type UsageStatistics, } from "./session-entries"; import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing"; -import { loadEntriesFromFile, readTitleSlotFromFile, resolveBlobRefsInEntries } from "./session-loader"; +import { + loadEntriesFromFile, + loadSessionFile, + resolveBlobRefsInEntries, + type SessionLoadResult, + visitEntriesFromFile, +} from "./session-loader"; import { generateId, migrateToCurrentVersion } from "./session-migrations"; import { computeDefaultSessionDir, @@ -83,6 +89,7 @@ import { const JSONL_SUFFIX_LENGTH = ".jsonl".length; const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session"; +const DISCARDED_ENTRY_BRANCH_MARKER = "discarded-entry-branch"; function mintSessionId(): string { return Bun.randomUUIDv7(); @@ -97,7 +104,31 @@ function fileSafeTimestamp(iso: string): string { } function artifactsDirectoryFor(sessionFile: string | undefined): string | null { - return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null; + if (!sessionFile?.endsWith(".jsonl")) return null; + return sessionFile.slice(0, -JSONL_SUFFIX_LENGTH); +} + +/** Copy a session's artifact directory to another session, matching interactive `/fork`. */ +export async function copySessionArtifacts(sourceSessionFile: string, destinationSessionFile: string): Promise { + const sourceArtifactsDir = artifactsDirectoryFor(sourceSessionFile); + const destinationArtifactsDir = artifactsDirectoryFor(destinationSessionFile); + if (!sourceArtifactsDir || !destinationArtifactsDir) return; + if (path.resolve(sourceArtifactsDir) === path.resolve(destinationArtifactsDir)) return; + + try { + const sourceStat = await fs.promises.stat(sourceArtifactsDir); + if (sourceStat.isDirectory()) { + await fs.promises.cp(sourceArtifactsDir, destinationArtifactsDir, { recursive: true }); + } + } catch (error) { + if (!isEnoent(error)) { + logger.warn("Failed to copy artifacts during fork", { + sourceArtifactsDir, + destinationArtifactsDir, + error: error instanceof Error ? error.message : String(error), + }); + } + } } /** @@ -516,6 +547,7 @@ export class SessionManager { */ #breadcrumbFresh = false; #sessionNameChangedCallbacks = new Set<() => void>(); + #persistenceErrorCallbacks = new Set<(error: Error) => void>(); private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) { this.#cwd = cwd; @@ -557,6 +589,15 @@ export class SessionManager { error: error.message, stack: error.stack, }); + for (const callback of this.#persistenceErrorCallbacks) { + try { + callback(error); + } catch (callbackError) { + logger.warn("Session persistence error observer failed", { + error: toError(callbackError).message, + }); + } + } } return this.#diskFailure; @@ -814,6 +855,7 @@ export class SessionManager { this.#diskTail = Promise.resolve(); this.#closeWriterEventually(); this.#storage.writeTextSync(targetPath, body); + this.#clearDiskError(); // Only mark the manager current when writing the active session path. // Mid-move writes update the live relocation path; `#sessionFile` is // still the pre-repoint source until moveTo repoints it. @@ -906,7 +948,13 @@ export class SessionManager { this.#atomicRewriteDirty = true; return; } - if (this.#diskFailure) throw this.#diskFailure; + if (this.#diskFailure) { + // The failed entry and any later entries remain in memory. A full + // replacement is the writability probe and restores all of them once + // transient storage pressure clears. + this.#fileIsCurrent = false; + this.#rewriteRequired = true; + } // Lazy gate: a brand-new session is not written until it has an assistant // message (or someone forced creation), so sessions that never produce @@ -944,9 +992,9 @@ export class SessionManager { // chain. Prefer appendSync so write failures latch `#diskFailure` before // this call returns (not via a discarded rejected Promise after a later // microtask). Callers stay non-throwing here — the core turn loop invokes - // appendMessage/appendCustomEntry without try/catch; flushSync/close and - // subsequent appends still throw the latched error. File writers apply - // each line to the OS page cache before return. + // appendMessage/appendCustomEntry without try/catch. A later entry retries + // all in-memory state through a full rewrite. File writers apply each line + // to the OS page cache before return. // A mid-close writer leaves `#writer` undefined, so `#appendWriter` simply // opens a fresh append handle and the entry still lands. try { @@ -955,16 +1003,28 @@ export class SessionManager { if (writer.appendSync) { writer.appendSync(line); } else { - void writer.append(line).catch(err => this.#noteDiskFailure(err)); + void writer.append(line).catch(err => { + this.#fileIsCurrent = false; + this.#rewriteRequired = true; + this.#noteDiskFailure(err); + }); } } catch (err) { + this.#fileIsCurrent = false; + this.#rewriteRequired = true; this.#noteDiskFailure(err); } } async #persistTitleChangeEntry(entry: TitleChangeEntry, update: SessionTitleUpdate): Promise { if (!this.#persist || !this.#sessionFile) return; - if (this.#diskFailure) throw this.#diskFailure; + if (this.#diskFailure) { + this.#fileIsCurrent = false; + this.#rewriteRequired = true; + this.#rewriteSynchronously(); + if (this.#diskFailure) throw this.#diskFailure; + return; + } if (!this.#shouldHaveSessionFile()) { this.#fileIsCurrent = false; @@ -1288,7 +1348,7 @@ export class SessionManager { await this.#setSessionFile(sessionFile); } - async #setSessionFile(sessionFile: string, loadedEntries?: FileEntry[]): Promise { + async #setSessionFile(sessionFile: string, loadedSession?: SessionLoadResult): Promise { await this.#drainAndCloseWriter(); this.#clearDiskError(); this.#draftOnlySessionCleanupArmed = false; @@ -1297,8 +1357,8 @@ export class SessionManager { this.#sessionFile = resolvedSessionFile; this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile); - const titleSlot = await readTitleSlotFromFile(resolvedSessionFile, this.#storage); - const fileEntries = loadedEntries ?? (await loadEntriesFromFile(resolvedSessionFile, this.#storage)); + const loaded = loadedSession ?? (await loadSessionFile(resolvedSessionFile, this.#storage)); + const { entries: fileEntries, titleSlot } = loaded; if (fileEntries.length === 0) { // Explicit but empty/missing path (e.g. --session flag): start fresh but // keep the requested path and materialize the header immediately. @@ -1332,7 +1392,7 @@ export class SessionManager { this.#titleUpdatedAt = titleSlot?.updatedAt ?? header.timestamp; this.#hasTitleSlot = titleSlot !== undefined; this.#fileIsCurrent = true; - this.#rewriteRequired = migrated; + this.#rewriteRequired = migrated || loaded.malformedRecords > 0; this.#forceFileCreation = true; this.#artifactManager = null; this.#artifactManagerSessionFile = null; @@ -1440,10 +1500,13 @@ export class SessionManager { const oldSessionFile = this.#sessionFile; const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile)); - const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!; - const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!; + const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile); + const newArtifactsDir = artifactsDirectoryFor(newSessionFile); const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile); - const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir); + const artifactPathChanged = + oldArtifactsDir !== null && + newArtifactsDir !== null && + path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir); sessionFileExisted = this.#storage.existsSync(oldSessionFile); let sessionMoved = false; @@ -1467,7 +1530,7 @@ export class SessionManager { } } } catch (err) { - if (artifactsMoved) { + if (artifactsMoved && oldArtifactsDir && newArtifactsDir) { try { await fs.promises.rename(newArtifactsDir, oldArtifactsDir); } catch (rollbackErr) { @@ -1985,6 +2048,14 @@ export class SessionManager { }; } + /** Subscribe to persistence failures so hosts can surface lost-durability state. */ + onPersistenceError(cb: (error: Error) => void): () => void { + this.#persistenceErrorCallbacks.add(cb); + return () => { + this.#persistenceErrorCallbacks.delete(cb); + }; + } + /** * Set the session display name. * @param source "user" for explicit renames; "auto" for generated titles. @@ -2394,6 +2465,37 @@ export class SessionManager { this.#setLeaf(null); } + /** + * Durably move the active branch past a discarded entry. + * + * The loader reconstructs the active branch from the last physical journal + * entry, so changing the in-memory leaf alone is lost on reload. Known + * metadata children are chained onto the discarded entry's parent before the + * entry is removed. If any child may carry content, the subtree is preserved + * off-branch instead. Both paths append a metadata-only branch marker and + * rewrite the journal, making the selected path durable. + */ + async discardEntryDurably(entryId: string): Promise { + const entry = this.#index.get(entryId); + if (!entry) return; + const children = this.#index.childrenOf(entryId); + const canReparentChildren = children.every(child => child.type === "service_tier_change"); + let leafId = entry.parentId; + if (canReparentChildren) { + for (const child of children) { + child.parentId = leafId; + leafId = child.id; + } + this.#entries = this.#entries.filter(candidate => candidate.id !== entryId); + this.#index.rebuild(this.#entries); + } + this.branchWithSummary(leafId, "", { + kind: DISCARDED_ENTRY_BRANCH_MARKER, + discardedEntryId: entryId, + }); + await this.rewriteEntries(); + } + /** Like branch(), but also records a branch_summary of the abandoned path. */ branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string { if (branchFromId !== null && !this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`); @@ -2533,16 +2635,16 @@ export class SessionManager { * session file while creating a fresh session file in this sessionDir. * * `options.sessionFile` pins the new session's file path (default: an - * auto-named `_.jsonl` in `sessionDir`). Callers that register - * the fork as a named agent (e.g. `/tan`) pass `.jsonl` so the - * persisted-subagent scan keys the agent by the same id the live ref uses. + * auto-named `_.jsonl` in `sessionDir`). Artifacts are copied + * recursively by default; nested agents that deliberately share their parent's + * artifact root may disable this with `copyArtifacts: false`. */ static async forkFrom( sourcePath: string, cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), - options?: { suppressBreadcrumb?: boolean; sessionFile?: string }, + options?: { copyArtifacts?: boolean; suppressBreadcrumb?: boolean; sessionFile?: string }, ): Promise { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); const manager = new SessionManager(cwd, dir, true, storage); @@ -2575,6 +2677,9 @@ export class SessionManager { manager.sanitizeLoadedOpenAIResponsesReplayMetadata(); manager.#forceFileCreation = true; await manager.#rewriteAtomically(); + if (options?.copyArtifacts !== false) { + await copySessionArtifacts(sourcePath, manager.#sessionFile!); + } return manager; } @@ -2589,8 +2694,8 @@ export class SessionManager { storage: SessionStorage = new FileSessionStorage(), options?: { initialCwd?: string; suppressBreadcrumb?: boolean }, ): Promise { - const loaded = await loadEntriesFromFile(filePath, storage); - const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined; + const loaded = await loadSessionFile(filePath, storage); + const header = loaded.entries.find(entry => entry.type === "session") as SessionHeader | undefined; // Resume into the session's recorded cwd only when that directory still // exists. A deleted project dir would make the constructor's #cwd — and the // `setProjectDir` chdir interactive mode runs next — point at (and fail on) @@ -2638,15 +2743,7 @@ export class SessionManager { advisor?: string; } | null; } | null> { - let loaded: FileEntry[]; - try { - loaded = await loadEntriesFromFile(filePath, storage); - } catch { - return null; - } - // A missing/empty file has no usable session — nothing to revive from. - if (loaded.length === 0) return null; - const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined; + let header: SessionHeader | undefined; let init: { systemPrompt: string; task: string; @@ -2661,8 +2758,11 @@ export class SessionManager { readSummarize?: boolean; advisor?: string; } | null = null; - for (let index = loaded.length - 1; index >= 0; index--) { - const entry = loaded[index]; + const visit = (entry: FileEntry): void => { + if (entry.type === "session") { + header ??= entry; + return; + } if (entry.type === "session_init") { init = { systemPrompt: entry.systemPrompt, @@ -2678,10 +2778,17 @@ export class SessionManager { spawns: entry.spawns, advisor: entry.advisor, }; - break; } + }; + + try { + await visitEntriesFromFile(filePath, visit, storage); + } catch { + return null; } - return { cwd: header?.cwd ?? getProjectDir(), init }; + // A missing, empty, or invalid file has no usable session. + if (!header) return null; + return { cwd: header.cwd ?? getProjectDir(), init }; } /** Continue the most recent session, or create a new one if none exists. */ diff --git a/packages/coding-agent/src/session/session-persistence.ts b/packages/coding-agent/src/session/session-persistence.ts index 22e95a1cf..ee1a95546 100644 --- a/packages/coding-agent/src/session/session-persistence.ts +++ b/packages/coding-agent/src/session/session-persistence.ts @@ -1,4 +1,4 @@ -import { isAnthropicWebSearchHistoryBlock } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; +import { isAnthropicServerToolHistoryBlock } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; import { type BlobStore, externalizeImageDataSync, @@ -100,8 +100,8 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string // Persist signed blocks verbatim — never truncate, externalize, or descend. // Unsigned blocks (e.g. an interrupted stream) have no such binding and stay // truncatable for size control. - // Anthropic validates native web-search history byte-for-byte on replay. - // Keep the complete typed block atomic, including nested encrypted_content. + // Anthropic validates native web-search and tool-search history byte-for-byte + // on replay. Keep the complete typed block atomic, including opaque content. if (typeof obj === "object" && "type" in obj && obj.type === "anthropicServerTool" && "block" in obj) { const block = obj.block; if (typeof block === "object" && block !== null && "type" in block && typeof block.type === "string") { @@ -112,7 +112,7 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string ...("tool_use_id" in block ? { tool_use_id: block.tool_use_id } : {}), ...("content" in block ? { content: block.content } : {}), }; - if (isAnthropicWebSearchHistoryBlock(validationView)) return obj; + if (isAnthropicServerToolHistoryBlock(validationView)) return obj; } } if (typeof obj === "object" && "type" in obj) { diff --git a/packages/coding-agent/src/session/session-storage.ts b/packages/coding-agent/src/session/session-storage.ts index a10e8dc7c..e3b913f92 100644 --- a/packages/coding-agent/src/session/session-storage.ts +++ b/packages/coding-agent/src/session/session-storage.ts @@ -128,14 +128,27 @@ class FileSessionStorageWriter implements SessionStorageWriter { } #writeNow(line: string): void { + const originalSize = fs.fstatSync(this.#fd).size; const buf = Buffer.from(line, "utf-8"); let offset = 0; - while (offset < buf.length) { - const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset); - if (written === 0) { - throw new Error("Short write"); + try { + while (offset < buf.length) { + const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset); + if (written === 0) { + throw new Error("Short write"); + } + offset += written; } - offset += written; + } catch (writeError) { + try { + fs.ftruncateSync(this.#fd, originalSize); + } catch (rollbackError) { + throw new AggregateError( + [toError(writeError), toError(rollbackError)], + "Session append failed and its partial bytes could not be rolled back", + ); + } + throw writeError; } } diff --git a/packages/coding-agent/src/session/session-tools.ts b/packages/coding-agent/src/session/session-tools.ts index 682fa2b9d..cfc03b3b4 100644 --- a/packages/coding-agent/src/session/session-tools.ts +++ b/packages/coding-agent/src/session/session-tools.ts @@ -26,7 +26,6 @@ import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { isMountableUnderXdev, listXdevTools, type XdevState, xdevDocsFor, xdevEntries } from "../tools/xdev"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; import { type InspectImageMode, isInspectImageToolActive } from "../utils/inspect-image-mode"; -import { formatLocalCalendarDate } from "../utils/local-date"; import { extractPermissionLocations, getPermissionIntent, @@ -82,7 +81,6 @@ interface SessionToolsOptions { toolNames: string[], tools: Map, ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>; - getLocalCalendarDate?: () => string; getMcpServerInstructions?: () => Map | undefined; xdev?: XdevState; setActiveToolNames?: (names: Iterable) => void; @@ -229,7 +227,6 @@ export class SessionTools { #toolRegistryMutationTail: Promise = Promise.resolve(); #promptModelKey: string | undefined; #rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"]; - #getLocalCalendarDate: () => string; #getMcpServerInstructions: SessionToolsOptions["getMcpServerInstructions"]; #setActiveToolNames: SessionToolsOptions["setActiveToolNames"]; #ensureWriteRegistered: SessionToolsOptions["ensureWriteRegistered"]; @@ -262,7 +259,6 @@ export class SessionTools { this.#presentationPinnedToolNames = options.presentationPinnedToolNames; this.#ensureWriteRegistered = options.ensureWriteRegistered; this.#rebuildSystemPrompt = options.rebuildSystemPrompt; - this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate; this.#getMcpServerInstructions = options.getMcpServerInstructions; this.#xdev = options.xdev; if (this.#xdev && this.#xdev.tools !== this.#toolRegistry) { @@ -1434,10 +1430,10 @@ export class SessionTools { * For everything else, callers must explicitly call {@link refreshBaseSystemPrompt} * after side-effecting changes; see the memory hooks and {@link syncAfterModelChange}. * - * The current calendar date IS covered (appended as a segment) because - * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`). - * Without this, a session spanning midnight with only tool-stable MCP - * reconnects would keep yesterday's date indefinitely. + * The calendar date is deliberately NOT part of the signature: the date/cwd + * reminder rides on the first user turn at request time (`date-cwd-reminder`), + * so a session spanning midnight must NOT rebuild a prompt that no longer + * embeds the date — the reminder picks up the new day on its own. */ #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string { // Order-preserving join: any reorder must produce a different signature so @@ -1471,8 +1467,7 @@ export class SessionTools { // the provider cache prefix byte-stable. Mounted MCP routes are the narrow // exception above, bounded to the exact projection rendered in the global // route guidance so churn wholly behind its fallback does not rebuild. - const date = this.#getLocalCalendarDate(); - return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0008${mountedMCPRouteSegment}|${date}`; + return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0008${mountedMCPRouteSegment}`; } /** diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index f1220df61..a07cb117e 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -74,6 +74,11 @@ const EMPTY_STOP_MAX_RETRIES = 3; const SIBLING_UNBLOCK_BUFFER_MS = 1_000; const NON_WHITESPACE_RE = /\S/; const USAGE_PREFLIGHT_BLOCKED_PREFIX = "Usage preflight blocked:"; +const STREAM_STALL_ERROR_RE = /stream stall/i; +const HTTP2_STREAM_RESET_ERROR_RE = + /stream closed with error code\s+nghttp2_(?:internal_error|refused_stream)|nghttp2_(?:internal_error|refused_stream)|HTTP2(?:StreamReset|RefusedStream)/i; +const IMMUTABLE_ANTHROPIC_THINKING_ERROR_PATTERN = + /messages\.\d+\.content\.\d+.*\b(?:thinking|redacted_thinking)\b.*\blatest assistant message cannot be modified\b/is; function hasNonWhitespace(value: string): boolean { return NON_WHITESPACE_RE.test(value); @@ -404,8 +409,8 @@ export class TurnRecovery { return this.#handleUnexpectedAssistantStop(message); } - /** Removes a persisted failed assistant turn after its persistence slot settles. */ - dropPersistedAssistantTurn(message: AssistantMessage): Promise { + /** Removes a persisted failed assistant turn after its persistence slot settles; returns the dropped branch entry id. */ + dropPersistedAssistantTurn(message: AssistantMessage): Promise { return this.#dropPersistedAssistantTurn(message); } @@ -674,15 +679,33 @@ export class TurnRecovery { this.#emptyStopRetryCount++; if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) { const attempts = this.#emptyStopRetryCount - 1; - const finalError = providerEmptyOutput - ? "Assistant returned no final output after retry cap; try switching models" - : "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames"; + const outputTokens = assistantMessage.usage.output; + const outputTokensExcludingKnownReasoning = Math.max( + 0, + outputTokens - (assistantMessage.usage.reasoningTokens ?? 0), + ); + let finalError: string; + if (providerEmptyOutput) { + finalError = "Assistant returned no final output after retry cap; try switching models"; + } else if (outputTokensExcludingKnownReasoning > 0 && assistantMessage.content.length === 0) { + // Billed non-reasoning output on a truly zero-block stop means content was + // generated and then dropped downstream (a filter/refusal flattened to + // `finish_reason: "stop"` by a proxy, or a lossy API translation) — the + // context/`/shake images` hint is wrong here, so name the billed output + // instead. Known reasoning-only usage is not evidence that deliverable + // content was dropped, and thinking-only stops retain a thinking block. + finalError = `Assistant returned an empty stop after retry cap, but the provider billed ${outputTokens} output token${outputTokens === 1 ? "" : "s"} for it; content was generated and then dropped before delivery, which usually points to a provider-side content filter or a lossy API translation rather than a context problem`; + } else { + finalError = + "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames"; + } assistantMessage.errorMessage = finalError; if (providerEmptyOutput) assistantMessage.errorId = AIError.create(); logger.warn(finalError, { attempts, model: assistantMessage.model, provider: assistantMessage.provider, + outputTokens, }); await this.#host.emitSessionEvent({ type: "auto_retry_end", @@ -697,10 +720,14 @@ export class TurnRecovery { // provider usage can anchor the next prompt at the full failed-request size // and re-trigger compaction at the same boundary. Remove every capped // empty output; toolUse orphans still need this for Anthropic history. - await this.dropPersistedAssistantTurn(assistantMessage); + await this.#dropAssistantTurnDurably(assistantMessage); return "terminal"; } - this.discardAssistantTurn(assistantMessage); + // The reparented leaf must be durably persisted before the retry continues: + // the loader rebuilds the active branch from the last physical entry, so an + // in-memory-only reparent lets the empty stop resurface on reload or after + // a mid-retry process kill. + await this.#dropAssistantTurnDurably(assistantMessage); this.#host.agent.appendMessage({ role: "developer", content: [{ type: "text", text: this.#emptyStopRetryReminder() }], @@ -823,9 +850,19 @@ export class TurnRecovery { * replays the failed turn, while no-recovery paths leave the persisted entry * (and the user-visible transcript line) in place. */ - async #dropPersistedAssistantTurn(assistantMessage: AssistantMessage): Promise { + async #dropPersistedAssistantTurn(assistantMessage: AssistantMessage): Promise { await this.#host.waitForSessionMessagePersistence(assistantMessage); - this.discardAssistantTurn(assistantMessage); + return this.discardAssistantTurn(assistantMessage); + } + + /** + * Drop the failed turn from active context and the persisted branch, then + * durably persist the reparented leaf so the empty stop cannot resurface on + * reload or after a mid-retry process kill. + */ + async #dropAssistantTurnDurably(assistantMessage: AssistantMessage): Promise { + const droppedEntryId = await this.#dropPersistedAssistantTurn(assistantMessage); + if (droppedEntryId) await this.#host.sessionManager.discardEntryDurably(droppedEntryId); } /** @@ -917,7 +954,7 @@ export class TurnRecovery { * the Gemini header-runaway interrupt, which must not replay a partial, * loop-fueling thinking block. */ - discardAssistantTurn(assistantMessage: AssistantMessage): void { + discardAssistantTurn(assistantMessage: AssistantMessage): string | undefined { this.removeAssistantMessageFromActiveContext(assistantMessage); const branch = this.#host.sessionManager.getBranch(); @@ -939,7 +976,7 @@ export class TurnRecovery { this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage), ); if (!branchEntry) { - return; + return undefined; } this.#host.withBashBranchTransition(() => { if (branchEntry.parentId === null) { @@ -948,6 +985,7 @@ export class TurnRecovery { this.#host.sessionManager.branch(branchEntry.parentId); } }); + return branchEntry.id; } #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean { @@ -1030,6 +1068,14 @@ export class TurnRecovery { isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; if (this.#isUsagePreflightBlocked(message)) return false; + const model = this.#host.model(); + const immutableAnthropicThinkingError = + model?.api === "anthropic-messages" && + (message.errorStatus === 400 || + message.errorId === 400 || + message.errorMessage?.startsWith("400 ") === true) && + IMMUTABLE_ANTHROPIC_THINKING_ERROR_PATTERN.test(message.errorMessage ?? ""); + if (immutableAnthropicThinkingError) return false; const id = this.#classifyRetryMessage(message); // Context overflow is handled by compaction, not retry. @@ -1103,10 +1149,10 @@ export class TurnRecovery { } /** - * Classify a reasonless abort or stream stall whose emitted tool calls all - * have results. The failed assistant/tool-result pair stays in context so - * continuation cannot replay completed side effects; synthetic results tell - * the next turn that an unexecuted call must be reissued. + * Classify a reasonless abort, idle stream stall, or HTTP/2 stream reset whose + * emitted tool calls all have results. The failed assistant/tool-result pair + * stays in context so continuation cannot replay completed side effects; + * synthetic results tell the next turn that an unexecuted call must be reissued. */ classifyResolvedInterruptedToolTurn(message: AssistantMessage): "reasonless-abort" | "stream-stall" | undefined { const id = this.#classifyRetryMessage(message); @@ -1118,20 +1164,28 @@ export class TurnRecovery { !this.#host.isDisposed() && !this.#host.streamingEditAbortTriggered() && ((message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) || genericAbort); + const errorMessage = message.errorMessage ?? ""; const streamStall = + message.stopReason === "error" && STREAM_STALL_ERROR_RE.test(errorMessage) && AIError.retriable(id); + const transportReset = message.stopReason === "error" && - message.errorMessage?.toLowerCase().includes("stream stall") === true && - AIError.retriable(id); - if (!reasonlessAbort && !streamStall) return undefined; + HTTP2_STREAM_RESET_ERROR_RE.test(errorMessage) && + AIError.retriable(id) && + !this.#host.abortInProgress() && + !this.#host.isDisposed() && + !this.#host.streamingEditAbortTriggered(); + if (!reasonlessAbort && !streamStall && !transportReset) return undefined; if (reasonlessAbort && genericAbort) message.errorId = AIError.create(AIError.Flag.Abort); - // The Cursor server-execution marker gate applies only to the stream-stall + // The Cursor server-execution marker gate applies only to the idle stream-stall // path: an unmarked/unresolved Cursor block there means the server has not // finished executing, so resuming would race it. A reasonless abort instead // ends the turn and the agent loop pairs every un-run call (Cursor's unmarked // `todo`/MCP blocks included) with a synthetic `executed: false` result, so // the tool-result reconciliation below is the safety gate and the marker is - // irrelevant. + // irrelevant. An HTTP/2 RST_STREAM / NGHTTP2_* close also ends the Connect + // stream, so there is no in-flight server exec to race — unmarked MCP/todo + // blocks are safe to continue once every emitted call has a result. const resolvedToolCallIds: string[] = []; for (const block of message.content) { if (block.type !== "toolCall") continue; @@ -1536,11 +1590,32 @@ export class TurnRecovery { if (!role) return false; const ceiling = this.#host.thinkingLevelCeiling(); + const latestAssistant = this.#host.agent.state.messages.findLast( + (message): message is AssistantMessage => message.role === "assistant" && message !== failedMessage, + ); for (const selector of this.findRetryFallbackCandidates(role, currentSelector)) { if (this.isRetryFallbackSelectorSuppressed(selector)) continue; const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings); const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id); if (!candidate) continue; + // Anthropic signatures and redacted blocks are model-bound, while the + // latest assistant response must remain byte-identical. A same-provider + // model switch can satisfy neither constraint, so keep retrying the + // source model or consider a later cross-provider candidate whose + // message transform can safely demote the foreign thinking. + if ( + candidate.api === "anthropic-messages" && + latestAssistant?.api === "anthropic-messages" && + latestAssistant.provider === candidate.provider && + latestAssistant.model !== candidate.id && + latestAssistant.content.some( + block => + (block.type === "thinking" && Boolean(block.thinkingSignature?.trim())) || + block.type === "redactedThinking", + ) + ) { + continue; + } // A candidate whose effort floor exceeds the per-spawn ceiling would be // clamped UP past the cap by its model floor — skip it entirely. if (ceiling !== undefined && !modelSupportsEffortCeiling(candidate, ceiling)) continue; @@ -1603,6 +1678,13 @@ export class TurnRecovery { if (this.#isUsagePreflightBlocked(message)) return false; const model = this.#host.model(); if (!model) return false; + const immutableAnthropicThinkingError = + model.api === "anthropic-messages" && + (message.errorStatus === 400 || + message.errorId === 400 || + message.errorMessage?.startsWith("400 ") === true) && + IMMUTABLE_ANTHROPIC_THINKING_ERROR_PATTERN.test(message.errorMessage ?? ""); + if (immutableAnthropicThinkingError) return false; const retrySettings = this.#host.settings.getGroup("retry"); if (!retrySettings.enabled || !retrySettings.modelFallback) return false; if (this.isClassifierRefusal(message)) return false; diff --git a/packages/coding-agent/src/session/unexpected-stop-classifier.ts b/packages/coding-agent/src/session/unexpected-stop-classifier.ts index bb3525eab..19fe82885 100644 --- a/packages/coding-agent/src/session/unexpected-stop-classifier.ts +++ b/packages/coding-agent/src/session/unexpected-stop-classifier.ts @@ -1,4 +1,4 @@ -import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai"; +import { type AssistantMessage, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; @@ -16,13 +16,21 @@ const CLASSIFIER_SYSTEM_PROMPT = prompt.render(unexpectedStopClassifierPrompt); */ const ANSWER_MAX_TOKENS = 16; /** - * Online classifier budget. Sized to survive backends that ignore - * `disableReasoning` (e.g. Qwen3 via llama.cpp catalogued `reasoning: false` - * but still emitting thinking): the yes/no keyword needs to land after any - * unavoidable thinking preamble. `maxTokens` is a hard cap — non-thinking - * completions still return in a single word (issue #4355). + * Online classifier budget. Sized against two independent constraints: + * - Backends that ignore `disableReasoning` still emit a thinking preamble + * (e.g. Qwen3 via llama.cpp catalogued `reasoning: false` but still thinking; + * Anthropic via LiteLLM/Vertex, whose `openai-completions` route downgrades a + * disabled request to the lowest reasoning effort instead of turning thinking + * off). The yes/no keyword must have room to land after that preamble + * (issue #4355). + * - Anthropic-dialect proxies reject `max_tokens <= thinking.budget_tokens`. The + * pinned lowest effort maps to at least Anthropic's 1024-token minimum budget, + * so the cap MUST comfortably exceed 1024 or the request 400s with + * `max_tokens must be greater than thinking.budget_tokens` (issue #8610). + * `maxTokens` is a hard cap — non-thinking completions still return in a single + * word. */ -const REASONING_SAFE_MAX_TOKENS = 1024; +const ONLINE_REASONING_SAFE_MAX_TOKENS = 4096; export interface ClassifyUnexpectedStopDeps { settings: Settings; @@ -86,21 +94,25 @@ async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): P throw new Error(`unexpected-stop: no API key for ${model.provider}/${model.id}`); } const metadata = deps.metadataResolver?.(model.provider); - const maxTokens = REASONING_SAFE_MAX_TOKENS; + const maxTokens = ONLINE_REASONING_SAFE_MAX_TOKENS; - const response = await completeSimple( - model, - { - systemPrompt: [CLASSIFIER_SYSTEM_PROMPT], - messages: [{ role: "user", content: text, timestamp: Date.now() }], - }, - { - apiKey: deps.registry.resolver(model, deps.sessionId), - maxTokens, - disableReasoning: true, - metadata, - signal: deps.signal, - }, + const response = await retryTransientCompletion( + () => + completeSimple( + model, + { + systemPrompt: [CLASSIFIER_SYSTEM_PROMPT], + messages: [{ role: "user", content: text, timestamp: Date.now() }], + }, + { + apiKey: deps.registry.resolver(model, deps.sessionId), + maxTokens, + disableReasoning: true, + metadata, + signal: deps.signal, + }, + ), + { signal: deps.signal }, ); if (response.stopReason === "error") { diff --git a/packages/coding-agent/src/slash-commands/builtin-session.ts b/packages/coding-agent/src/slash-commands/builtin-session.ts index 456a712e7..a799725dd 100644 --- a/packages/coding-agent/src/slash-commands/builtin-session.ts +++ b/packages/coding-agent/src/slash-commands/builtin-session.ts @@ -336,7 +336,7 @@ export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray = [ { name: "stats", description: "Launch the local stats dashboard", - inlineHint: "[--port ]", + inlineHint: "[--port ] [--host ]", allowArgs: true, handle: async (command, runtime) => { const parsed = parseStatsDashboardArgs(command.args); diff --git a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts index a22da28a6..569ef25b7 100644 --- a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts +++ b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts @@ -11,6 +11,7 @@ interface StatsDashboardServer { export interface StatsDashboardArgs { port: number; + host: string; } export interface StatsDashboardLaunchResult { @@ -20,7 +21,7 @@ export interface StatsDashboardLaunchResult { let activeStatsServer: StatsDashboardServer | undefined; -const STATS_DASHBOARD_USAGE = "Usage: /stats [--port ]"; +const STATS_DASHBOARD_USAGE = "Usage: /stats [--port ] [--host ]"; function parsePort(value: string | undefined): number | string { if (!value) return `Missing port. ${STATS_DASHBOARD_USAGE}`; @@ -33,6 +34,7 @@ function parsePort(value: string | undefined): number | string { export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { error: string } { const tokens = args.split(/\s+/).filter(Boolean); let port = DEFAULT_STATS_DASHBOARD_PORT; + let host = "127.0.0.1"; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; @@ -48,28 +50,40 @@ export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { er port = parsed; continue; } + if (token === "--host") { + const value = tokens[++i]; + if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` }; + host = value; + continue; + } + if (token.startsWith("--host=")) { + const value = token.slice("--host=".length); + if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` }; + host = value; + continue; + } return { error: `Unknown option: ${token}. ${STATS_DASHBOARD_USAGE}` }; } - return { port }; + return { port, host }; } export async function launchStatsDashboard(args: StatsDashboardArgs): Promise { const { processed, files } = await stats.syncAllSessions(); const total = await stats.getTotalMessageCount(); - let requestedPortIgnored = false; + let requestedAddressIgnored = false; if (!activeStatsServer) { - activeStatsServer = await stats.startServer(args.port); - } else if (args.port !== activeStatsServer.port) { - requestedPortIgnored = true; + activeStatsServer = await stats.startServer(args.port, args.host); + } else if (args.port !== activeStatsServer.port || args.host !== activeStatsServer.hostname) { + requestedAddressIgnored = true; } - const url = `http://${activeStatsServer.hostname}:${activeStatsServer.port}`; + const url = stats.formatStatsDashboardUrl(activeStatsServer.hostname, activeStatsServer.port); openUtils.openPath(url); - const serverLine = requestedPortIgnored - ? `Dashboard already running at: ${url} (requested port ${args.port} ignored)` + const serverLine = requestedAddressIgnored + ? `Dashboard already running at: ${url} (requested ${args.host}:${args.port} ignored)` : `Dashboard available at: ${url}`; return { diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 408a4f9da..bea09d051 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -27,7 +27,6 @@ import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type import { normalizeConcurrencyLimit } from "./task/parallel"; import { usesCodexTaskPrompt } from "./task/prompt-policy"; import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context"; -import { formatLocalCalendarDate } from "./utils/local-date"; import { normalizePromptPath } from "./utils/prompt-path"; import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; @@ -784,8 +783,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): } } - const date = formatLocalCalendarDate(); - const dateTime = date; const promptCwd = normalizePromptPath(resolvedCwd); const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext); @@ -870,8 +867,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): skills: filteredSkills, rules: rules ?? [], alwaysApplyRules: injectedAlwaysApplyRules, - date, - dateTime, cwd: promptCwd, additionalWorkspaceRoots: additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)), model: includeModelInPrompt ? (model ?? "") : "", diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 0667cffe5..edfca2f6d 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2803,19 +2803,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise void) | null = null; let reviveSession: AgentReviver | null = null; - // Adopted (kept-alive) subagents flip registry status from session events on - // later turns: revive/wake → running, turn drained → idle. The subscription - // intentionally survives this run; a disposed session emits nothing, so it - // needs no teardown. - const installRegistryStatusSync = (target: AgentSession): void => { - target.subscribe(event => { - if (event.type === "agent_start") { - AgentRegistry.global().setStatus(id, "running", target); - } else if (event.type === "agent_end") { - AgentRegistry.global().setStatus(id, "idle", target); - } - }); - }; const installIrcWakeTurnMonitor = (target: AgentSession): void => { attachIrcWakeTurnMonitor(target, { id, @@ -3162,7 +3149,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise { - if (event.type === "agent_start") registry.setStatus(ref.id, "running", session); - else if (event.type === "agent_end") registry.setStatus(ref.id, "idle", session); - }); + // The internal run-state signal precedes deferrable public `agent_end`, + // keeping idle-TTL ownership synchronized even while prompts unwind. + registry.syncSessionStatus(ref.id, session); // Persisted files predate an agent-source field, so cold-revived frames // report the runtime-neutral `user` source; name comes from the ref. const wakeAgent: AgentDefinition = { diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 0730a207c..41dd943d2 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -716,7 +716,7 @@ function formatQuestionResult(result: QuestionResult): string { ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}` : `${result.id}: ${result.selectedOptions[0]}${suffix}`; } - return `${result.id}: (cancelled)${noteSuffix}`; + return result.multi ? `${result.id}: []${noteSuffix}` : `${result.id}: (cancelled)${noteSuffix}`; } function formatSingleQuestionResponse(result: { @@ -753,7 +753,8 @@ function formatSingleQuestionResponse(result: { : `User added note: ${result.note}`, ); } - return responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection"; + if (responseParts.length > 0) return responseParts.join("\n"); + return result.multi ? "User did not select any options" : "User cancelled the selection"; } // ============================================================================= @@ -949,9 +950,15 @@ export class AskTool implements AgentTool { } if (params.questions.length === 1) { const result = results[0]; + // An empty multi-select submission is a valid "select none" + // answer (#8265 review); only a truly empty single-select + // result counts as cancellation. if ( !result || - (!result.timedOut && result.selectedOptions.length === 0 && result.customInput === undefined) + (!result.timedOut && + !result.multi && + result.selectedOptions.length === 0 && + result.customInput === undefined) ) { context.abort(); throw new ToolAbortError("Ask tool was cancelled by the user"); diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index 188c2b9bc..7a65011d2 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -69,7 +69,7 @@ const browserSchema = type({ "dialogs?": type("'accept' | 'dismiss'").describe("auto-handle dialogs"), "code?": type("string").describe("js body to run in tab"), "timeout?": type("number").describe("timeout in seconds"), - "all?": type("boolean").describe("close every tab"), + "all?": type("boolean").describe("release every managed tab"), "kill?": type("boolean").describe("also kill spawned-app browsers"), }); @@ -133,7 +133,7 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse /** * Browser tool: stateful, multi-tab. Three actions: * - `open` → acquire/create a named tab on a browser kind (headless | spawned | connected) and optionally goto a url. - * - `close` → release a named tab (or all tabs); dispose browser when refcount hits 0. + * - `close` → release a named tab handle (or all handles); attached/relay pages remain open, and spawned pages remain unless killed. * - `run` → execute JS code against an existing tab with `page`/`browser`/`tab` helpers in scope. */ export class BrowserTool implements AgentTool { @@ -204,7 +204,7 @@ export class BrowserTool implements AgentTool releaseAllTabs({ kill, timeoutMs })); - details.result = `Closed ${count} tab(s)`; + details.result = `Released ${count} managed tab${count === 1 ? "" : "s"}`; return toolResult(details).text(details.result).done(); } const closed = await untilAborted(signal, () => releaseTab(name, { kill, timeoutMs })); - details.result = closed ? `Closed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`; + details.result = closed ? `Released managed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`; return toolResult(details).text(details.result).done(); } diff --git a/packages/coding-agent/src/tools/browser/attach.ts b/packages/coding-agent/src/tools/browser/attach.ts index 15330be6e..b1db5d764 100644 --- a/packages/coding-agent/src/tools/browser/attach.ts +++ b/packages/coding-agent/src/tools/browser/attach.ts @@ -1,5 +1,6 @@ import * as net from "node:net"; import { Process, ProcessStatus } from "@oh-my-pi/pi-natives"; +import type { Socket } from "bun"; import type { Browser, Page } from "puppeteer-core"; import { ToolError, throwIfAborted } from "../tool-errors"; @@ -29,31 +30,92 @@ export async function findFreeCdpPort(): Promise { return promise; } +/** + * Loopback HTTP/1.1 GET that never routes through a proxy, resolving to the + * response status code (or null when the endpoint is unreachable, aborted, + * malformed, or slow past `timeoutMs`). + * + * Chrome's DevTools endpoint listens on loopback and speaks plain HTTP/1.1. + * Both `fetch` and Bun's `node:http` honor `HTTP_PROXY`/`HTTPS_PROXY` and + * forward even `127.0.0.1` requests to the proxy unless `NO_PROXY` covers them, + * so a local proxy that 502s internal addresses makes a healthy daemon look + * dead and the CDP readiness checks tear it down (issue #8567). Talking to the + * socket over raw TCP sidesteps proxy env entirely. + */ +export async function probeCdpStatus( + url: string, + opts: { timeoutMs: number; signal?: AbortSignal }, +): Promise { + let target: URL; + try { + target = new URL(url); + } catch { + return null; + } + if (opts.signal?.aborted) return null; + const port = target.port ? Number(target.port) : 80; + const requestPath = `${target.pathname}${target.search}` || "/"; + const { promise, resolve } = Promise.withResolvers(); + let socket: Socket | undefined; + let settled = false; + const finish = (status: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + try { + socket?.end(); + } catch { + // socket already torn down + } + resolve(status); + }; + const onAbort = () => finish(null); + const timer = setTimeout(() => finish(null), opts.timeoutMs); + opts.signal?.addEventListener("abort", onAbort, { once: true }); + let buffered = ""; + try { + socket = await Bun.connect({ + hostname: target.hostname, + port, + socket: { + open(s) { + s.write(`GET ${requestPath} HTTP/1.1\r\nHost: ${target.hostname}:${port}\r\nConnection: close\r\n\r\n`); + }, + data(_s, chunk) { + buffered += chunk.toString("latin1"); + const match = /^HTTP\/\d(?:\.\d)? (\d{3})/.exec(buffered); + if (match) finish(Number(match[1])); + }, + error() { + finish(null); + }, + close() { + finish(null); + }, + }, + }); + } catch { + finish(null); + } + return promise; +} + /** Poll `${cdpUrl}/json/version` until it responds with 200, with abort + timeout support. */ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: AbortSignal): Promise { const deadline = Date.now() + timeoutMs; - let lastErr: unknown; const probeUrl = `${cdpUrl.replace(/\/+$/, "")}/json/version`; + let lastStatus: number | null = null; while (Date.now() < deadline) { throwIfAborted(signal); - const probeTimeout = AbortSignal.timeout(2000); - const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout; - try { - const res = await fetch(probeUrl, { signal: probeSignal }); - if (res.ok) { - await res.body?.cancel(); - return; - } - lastErr = new Error(`HTTP ${res.status}`); - await res.body?.cancel(); - } catch (err) { - if (signal?.aborted) throwIfAborted(signal); - lastErr = err; - } + const status = await probeCdpStatus(probeUrl, { timeoutMs: 2000, signal }); + if (status !== null && status >= 200 && status < 300) return; + lastStatus = status; await Bun.sleep(150); } + throwIfAborted(signal); throw new ToolError( - `Timed out waiting for CDP endpoint ${cdpUrl}${lastErr instanceof Error ? `: ${lastErr.message}` : ""}`, + `Timed out waiting for CDP endpoint ${cdpUrl}${lastStatus !== null ? `: HTTP ${lastStatus}` : ""}`, ); } @@ -81,15 +143,8 @@ function findCdpPortInArgs(args: string[]): number | null { /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */ async function probeCdpAt(port: number, signal?: AbortSignal): Promise { - const probeTimeout = AbortSignal.timeout(1500); - const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout; - try { - const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: probeSignal }); - await res.body?.cancel(); - return res.ok; - } catch { - return false; - } + const status = await probeCdpStatus(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 1500, signal }); + return status !== null && status >= 200 && status < 300; } /** diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index 4c10635e1..6e12471d7 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -121,23 +121,36 @@ async function loadBrowsers(): Promise { } /** - * Resolve the Chromium executable puppeteer will launch, honoring - * PUPPETEER_EXECUTABLE_PATH before system browser detection and lazily - * downloading Chromium otherwise. The browser is cached under - * ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when platform - * detection fails (puppeteer default resolution takes over). Exported so - * real-browser tests can probe launchability and skip on hosts missing - * Chrome's system libraries. + * Resolve the Chromium executable puppeteer will launch. + * + * `PUPPETEER_EXECUTABLE_PATH` always wins. On macOS the isolated Chrome for + * Testing binary is preferred over a detected system Chrome: a headless + * daemon launched from a system `Google Chrome.app` bundle shares its + * LaunchServices bundle identity (`com.google.Chrome`), so macOS can deliver + * the user's open-URL Apple Events to the daemon and silently swallow their + * link clicks (#8673). Chrome for Testing uses a dedicated bundle id + * (`com.google.chrome.for.testing`) that is never a user's default handler; + * system Chrome is used on macOS only when Chrome for Testing cannot be + * obtained. Other platforms keep the download-avoiding system Chrome + * preference and fall back to Chrome for Testing. The managed browser is + * cached under ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when + * platform detection fails (puppeteer default resolution takes over). + * Exported so real-browser tests can probe launchability and skip on hosts + * missing Chrome's system libraries. */ let chromiumExecutablePromise: Promise | undefined; export async function ensureChromiumExecutable(): Promise { const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; if (envPath) return envPath; - const sysChrome = await resolveSystemChromium(); - if (sysChrome) return sysChrome; - if (chromiumExecutablePromise) return chromiumExecutablePromise; + // macOS: never route a background daemon through the user's GUI Chrome + // bundle; prefer the isolated Chrome for Testing binary instead (#8673). + const preferManagedChromium = process.platform === "darwin"; + if (!preferManagedChromium) { + const sysChrome = await resolveSystemChromium(); + if (sysChrome) return sysChrome; + } - chromiumExecutablePromise = (async () => { + chromiumExecutablePromise ??= (async () => { const browsers = await loadBrowsers(); const platform = browsers.detectBrowserPlatform(); if (!platform) { @@ -185,7 +198,23 @@ export async function ensureChromiumExecutable(): Promise { "Set PUPPETEER_EXECUTABLE_PATH to use an existing Chrome/Chromium binary, or install one manually.", ); }); - return chromiumExecutablePromise; + + try { + return await chromiumExecutablePromise; + } catch (err) { + if (!preferManagedChromium) throw err; + // Chrome for Testing could not be obtained on macOS; degrade to the + // system Chrome bundle rather than leaving the browser tool unusable. + const sysChrome = await resolveSystemChromium(); + if (!sysChrome) throw err; + logger.warn( + "Chrome for Testing unavailable; falling back to the system Chrome bundle. On macOS this can let the " + + "headless browser daemon capture your link clicks (#8673). Set PUPPETEER_EXECUTABLE_PATH to a " + + "dedicated Chromium to avoid this.", + { path: sysChrome, error: (err as Error).message }, + ); + return sysChrome; + } } let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found @@ -430,8 +459,8 @@ export interface SharedBrowserLaunchSpec { * Resolve the executable and complete argv for a shared Chromium the daemon * broker spawns directly (no puppeteer inside the broker). Mirrors * `launchHeadlessBrowser` flag assembly — puppeteer's default args minus the - * stealth-suppressed set — plus `--remote-debugging-port=0` so every client - * attaches over CDP. Returns null when no Chromium executable resolves; + * stealth-suppressed set — suppresses Puppeteer's unowned startup window, and + * exposes CDP on an ephemeral port. Returns null when no executable resolves; * callers fall back to a process-local launch. */ export async function resolveSharedBrowserLaunchSpec(opts: { @@ -451,7 +480,7 @@ export async function resolveSharedBrowserLaunchSpec(opts: { }); return { executablePath, - args: [...defaults.filter(arg => !ignored.has(arg)), "--remote-debugging-port=0"], + args: [...defaults.filter(arg => !ignored.has(arg)), "--no-startup-window", "--remote-debugging-port=0"], }; } diff --git a/packages/coding-agent/src/tools/browser/relay/daemon.ts b/packages/coding-agent/src/tools/browser/relay/daemon.ts index 5c2909372..053f04212 100644 --- a/packages/coding-agent/src/tools/browser/relay/daemon.ts +++ b/packages/coding-agent/src/tools/browser/relay/daemon.ts @@ -17,6 +17,7 @@ import { daemonClientForGlobal } from "../../../launch/client"; import { describeQuietly, stopQuietly, waitReady } from "../../../launch/ensure"; import { resolveWorkerSpawnCmd } from "../../../subprocess/worker-client"; import { throwIfAborted } from "../../tool-errors"; +import { probeCdpStatus } from "../attach"; /** Stable broker daemon name for the relay server. */ export const RELAY_DAEMON_NAME = "omp.browser.relay"; @@ -30,13 +31,8 @@ const ENSURE_ATTEMPTS = 3; /** True when the relay HTTP server answers /json/version at all (200 = extension connected, 503 = waiting for it). */ export async function probeRelayServer(cdpUrl: string): Promise { - try { - const res = await fetch(`${cdpUrl}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); - await res.body?.cancel(); - return res.ok || res.status === 503; - } catch { - return false; - } + const status = await probeCdpStatus(`${cdpUrl}/json/version`, { timeoutMs: PROBE_TIMEOUT_MS }); + return status === 503 || (status !== null && status >= 200 && status < 300); } /** Auto-start is only safe for endpoints this machine can own. */ diff --git a/packages/coding-agent/src/tools/browser/render.ts b/packages/coding-agent/src/tools/browser/render.ts index d37c5f34a..9eb2d1890 100644 --- a/packages/coding-agent/src/tools/browser/render.ts +++ b/packages/coding-agent/src/tools/browser/render.ts @@ -162,7 +162,7 @@ function renderOpenOrCloseLine( let title: string; if (action === "close") { const all = args.all === true || (args.name === undefined && details?.name === undefined); - title = all ? "Close all tabs" : `Close ${tabLabel(args, details)}`; + title = all ? "Release all tabs" : `Release ${tabLabel(args, details)}`; if (args.kill) title += " (kill)"; } else { title = `Open ${tabLabel(args, details)}`; diff --git a/packages/coding-agent/src/tools/browser/shared-daemon.ts b/packages/coding-agent/src/tools/browser/shared-daemon.ts index fc98b488f..c26fd05ba 100644 --- a/packages/coding-agent/src/tools/browser/shared-daemon.ts +++ b/packages/coding-agent/src/tools/browser/shared-daemon.ts @@ -16,6 +16,7 @@ import { describeQuietly, stopQuietly, waitReady } from "../../launch/ensure"; import { daemonRuntimeDir } from "../../launch/paths"; import type { DaemonSnapshot } from "../../launch/protocol"; import { throwIfAborted } from "../tool-errors"; +import { probeCdpStatus } from "./attach"; import { resolveSharedBrowserLaunchSpec } from "./launch"; /** Chrome prints this on stderr once the CDP listener is up; the broker's ready probe captures the line. */ @@ -50,13 +51,8 @@ async function probeEndpoint(wsEndpoint: string): Promise { } catch { return false; } - try { - const res = await fetch(`http://${host}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); - await res.body?.cancel(); - return res.ok; - } catch { - return false; - } + const status = await probeCdpStatus(`http://${host}/json/version`, { timeoutMs: PROBE_TIMEOUT_MS }); + return status !== null && status >= 200 && status < 300; } /** diff --git a/packages/coding-agent/src/tools/builtin-names.ts b/packages/coding-agent/src/tools/builtin-names.ts index 7c323c9f5..704d11c07 100644 --- a/packages/coding-agent/src/tools/builtin-names.ts +++ b/packages/coding-agent/src/tools/builtin-names.ts @@ -41,10 +41,14 @@ const LEGACY_BUILTIN_TOOL_NAME_ALIASES: ReadonlyMap = n ["find", "glob"], ]); -/** Return the canonical tool name for current and legacy built-in tool IDs. */ +const CANONICAL_TOOL_NAMES: Record = Object.fromEntries( + [...BUILTIN_TOOL_NAMES, ...HIDDEN_TOOL_NAMES].map(name => [name, true]), +); + +/** Canonicalize built-in IDs and legacy aliases. Leave plugin names unchanged. */ export function normalizeToolName(name: string): string { - const normalized = name.toLowerCase(); - return LEGACY_BUILTIN_TOOL_NAME_ALIASES.get(normalized) ?? normalized; + const lower = name.toLowerCase(); + return LEGACY_BUILTIN_TOOL_NAME_ALIASES.get(lower) ?? (Object.hasOwn(CANONICAL_TOOL_NAMES, lower) ? lower : name); } /** Normalize and deduplicate tool names while preserving first-seen order. */ diff --git a/packages/coding-agent/src/tools/checkpoint.ts b/packages/coding-agent/src/tools/checkpoint.ts index 8628b4975..74c1bec60 100644 --- a/packages/coding-agent/src/tools/checkpoint.ts +++ b/packages/coding-agent/src/tools/checkpoint.ts @@ -81,13 +81,7 @@ export class CheckpointTool implements AgentTool({ goal: params.goal, startedAt }) - .text( - [ - "Checkpoint created.", - `Goal: ${params.goal}`, - "Run your investigation, then call rewind with a concise report.", - ].join("\n"), - ) + .text([`Checkpoint: ${params.goal}`, "Finish exploration and formulate findings."].join("\n")) .done(); } } diff --git a/packages/coding-agent/src/tools/hub/index.ts b/packages/coding-agent/src/tools/hub/index.ts index 1b3640b13..c7e087048 100644 --- a/packages/coding-agent/src/tools/hub/index.ts +++ b/packages/coding-agent/src/tools/hub/index.ts @@ -377,7 +377,7 @@ export class HubTool implements AgentTool { // instead of blocking a full message-timeout window. const hasRunningPeer = messaging.registry .listVisibleTo(messaging.senderId) - .some(ref => ref.status === "running"); + .some(ref => messaging.registry.isRunning(ref)); if (!hasRunningPeer) return nothingToWaitForResult(this.session); } return executeMessageWait(messaging, { from, timeoutMs: params.timeoutMs }, signal); diff --git a/packages/coding-agent/src/tools/hub/jobs.ts b/packages/coding-agent/src/tools/hub/jobs.ts index b947372b4..d7402f6bd 100644 --- a/packages/coding-agent/src/tools/hub/jobs.ts +++ b/packages/coding-agent/src/tools/hub/jobs.ts @@ -87,6 +87,12 @@ export function visibleJobs(manager: AsyncJobManager, ids: string[], ownerId: st * that activity instead of implying the system is quiet. Existence is * already public via the peer roster, so listing ids here leaks nothing new; * job *control* stays owner-scoped. + * + * Reporting deliberately uses the claimed `status`, not the session-corroborated + * `registry.isRunning` used by the wait-sustaining gates: a ref that claims + * `running` with no live turn is exactly the stale entry an operator must see + * here to cancel it (#8634). Hiding it would match the badge count to nothing + * and remove the only discovery path for the id. */ export function runningAgentsOutsideJobs(session: ToolSession): AgentActivitySnapshot[] { const registry = session.agentRegistry; @@ -113,6 +119,7 @@ export function runningAgentsOutsideJobs(session: ToolSession): AgentActivitySna ...(ref.parentId ? { parentId: ref.parentId } : {}), ...(ref.activity ? { activity: ref.activity } : {}), ageMs: Math.max(0, now - ref.createdAt), + live: registry.isRunning(ref), }); } return out; @@ -124,9 +131,15 @@ function describeAgents(agents: AgentActivitySnapshot[]): string[] { for (const agent of agents) { const parent = agent.parentId ? ` (spawned by \`${agent.parentId}\`)` : ""; const activity = agent.activity ? ` — ${agent.activity}` : ""; - lines.push(`- \`${agent.id}\`${parent} — up ${formatDuration(agent.ageMs)}${activity}`); + const stale = agent.live ? "" : " — no turn in flight (stale registration?)"; + lines.push(`- \`${agent.id}\`${parent} — up ${formatDuration(agent.ageMs)}${activity}${stale}`); } lines.push("", "These agents have no job entry; message them via `hub` send, transcripts at `history://`."); + if (agents.some(agent => !agent.live)) { + lines.push( + "An agent with no turn in flight cannot answer a message and never satisfies a bare `wait`; clear it with `hub` cancel.", + ); + } return lines; } @@ -690,8 +703,12 @@ export function jobsRenderResult( maxCollapsed: COLLAPSED_LIST_LIMIT, itemType: "agent", renderItem: agent => { - const icon = formatStatusIcon("running", uiTheme, options.spinnerFrame); - const badge = formatBadge("agent", "accent", uiTheme); + const icon = agent.live + ? formatStatusIcon("running", uiTheme, options.spinnerFrame) + : formatStatusIcon("warning", uiTheme); + const badge = agent.live + ? formatBadge("agent", "accent", uiTheme) + : formatBadge("agent · no turn", "warning", uiTheme); const gist = agent.activity ? ` ${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(agent.activity), LABEL_MAX_WIDTH, Ellipsis.Unicode))}` : ""; diff --git a/packages/coding-agent/src/tools/hub/types.ts b/packages/coding-agent/src/tools/hub/types.ts index d3b7e0a10..5685e3563 100644 --- a/packages/coding-agent/src/tools/hub/types.ts +++ b/packages/coding-agent/src/tools/hub/types.ts @@ -74,6 +74,12 @@ export interface AgentActivitySnapshot { activity?: string; /** Time since the agent was registered. */ ageMs: number; + /** + * Whether an attached session corroborates the `running` claim. False marks + * a ref that says `running` with no turn in flight — either a spawn still + * wiring up or a stale registration that `hub cancel ` clears (#8634). + */ + live: boolean; } /** Result details for messaging and job ops; fields are disjoint per op. */ diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index d17b0e118..45b404c01 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -599,79 +599,111 @@ export function truncateDiffByHunk( let keptHunks = 0; for (const seg of segments) { - if (seg.isChange) { - keptHunks++; - if (keptHunks > maxHunks) break; - } - kept.push(...seg.lines); if (kept.length >= maxLines) break; + if (seg.isChange) { + if (keptHunks >= maxHunks) break; + keptHunks++; + } + const take = Math.min(seg.lines.length, maxLines - kept.length); + for (let i = 0; i < take; i++) { + kept.push(seg.lines[i]!); + } } - const keptStats = getDiffStats(kept.join("\n")); return { text: kept.join("\n"), - hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks), + hiddenHunks: Math.max(0, totalStats.hunks - keptHunks), hiddenLines: Math.max(0, lines.length - kept.length), }; } const contextBudget = maxLines - changeLineCount; - const contextSegments = segments.filter(s => !s.isChange && !s.isEllipsis); + const contextSegments = segments.filter(s => !s.isChange); const totalContextLines = contextSegments.reduce((sum, s) => sum + s.lines.length, 0); const kept: string[] = []; let keptHunks = 0; + let keptSourceLines = 0; if (totalContextLines <= contextBudget) { for (const seg of segments) { if (seg.isChange) { + if (keptHunks >= maxHunks) break; keptHunks++; - if (keptHunks > maxHunks) break; } kept.push(...seg.lines); + keptSourceLines += seg.lines.length; } } else { - const contextRatio = contextSegments.length > 0 ? contextBudget / totalContextLines : 0; + const contextRatio = totalContextLines > 0 ? contextBudget / totalContextLines : 0; + let remainingContextBudget = contextBudget; for (let i = 0; i < segments.length; i++) { const seg = segments[i]; if (seg.isChange) { + if (keptHunks >= maxHunks) break; keptHunks++; - if (keptHunks > maxHunks) break; - kept.push(...seg.lines); - } else if (seg.isEllipsis) { kept.push(...seg.lines); + keptSourceLines += seg.lines.length; + continue; + } + if (remainingContextBudget <= 0) continue; + + const allowedLines = Math.min( + remainingContextBudget, + Math.max(1, Math.floor(seg.lines.length * contextRatio)), + ); + const outputStart = kept.length; + let sourceLinesAdded = 0; + + if (seg.isEllipsis || seg.lines.length <= allowedLines) { + for (let j = 0; j < allowedLines; j++) { + kept.push(seg.lines[j]!); + } + sourceLinesAdded = allowedLines; } else { - const allowedLines = Math.max(1, Math.floor(seg.lines.length * contextRatio)); const isBeforeChange = segments[i + 1]?.isChange; const isAfterChange = segments[i - 1]?.isChange; if (isBeforeChange && isAfterChange) { - const half = Math.ceil(allowedLines / 2); - if (seg.lines.length > allowedLines) { - kept.push(...seg.lines.slice(0, half)); + if (allowedLines >= 3) { + const sourceBudget = allowedLines - 1; + const firstCount = Math.ceil(sourceBudget / 2); + const lastCount = sourceBudget - firstCount; + kept.push(...seg.lines.slice(0, firstCount)); kept.push(""); - kept.push(...seg.lines.slice(-half)); + if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount)); + sourceLinesAdded = sourceBudget; } else { - kept.push(...seg.lines); + const firstCount = Math.ceil(allowedLines / 2); + const lastCount = allowedLines - firstCount; + kept.push(...seg.lines.slice(0, firstCount)); + if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount)); + sourceLinesAdded = allowedLines; } } else if (isBeforeChange) { kept.push(...seg.lines.slice(-allowedLines)); + sourceLinesAdded = allowedLines; } else if (isAfterChange) { kept.push(...seg.lines.slice(0, allowedLines)); + sourceLinesAdded = allowedLines; } else { - kept.push(...seg.lines.slice(0, Math.min(allowedLines, 2))); + const take = Math.min(allowedLines, 2); + kept.push(...seg.lines.slice(0, take)); + sourceLinesAdded = take; } } + + keptSourceLines += sourceLinesAdded; + remainingContextBudget -= kept.length - outputStart; } } - const keptStats = getDiffStats(kept.join("\n")); return { text: kept.join("\n"), - hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks), - hiddenLines: Math.max(0, lines.length - kept.length), + hiddenHunks: Math.max(0, totalStats.hunks - keptHunks), + hiddenLines: Math.max(0, lines.length - keptSourceLines), }; } diff --git a/packages/coding-agent/src/tools/think.ts b/packages/coding-agent/src/tools/think.ts index a4868f71b..c272af687 100644 --- a/packages/coding-agent/src/tools/think.ts +++ b/packages/coding-agent/src/tools/think.ts @@ -8,14 +8,26 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme"; /** Whether a model transport can suppress native reasoning while private scratchpad thoughts are active. */ export function supportsExternalThinking(model: Model | null | undefined): boolean { if (!model) return false; + const compat = model.compat; const requiresThinking = model.api === "anthropic-messages" && - model.compat !== undefined && - "requiresThinkingEnabled" in model.compat && - model.compat.requiresThinkingEnabled === true; + compat !== undefined && + "requiresThinkingEnabled" in compat && + compat.requiresThinkingEnabled === true; if (model.reasoning && (requiresThinking || (model.thinking?.requiresEffort && !model.thinking.suppressWhenOff))) { return false; } + // Transports that reject `reasoning.effort` (xAI Grok 4 and the other + // reasoning-only Responses models) cannot honour `forceReasoningOff`, so the + // scratchpad would run alongside native reasoning instead of replacing it. + if ( + model.reasoning && + compat !== undefined && + (("omitReasoningEffort" in compat && compat.omitReasoningEffort === true) || + ("supportsReasoningEffort" in compat && compat.supportsReasoningEffort === false)) + ) { + return false; + } if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") { return !model.reasoning || model.thinking?.mode === "budget" || model.thinking?.suppressWhenOff === true; } diff --git a/packages/coding-agent/src/tts/speech-enhancer.ts b/packages/coding-agent/src/tts/speech-enhancer.ts index 8f100d63f..43a4fd1f4 100644 --- a/packages/coding-agent/src/tts/speech-enhancer.ts +++ b/packages/coding-agent/src/tts/speech-enhancer.ts @@ -15,7 +15,7 @@ * mechanical {@link SpeakableStream} cleanup — speech never blocks on the * model. */ -import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai"; +import { type AssistantMessage, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver"; @@ -82,20 +82,25 @@ export class SpeechEnhancer { if (!apiKey) return null; // Resolve metadata after getApiKey so the session-sticky credential is recorded first. const metadata = this.#deps.metadataResolver?.(model.provider); - const timeout = AbortSignal.timeout(REWRITE_TIMEOUT_MS); - const response = await completeSimple( - model, - { - systemPrompt: [SYSTEM_PROMPT], - messages: [{ role: "user", content: boundBlock(block), timestamp: Date.now() }], - }, - { - apiKey: registry.resolver(model, sessionId), - maxTokens: ANSWER_MAX_TOKENS, - disableReasoning: true, - metadata, - signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + const response = await retryTransientCompletion( + () => { + const timeout = AbortSignal.timeout(REWRITE_TIMEOUT_MS); + return completeSimple( + model, + { + systemPrompt: [SYSTEM_PROMPT], + messages: [{ role: "user", content: boundBlock(block), timestamp: Date.now() }], + }, + { + apiKey: registry.resolver(model, sessionId), + maxTokens: ANSWER_MAX_TOKENS, + disableReasoning: true, + metadata, + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }, + ); }, + { signal }, ); if (response.stopReason === "error") { logger.debug("speech-enhancer: rewrite errored", { error: response.errorMessage }); diff --git a/packages/coding-agent/src/utils/commit-message-generator.ts b/packages/coding-agent/src/utils/commit-message-generator.ts index 2a242f205..bb3c24500 100644 --- a/packages/coding-agent/src/utils/commit-message-generator.ts +++ b/packages/coding-agent/src/utils/commit-message-generator.ts @@ -4,7 +4,7 @@ */ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple } from "@oh-my-pi/pi-ai"; +import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; @@ -106,17 +106,19 @@ export async function generateCommitMessage( try { const maxTokens = COMMIT_MAX_TOKENS; - const response = await completeSimple( - candidate.model, - { - systemPrompt: [COMMIT_SYSTEM_PROMPT], - messages: [{ role: "user", content: userMessage, timestamp: Date.now() }], - }, - { - apiKey: registry.resolver(candidate.model, sessionId), - maxTokens, - reasoning: toReasoningEffort(candidate.thinkingLevel), - }, + const response = await retryTransientCompletion(() => + completeSimple( + candidate.model, + { + systemPrompt: [COMMIT_SYSTEM_PROMPT], + messages: [{ role: "user", content: userMessage, timestamp: Date.now() }], + }, + { + apiKey: registry.resolver(candidate.model, sessionId), + maxTokens, + reasoning: toReasoningEffort(candidate.thinkingLevel), + }, + ), ); if (response.stopReason === "error") { diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index 2ae6be589..ace47e4b8 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -4,7 +4,7 @@ import { dlopen, FFIType, ptr } from "bun:ffi"; import * as path from "node:path"; -import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai"; +import { type Api, type AssistantMessage, completeSimple, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai"; import { StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing"; import { isConPTYHosted } from "@oh-my-pi/pi-tui"; import { isTerminalHeadless, logger, prompt } from "@oh-my-pi/pi-utils"; @@ -254,24 +254,28 @@ export async function generateTitleOnline( const maxTokens = TITLE_MAX_TOKENS; logger.debug("title-generator: request", { ...modelContext, maxTokens }); - const response = await completeSimple( - model, - { - systemPrompt, - messages: [{ role: "user", content: userMessage, timestamp: Date.now() }], - }, - { - apiKey: registry.resolver(model, sessionId), - maxTokens, - disableReasoning: true, - // Greedy decode: titling is extraction, not generation. Backends that - // default temperature high (e.g. Ollama's 0.8) otherwise garble names - // from the message ("hashline" → "HasHroshi"). Providers whose models - // reject sampling params drop this via `supportsSamplingParams`. - temperature: 0, - metadata, - signal, - }, + const response = await retryTransientCompletion( + () => + completeSimple( + model, + { + systemPrompt, + messages: [{ role: "user", content: userMessage, timestamp: Date.now() }], + }, + { + apiKey: registry.resolver(model, sessionId), + maxTokens, + disableReasoning: true, + // Greedy decode: titling is extraction, not generation. Backends that + // default temperature high (e.g. Ollama's 0.8) otherwise garble names + // from the message ("hashline" → "HasHroshi"). Providers whose models + // reject sampling params drop this via `supportsSamplingParams`. + temperature: 0, + metadata, + signal, + }, + ), + { signal }, ); if (response.stopReason === "error") { diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index 8db7dda04..92ce840fd 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -421,11 +421,17 @@ export class VibeSessionRegistry { } /** - * Insert a bare worker record without the spawn/job machinery. Test-only — - * lets {@link aggregateVibeWorkerTokensPerSecond} be exercised against a - * fake roster + AgentRegistry session without driving a real turn. + * Insert a bare worker record without the spawn machinery. Test-only — + * lets focused runtime tests attach an optional synthetic in-flight job. */ - registerRecordForTests(record: { id: string; cli?: VibeCli; ownerId: string; state?: VibeSessionState }): void { + registerRecordForTests(record: { + id: string; + cli?: VibeCli; + ownerId: string; + state?: VibeSessionState; + jobId?: string; + }): void { + const now = Date.now(); this.#records.set(record.id, { id: record.id, cli: record.cli ?? "fast", @@ -434,8 +440,11 @@ export class VibeSessionRegistry { parentSessionFile: null, agent: getBundledAgent("sonic")!, state: record.state ?? "running", - createdAt: Date.now(), - lastActivityAt: Date.now(), + createdAt: now, + lastActivityAt: now, + turn: record.jobId + ? { jobId: record.jobId, message: "test turn", startedAt: now, trace: [], toolCount: 0 } + : undefined, queue: [], turnCount: 0, killed: false, @@ -1113,25 +1122,31 @@ export class VibeSessionRegistry { if (job?.status === "running") runningJobs.push(job); } - let waited = false; + let waitEndedByTimeout = false; if (runningJobs.length > 0 && collectSettled().length === 0) { - waited = true; const timeoutMs = Math.max(1, Math.trunc(args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)); const watchedJobIds = runningJobs.map(job => job.id); manager.watchJobs(watchedJobIds); - const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers(); - const timeoutHandle = setTimeout(() => timeoutResolve(), timeoutMs); - const racePromises: Promise[] = [...runningJobs.map(job => job.promise), timeoutPromise]; + const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers<"timeout">(); + const timeoutHandle = setTimeout(() => timeoutResolve("timeout"), timeoutMs); + const racePromises: Array> = [ + ...runningJobs.map(job => job.promise.then(() => "settled" as const)), + timeoutPromise, + ]; let abortCleanup: (() => void) | undefined; if (args.signal) { - const { promise: abortPromise, resolve: abortResolve } = Promise.withResolvers(); - const onAbort = () => abortResolve(); - args.signal.addEventListener("abort", onAbort, { once: true }); - abortCleanup = () => args.signal?.removeEventListener("abort", onAbort); + const { promise: abortPromise, resolve: abortResolve } = Promise.withResolvers<"aborted">(); + const onAbort = () => abortResolve("aborted"); + if (args.signal.aborted) { + onAbort(); + } else { + args.signal.addEventListener("abort", onAbort, { once: true }); + abortCleanup = () => args.signal?.removeEventListener("abort", onAbort); + } racePromises.push(abortPromise); } try { - await Promise.race(racePromises); + waitEndedByTimeout = (await Promise.race(racePromises)) === "timeout"; } finally { manager.unwatchJobs(watchedJobIds); clearTimeout(timeoutHandle); @@ -1144,7 +1159,7 @@ export class VibeSessionRegistry { // Current in-flight state, independent of the snapshot: a session whose // watched turn settled may already be mid queued follow-up. const stillRunning = watched.filter(record => record.turn !== undefined).map(record => record.id); - return { settled, stillRunning, timedOut: waited && settled.length === 0 }; + return { settled, stillRunning, timedOut: waitEndedByTimeout && settled.length === 0 }; } /** Detach one parent's process-local workers without tombstoning their persisted conversations. */ diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 952260cbf..a125fa074 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -23,6 +23,21 @@ import { TempDir } from "@oh-my-pi/pi-utils"; const AGENT_ID = "Worker"; const TEST_CWD = path.resolve("agent-hub-cwd"); +function persistedChildJsonl(id: string): string { + return [ + JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-07-30T01:13:37.835Z", cwd: TEST_CWD }), + JSON.stringify({ + type: "session_init", + id: "init", + parentId: null, + timestamp: "2026-07-30T01:13:37.835Z", + systemPrompt: "system", + task: "work", + tools: ["read"], + }), + ].join("\n"); +} + function makeHub(focusAgent: (id: string) => Promise) { const agents = new AgentRegistry(); agents.register({ @@ -123,7 +138,7 @@ describe("Agent hub Enter activation", () => { const sessionFile = path.join(tempDir.path(), "main.jsonl"); const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write(workerSessionFile, persistedChildJsonl("worker")); const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ settings: Settings.isolated(), @@ -172,8 +187,8 @@ describe("Agent hub Enter activation", () => { const parentSessionFile = path.join(tempDir.path(), "main", "Parent.jsonl"); const childSessionFile = path.join(tempDir.path(), "main", "Parent", "Child.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(parentSessionFile, ""); - await Bun.write(childSessionFile, ""); + await Bun.write(parentSessionFile, persistedChildJsonl("parent")); + await Bun.write(childSessionFile, persistedChildJsonl("child")); const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ settings: Settings.isolated(), @@ -393,9 +408,10 @@ describe("Agent hub Enter activation", () => { if (!sourceSessionFile) throw new Error("Expected source session file"); const sourceArtifacts = sourceSessionFile.slice(0, -6); await fs.mkdir(sourceArtifacts, { recursive: true }); - for (const id of ["ActiveVibe", "KilledVibe", "OrdinaryTask"]) { + for (const id of ["ActiveVibe", "KilledVibe"]) { await fs.writeFile(path.join(sourceArtifacts, `${id}.jsonl`), "persisted child"); } + await fs.writeFile(path.join(sourceArtifacts, "OrdinaryTask.jsonl"), persistedChildJsonl("OrdinaryTask")); const fork = await manager.fork(); if (!fork) throw new Error("Expected persisted fork"); await fs.cp(sourceArtifacts, fork.newSessionFile.slice(0, -6), { recursive: true }); @@ -581,7 +597,7 @@ describe("Agent hub double-← gating", () => { const sessionFile = path.join(tempDir.path(), "main.jsonl"); const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write(workerSessionFile, persistedChildJsonl("worker")); const agents = new AgentRegistry(); const { controller, shown, shownReady } = setup(agents, sessionFile); @@ -597,7 +613,7 @@ describe("Agent hub double-← gating", () => { using tempDir = TempDir.createSync("@omp-agent-hub-explicit-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); await Bun.write(sessionFile, ""); - await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), ""); + await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), persistedChildJsonl("worker")); const agents = new AgentRegistry(); const { controller, shown, overlayOptions } = setup(agents, sessionFile); diff --git a/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts b/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts index a8a003a22..7a47854ed 100644 --- a/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts +++ b/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts @@ -3,7 +3,12 @@ import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Message, ThinkingContent } from "@oh-my-pi/pi-ai"; -import { createMockModel, type MockContent, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; +import { + createMockModel, + type MockContent, + type MockModel, + type MockResponseSource, +} from "@oh-my-pi/pi-ai/providers/mock"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; @@ -12,7 +17,7 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { RewindTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { CheckpointTool, RewindTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { TempDir } from "@oh-my-pi/pi-utils"; @@ -105,7 +110,7 @@ function signedThinking(thinking: string, thinkingSignature: string): MockConten } async function createHarness( - responses: MockResponse[], + responses: MockResponseSource, tools: AgentTool[] = [checkpointTool as AgentTool, rewindTool as AgentTool], options?: { onAgentEnd?: (willContinue: boolean | undefined) => void }, ): Promise { @@ -250,7 +255,9 @@ describe("AgentSession checkpoint rewind branch context", () => { const reportMessage = finalCall.context.messages[reportIndex]; if (!reportMessage) throw new Error("Expected rewind report context"); const reportText = messageText(reportMessage); - expect(reportText).toContain("`rewind`"); + expect(reportText).toContain( + "Checkpoint called and rewound. Report retained below. Need explore again → new `checkpoint`.", + ); expect(reportText).toContain(report); expect( @@ -267,6 +274,94 @@ describe("AgentSession checkpoint rewind branch context", () => { expect(finalThinking?.thinkingSignature).toBe("sig_after_rewind"); }); + it("shows a transient checkpoint-active reminder that is branch-cut away on rewind", async () => { + const report = "findings: transient reminder"; + const proceed = Promise.withResolvers(); + const { session, mock } = await createHarness( + (async function* () { + yield { + content: [ + { type: "toolCall", id: "call_checkpoint", name: "checkpoint", arguments: { goal: "inspect" } }, + ], + stopReason: "toolUse", + }; + await proceed.promise; + yield { + content: [{ type: "toolCall", id: "call_rewind", name: "rewind", arguments: { report } }], + stopReason: "toolUse", + }; + yield { content: ["DONE"], stopReason: "stop" }; + })() as MockResponseSource, + ); + + const promptPromise = session.prompt("investigate with a checkpoint"); + // Pause the model between the checkpoint and rewind calls to observe the + // active-checkpoint state: the transient notice must be in the active path. + const deadline = Date.now() + 5000; + while ( + !session.messages.some( + message => message.role === "custom" && message.customType === "checkpoint-active-reminder", + ) || + mock.calls.length < 2 + ) { + if (Date.now() > deadline) throw new Error("checkpoint reminder/provider call never appeared"); + await Bun.sleep(10); + } + const reminder = session.messages.find( + (message): message is Extract => + message.role === "custom" && message.customType === "checkpoint-active-reminder", + ); + expect(reminder).toBeDefined(); + expect(reminder?.content).toContain("MUST `rewind` before yielding"); + const activeCall = mock.calls[1]; + expect(activeCall).toBeDefined(); + expect( + activeCall?.context.messages.some(message => messageText(message).includes("Exploration checkpoint active.")), + ).toBe(true); + // #checkpointState is set synchronously with the reminder (pre-await), so an + // immediate rewind would find an active checkpoint, not "No active checkpoint". + expect(session.getCheckpointState()).toBeDefined(); + proceed.resolve(); + await promptPromise; + + // After rewind the branch cut dropped the reminder from the active path. + expect( + session.messages.some( + message => message.role === "custom" && message.customType === "checkpoint-active-reminder", + ), + ).toBe(false); + + // No negation guard anywhere in the model-visible context. + const finalCall = mock.calls.at(-1); + if (!finalCall) throw new Error("Expected final post-rewind provider call"); + for (const message of finalCall.context.messages) { + const text = messageText(message); + expect(text).not.toContain("NEVER call"); + expect(text).not.toContain("Do not call `rewind` again"); + } + + // Exactly one developer message carries the forward-looking rewind report. + const reportMessages = finalCall.context.messages.filter( + message => message.role === "developer" && messageText(message).includes("Checkpoint called and rewound."), + ); + expect(reportMessages).toHaveLength(1); + expect(messageText(reportMessages[0]!)).toContain("Need explore again → new `checkpoint`."); + expect(messageText(reportMessages[0]!)).toContain(report); + }); + + it("checkpoint tool result carries only the goal and a forward-looking line", async () => { + const tool = new CheckpointTool( + createToolSession({ + getCheckpointState: () => undefined, + }), + ); + const result = await tool.execute("call_checkpoint", { goal: "inspect" }); + const text = result.content.find(part => part.type === "text")?.text; + expect(text).toBe("Checkpoint: inspect\nFinish exploration and formulate findings."); + expect(text).not.toContain("Run your investigation"); + expect(text).not.toContain("call rewind"); + }); + it("ignores a completed cycle's rewind result after rebuilding context", async () => { const staleReport = "findings from the previous checkpoint"; const currentReport = "findings from the current checkpoint"; diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 94ada99f5..258f7e044 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -845,8 +845,12 @@ describe("AgentSession concurrent prompt guard", () => { const observedIsStreamingAtAgentEnd: boolean[] = []; const reentrantPromptResults: Array<"resolved" | { error: string }> = []; + const observedIsStreamingAtRunIdle: boolean[] = []; let reentrantPrompted = false; + session.subscribeRunState(state => { + if (state === "idle") observedIsStreamingAtRunIdle.push(session.isStreaming); + }); session.subscribe(event => { if (event.type !== "agent_end") return; observedIsStreamingAtAgentEnd.push(session.isStreaming); @@ -863,6 +867,7 @@ describe("AgentSession concurrent prompt guard", () => { await session.waitForIdle(); expect(observedIsStreamingAtAgentEnd).not.toContain(true); + expect(observedIsStreamingAtRunIdle).toContain(true); expect(reentrantPromptResults).toEqual(["resolved"]); }); diff --git a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts index 579f2d51d..2f49b216c 100644 --- a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts @@ -57,7 +57,26 @@ function emptyStop(): MockResponse { return { content: [], stopReason: "stop", - usage: { output: 1, cacheRead: 100 }, + usage: { output: 0, cacheRead: 100 }, + }; +} + +// A zero-block `stop` for which the provider still billed output tokens: content +// was generated and dropped downstream (e.g. a filter/refusal flattened to +// `finish_reason: "stop"` by a proxy), so the context/`/shake images` hint is wrong. +function filteredEmptyStop(): MockResponse { + return { + content: [], + stopReason: "stop", + usage: { output: 126, cacheRead: 100 }, + }; +} + +function reasoningOnlyEmptyStop(): MockResponse { + return { + content: [], + stopReason: "stop", + usage: { output: 126, reasoningTokens: 126, cacheRead: 100 }, }; } @@ -212,6 +231,10 @@ describe("AgentSession empty stop guard", () => { .filter(entry => entry.type === "message") .map(entry => entry.message as AgentMessage); expect(emptyAssistantStops(activeBranchMessages)).toHaveLength(0); + // A discarded empty stop is physically removed from the journal, not just + // reparented off the active branch: it must never be able to resurface as + // the active leaf on reload (the loader rebuilds from the last physical + // entry) if the process is killed before the recovery turn lands. expect( emptyAssistantStops( session.sessionManager @@ -219,7 +242,7 @@ describe("AgentSession empty stop guard", () => { .filter(entry => entry.type === "message") .map(entry => entry.message as AgentMessage), ), - ).toHaveLength(1); + ).toHaveLength(0); }); it("retries a tool-use stop that has no tool call or text", async () => { @@ -373,6 +396,21 @@ describe("AgentSession empty stop guard", () => { .filter(entry => entry.type === "message") .map(entry => entry.message as AgentMessage); expect(emptyAssistantStops(activeBranchMessages)).toHaveLength(0); + + // The loader reconstructs the active branch from the last physical journal + // entry. The empty stop is removed from history and a marker durably + // selects its parent, so reload cannot reactivate the discarded turn. + const journalMessages = session.sessionManager + .getEntries() + .filter(entry => entry.type === "message") + .map(entry => entry.message as AgentMessage); + expect(emptyAssistantStops(journalMessages)).toHaveLength(0); + const lastJournalEntry = session.sessionManager.getEntries().at(-1); + expect(lastJournalEntry).toMatchObject({ + type: "branch_summary", + summary: "", + details: { kind: "discarded-entry-branch" }, + }); }); it("waits for capped empty-stop persistence before removing the active branch entry", async () => { @@ -463,6 +501,81 @@ describe("AgentSession empty stop guard", () => { expect(retryEndEvents[0]?.finalError).toContain("/shake images"); }); + it("names billed output tokens instead of the context hint when a capped empty stop billed output", async () => { + const { session, mock } = await createHarness([ + filteredEmptyStop(), + filteredEmptyStop(), + filteredEmptyStop(), + filteredEmptyStop(), + ]); + const retryEndEvents: Array> = []; + session.subscribe(event => { + if (event.type === "auto_retry_end") { + retryEndEvents.push(event); + } + }); + + await expectPromptCompletes(session.prompt("answer that gets filtered")); + await session.waitForIdle(); + + expect(mock.calls).toHaveLength(4); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]?.success).toBe(false); + const finalError = retryEndEvents[0]?.finalError ?? ""; + expect(finalError).toContain("billed 126 output tokens"); + expect(finalError).not.toContain("/shake images"); + }); + + it("keeps the context hint when a capped zero-block stop billed only reasoning tokens", async () => { + const { session, mock } = await createHarness([ + reasoningOnlyEmptyStop(), + reasoningOnlyEmptyStop(), + reasoningOnlyEmptyStop(), + reasoningOnlyEmptyStop(), + ]); + const retryEndEvents: Array> = []; + session.subscribe(event => { + if (event.type === "auto_retry_end") { + retryEndEvents.push(event); + } + }); + + await expectPromptCompletes(session.prompt("think without delivering an answer")); + await session.waitForIdle(); + + expect(mock.calls).toHaveLength(4); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]?.success).toBe(false); + const finalError = retryEndEvents[0]?.finalError ?? ""; + expect(finalError).toContain("/shake images"); + expect(finalError).not.toContain("billed"); + }); + + it("keeps the context hint for a capped thinking-only stop even though it billed output", async () => { + const { session, mock } = await createHarness([ + thinkingOnlyStop(), + thinkingOnlyStop(), + thinkingOnlyStop(), + thinkingOnlyStop(), + ]); + const retryEndEvents: Array> = []; + session.subscribe(event => { + if (event.type === "auto_retry_end") { + retryEndEvents.push(event); + } + }); + + await expectPromptCompletes(session.prompt("think without answering")); + await session.waitForIdle(); + + expect(mock.calls).toHaveLength(4); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]?.success).toBe(false); + const finalError = retryEndEvents[0]?.finalError ?? ""; + expect(finalError).toContain("/shake images"); + expect(finalError).not.toContain("billed"); + }); + it("ends auto-retry state when empty stop retries hit the cap", async () => { vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); const { session, mock } = await createHarness( diff --git a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts index 88922785d..bb198cbee 100644 --- a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts @@ -496,6 +496,71 @@ describe("AgentSession mid-run threshold compaction", () => { expect(JSON.stringify(session.messages)).not.toContain("display-variant"); }); + it.each([ + ["auto_compaction_end", "context-full"], + ["session_compact", "context-full"], + ["auto_compaction_end", "shake"], + ["session_compact", "shake"], + ] as const)("hung %s handlers do not pin the mid-run %s loop", async (handlerType, strategy) => { + const releaseHandler = Promise.withResolvers(); + const handlerEntered = Promise.withResolvers(); + const nextProviderCall = Promise.withResolvers(); + const extensionRunner = { + hasHandlers: vi.fn((eventType: string) => eventType === handlerType), + emitBeforeAgentStart: vi.fn(async () => undefined), + emit: vi.fn(async (event: { type: string }) => { + if (event.type === handlerType) { + handlerEntered.resolve(); + await releaseHandler.promise; + } + }), + } as unknown as ExtensionRunner; + const { session, observedContexts } = await createHarness( + { "compaction.strategy": strategy }, + { + extensionRunner, + onProviderCall: index => { + if (index === 1) nextProviderCall.resolve(); + }, + }, + ); + const shakeSpy = + strategy === "shake" + ? vi + .spyOn(session, "shake") + .mockResolvedValue({ mode: "elide", toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 }) + : undefined; + const compactSpy = mockCompaction("MID-RUN-COMPACTED-WITHOUT-WAITING-ON-LIFECYCLE"); + + const prompt = session.prompt("work on the release"); + const handlerOutcome = await raceWithTimeout( + handlerEntered.promise.then(() => "entered" as const), + 2_000, + "blocked" as const, + ); + const providerOutcome = + handlerOutcome === "entered" + ? await raceWithTimeout( + nextProviderCall.promise.then(() => "dispatched" as const), + 2_000, + "blocked" as const, + ) + : "blocked"; + const promptOutcome = await raceWithTimeout( + prompt.then(() => "settled" as const), + 2_000, + "blocked" as const, + ); + releaseHandler.resolve(); + + expect(handlerOutcome).toBe("entered"); + expect(providerOutcome).toBe("dispatched"); + expect(promptOutcome).toBe("settled"); + expect(compactSpy).toHaveBeenCalledTimes(1); + if (shakeSpy) expect(shakeSpy).toHaveBeenCalledTimes(1); + expect(observedContexts[1].join("\n")).toContain("MID-RUN-COMPACTED-WITHOUT-WAITING-ON-LIFECYCLE"); + }); + it("does not compact mid-run outside goal mode when disabled", async () => { const { session } = await createHarness({ "compaction.midTurnEnabled": false }); const compactSpy = mockCompaction("SHOULD-NOT-RUN"); diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index fcf3823e0..251252339 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -333,7 +333,10 @@ describe("AgentSession message pipeline", () => { expect(contexts).toHaveLength(1); const userMessage = contexts[0]!.messages.find(message => message.role === "user"); + // The date/cwd reminder rides on the first user turn (#7404); the contract + // here is that the undecodable WebP is replaced by the placeholder text. expect(userMessage?.content).toEqual([ + { type: "text", text: expect.stringContaining("") }, { type: "text", text: "inspect this" }, { type: "text", text: "[image omitted: WebP could not be decoded for this model]" }, ]); diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index 3b2b27b54..e7902693e 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -1263,6 +1263,129 @@ describe("AgentSession retry delay cap", () => { expect(retryEndEvents).toContainEqual(expect.objectContaining({ success: true, attempt: 1 })); expect(lastAssistant(session).content).toContainEqual({ type: "text", text: "Recovered after Cursor stall" }); }); + + it("resumes a Cursor HTTP/2 stream reset after an unmarked MCP tool result", async () => { + const resetMessage = "Stream closed with error code NGHTTP2_INTERNAL_ERROR"; + const model = createMockModel({ + id: "composer-2.5", + provider: "cursor", + }); + authStorage.setRuntimeApiKey("cursor", "cursor-test-key"); + const toolCall: ToolCall = { + type: "toolCall", + id: "cursor-mcp-1", + name: "mcp__databricks_production_execute_sql", + arguments: { query: "SELECT 1" }, + }; + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: "1" }], + isError: false, + timestamp: Date.now(), + }; + let streamCalls = 0; + let resumedWithToolResult = false; + const agent = new Agent({ + getApiKey: requestedModel => `${requestedModel.provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + cursorOnToolResult: message => message, + streamFn: (_requestedModel, context, options) => { + streamCalls += 1; + if (streamCalls > 1) { + resumedWithToolResult = context.messages.some( + message => message.role === "toolResult" && message.toolCallId === toolCall.id, + ); + model.push({ content: ["Recovered after Cursor HTTP/2 reset"] }); + return model.stream(model, context, options); + } + + const stream = new AssistantMessageEventStream(); + queueMicrotask(async () => { + await options?.cursorOnToolResult?.(toolResult); + const partial: AssistantMessage = { + role: "assistant", + content: [toolCall], + 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(), + }; + stream.push({ type: "start", partial }); + stream.push({ type: "toolcall_start", contentIndex: 0, partial }); + stream.push({ + type: "toolcall_delta", + contentIndex: 0, + delta: JSON.stringify(toolCall.arguments), + partial, + }); + stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial }); + stream.push({ + type: "error", + reason: "error", + error: { + ...partial, + stopReason: "error", + errorMessage: resetMessage, + }, + }); + }); + return stream; + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxRetries": 1, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const retryStartEvents: AutoRetryStartEvent[] = []; + const retryEndEvents: AutoRetryEndEvent[] = []; + session.subscribe(event => { + if (event.type === "auto_retry_start") retryStartEvents.push(event); + if (event.type === "auto_retry_end") retryEndEvents.push(event); + }); + + await session.prompt("Run the query"); + await session.waitForIdle(); + + expect(streamCalls).toBe(2); + expect(resumedWithToolResult).toBe(true); + expect( + session.agent.state.messages.some( + message => message.role === "toolResult" && message.toolCallId === toolCall.id, + ), + ).toBe(true); + expect(retryStartEvents).toHaveLength(1); + expect(retryEndEvents).toContainEqual(expect.objectContaining({ success: true, attempt: 1 })); + expect(lastAssistant(session).content).toContainEqual({ + type: "text", + text: "Recovered after Cursor HTTP/2 reset", + }); + }); + it("resumes a Cursor reasonless abort after an unmarked client-side tool call", async () => { const model = createMockModel({ id: "composer-2.5", diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 9782fa0e0..39e6662fe 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1884,6 +1884,165 @@ describe("AgentSession retry fallback", () => { expect(session.model?.provider).toBe(fallbackModel.provider); expect(getLastAssistantMessage(session).stopReason).toBe("stop"); }); + it("surfaces immutable Anthropic thinking errors without retry fallback", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("anthropic", "claude-opus-4-1"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled Anthropic test models to exist"); + } + + const immutableThinkingError = + "400 invalid_request_error: messages.1.content.5: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified"; + const mock = createMockModel(); + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + let requestCount = 0; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { + model: primaryModel, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (model, context, options) => { + requestCount++; + requestedModels.push(`${model.provider}/${model.id}`); + if (requestCount === 1) { + mock.push({ + content: [ + { type: "thinking", thinking: "Signed Sonnet reasoning.", thinkingSignature: "sonnet-signature" }, + "Seeded turn", + ], + }); + } else { + mock.push({ throw: immutableThinkingError }); + } + return mock.stream(model, context, options); + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.fallbackChains": { + "anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const { retryStartEvents } = trackRetryEvents(session); + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Seed signed thinking"); + await session.waitForIdle(); + await session.prompt("Continue"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${primaryModel.provider}/${primaryModel.id}`, + ]); + expect(fallbackAppliedEvents).toEqual([]); + expect(retryStartEvents).toEqual([]); + expect(session.model?.id).toBe(primaryModel.id); + expect(getLastAssistantMessage(session)).toMatchObject({ + stopReason: "error", + errorMessage: immutableThinkingError, + }); + }); + + it("keeps signed Anthropic thinking on its source model during transient retry", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("anthropic", "claude-opus-4-1"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled Anthropic test models to exist"); + } + + const mock = createMockModel(); + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + let requestCount = 0; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { + model: primaryModel, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (model, context, options) => { + requestCount++; + requestedModels.push(`${model.provider}/${model.id}`); + if (requestCount === 1) { + mock.push({ + content: [ + { type: "thinking", thinking: "Signed Sonnet reasoning.", thinkingSignature: "sonnet-signature" }, + "Seeded turn", + ], + }); + } else if (requestCount === 2) { + mock.push({ throw: "503 overloaded_error: provider returned error" }); + } else { + mock.push({ content: ["Recovered on Sonnet"] }); + } + return mock.stream(model, context, options); + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 1, + "retry.maxRetries": 1, + "retry.fallbackChains": { + "anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Seed signed thinking"); + await session.waitForIdle(); + const seededAssistant = agent.state.messages.findLast( + (message): message is AssistantMessage => message.role === "assistant", + ); + if (!seededAssistant) throw new Error("Expected seeded assistant message"); + seededAssistant.api = primaryModel.api; + seededAssistant.provider = primaryModel.provider; + seededAssistant.model = primaryModel.id; + await session.prompt("Retry a transient failure"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${primaryModel.provider}/${primaryModel.id}`, + `${primaryModel.provider}/${primaryModel.id}`, + ]); + expect(fallbackAppliedEvents).toEqual([]); + expect(retryStartEvents).toHaveLength(1); + expect(retryEndEvents).toEqual([expect.objectContaining({ success: true })]); + expect(session.model?.id).toBe(primaryModel.id); + expect(getLastAssistantMessage(session).stopReason).toBe("stop"); + }); it("surfaces a non-retryable error without same-model retries when no fallback candidate has a credential", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 5b6cedd11..506215b8c 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -8,6 +8,7 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SKILL_PROMPT_MESSAGE_TYPE } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { AUTO_THINKING, @@ -374,6 +375,63 @@ describe("AgentSession role model thinking behavior", () => { expect(session.agent.state.thinkingLevel).toBe(Effort.Medium); }); + it("classifies a user-invoked /skill turn under auto (resolves concrete effort)", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + await createSession({ + initialModelId: model.id, + initialThinkingLevel: Effort.High, + modelRoles: { default: `${model.provider}/${model.id}` }, + }); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Medium); + + session.setThinkingLevel(AUTO_THINKING); + expect(session.autoResolvedThinkingLevel()).toBeUndefined(); + + // A /skill: invocation reaches the session as a user-attributed + // custom message, not a `user` role. It is still a real user turn. + await session.promptCustomMessage({ + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: "Expanded SKILL.md body: implement the focused parser fix", + display: true, + details: { name: "implement", path: "/skills/implement/SKILL.md", args: "the parser" }, + attribution: "user", + }); + + expect(classifierSpy).toHaveBeenCalledTimes(1); + expect(classifierSpy.mock.calls[0]?.[0]).toContain("implement the focused parser fix"); + expect(promptSpy).toHaveBeenCalledTimes(1); + expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING); + expect(session.thinkingLevel).toBe(Effort.Medium); + expect(session.autoResolvedThinkingLevel()).toBe(Effort.Medium); + expect(session.agent.state.thinkingLevel).toBe(Effort.Medium); + }); + + it("does not classify an agent-originated skill custom message under auto", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + await createSession({ + initialModelId: model.id, + initialThinkingLevel: Effort.High, + modelRoles: { default: `${model.provider}/${model.id}` }, + }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Medium); + + session.setThinkingLevel(AUTO_THINKING); + + // Autoloaded / agent-originated skill injections must stay excluded. + await session.promptCustomMessage({ + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: "Autoloaded skill body", + display: false, + details: { name: "autoload", path: "/skills/autoload/SKILL.md" }, + attribution: "agent", + }); + + expect(classifierSpy).not.toHaveBeenCalled(); + expect(session.autoResolvedThinkingLevel()).toBeUndefined(); + }); + it("keeps auto active on resume (pending until the next turn reclassifies)", async () => { const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); const agent = new Agent({ diff --git a/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts b/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts index fb0fb5a65..f0b7b4f76 100644 --- a/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts +++ b/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts @@ -112,6 +112,23 @@ describe("AgentSession steer idle drain", () => { expect(continueSpy).toHaveBeenCalledTimes(1); }); + it("delivers successive idle steers after each successful drain", async () => { + await createSession([{ role: "user", content: "hello", timestamp: Date.now() }, createAssistantMessage()]); + const continueSpy = vi.spyOn(session.agent, "continue").mockImplementation(async () => { + session.agent.clearAllQueues(); + }); + + await session.steer("first steer"); + vi.advanceTimersByTime(200); + await session.waitForIdle(); + + await session.steer("second steer"); + vi.advanceTimersByTime(200); + await session.waitForIdle(); + + expect(continueSpy).toHaveBeenCalledTimes(2); + }); + it("delivers a steer queued after an interrupted tool result", async () => { await createSession([ { role: "user", content: "hello", timestamp: Date.now() }, diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 7856cce05..7e84f221d 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -98,7 +98,6 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { interface NewSessionOptions { getMcpServerInstructions?: () => Map | undefined; - getLocalCalendarDate?: () => string; xdev?: XdevState; lazyWrite?: boolean; /** Scripted mock model responses; enables driving `session.prompt()`. */ @@ -183,7 +182,6 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { return { systemPrompt: [`${base}\nxd:// catalog: ${catalog.join(",")}`], xdevCatalogNames: catalog }; }, getMcpServerInstructions: options.getMcpServerInstructions, - getLocalCalendarDate: options.getLocalCalendarDate, xdev: options.xdev, }); sessions.push(session); @@ -822,39 +820,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { await session.refreshMCPTools([dynamicTool]); expect(rebuildCount).toBe(baseline + 1); }); - it("rebuilds when the local calendar date rolls over between tool-stable MCP refreshes", async () => { - // `buildSystemPrompt` injects today's local date into the prompt body. The - // signature reads the same date provider so a session spanning local midnight - // must rebuild after an MCP reconnect with an otherwise identical tool set. - let currentDate = "2026-06-30"; - let rebuildCount = 0; - const { session } = newSession( - async toolNames => { - rebuildCount++; - return `tools:${toolNames.join(",")}`; - }, - { getLocalCalendarDate: () => currentDate }, - ); - const tool = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search"); - // First refresh: no signature yet, must rebuild. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(1); - - // Same tools, same local day: signature matches, skip. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(1); - - currentDate = "2026-07-01"; - - // Same tools, new local calendar day: date segment changed, must rebuild. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(2); - - // Same tools, same new local day: skip again. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(2); - }); it("does not rebuild when MCP server instructions change only beyond the 4000-char truncation boundary", async () => { // `rebuildSystemPrompt` (sdk.ts) truncates each server instruction to 4000 chars // before embedding it. The `getMcpServerInstructions` callback must therefore @@ -1268,19 +1234,17 @@ These tools became available: it("rolls back MCP catalog replacement when prompt rebuild fails", async () => { let failRebuild = false; - let date = "2026-07-16"; const xdevState = createTestXdevState(); const { session } = newSession( async toolNames => { if (failRebuild) throw new Error("rebuild failed"); return `tools:${toolNames.join(",")}`; }, - { xdev: xdevState, getLocalCalendarDate: () => date }, + { xdev: xdevState }, ); const oldTool = createMcpCustomTool("mcp__nucleus_old", "nucleus", "old", "Old tool"); const newTool = createMcpCustomTool("mcp__nucleus_new", "nucleus", "new", "New tool"); await session.refreshMCPTools([oldTool]); - date = "2026-07-17"; failRebuild = true; await expect(session.refreshMCPTools([newTool])).rejects.toThrow("rebuild failed"); @@ -1297,30 +1261,49 @@ These tools became available: it("rolls back RPC catalog replacement when prompt rebuild fails", async () => { let failRebuild = false; - let date = "2026-07-16"; const xdevState = createTestXdevState(); const { session } = newSession( async toolNames => { if (failRebuild) throw new Error("rebuild failed"); return `tools:${toolNames.join(",")}`; }, - { xdev: xdevState, getLocalCalendarDate: () => date }, + { xdev: xdevState }, ); - const oldTool = { ...createBasicTool("rpc_old", "RPC Old"), loadMode: "discoverable" as const }; - const newTool = { ...createBasicTool("rpc_new", "RPC New"), loadMode: "discoverable" as const }; + // Non-discoverable RPC tools stay active top-level, so replacing the catalog + // (old → new) changes the rebuild signature on its own — the replacement + // itself must trigger the failing rebuild that gets rolled back. + const oldTool = createBasicTool("rpc_old", "RPC Old"); + const newTool = createBasicTool("rpc_new", "RPC New"); await session.refreshRpcHostTools([oldTool]); - date = "2026-07-17"; failRebuild = true; await expect(session.refreshRpcHostTools([newTool])).rejects.toThrow("rebuild failed"); expect(session.getToolByName(oldTool.name)).toBeDefined(); expect(session.getToolByName(newTool.name)).toBeUndefined(); - expect(session.getMountedXdevToolNames()).toContain(oldTool.name); + expect(session.getActiveToolNames()).toContain(oldTool.name); + expect(session.getActiveToolNames()).not.toContain(newTool.name); failRebuild = false; await session.refreshRpcHostTools([newTool]); expect(session.getToolByName(oldTool.name)).toBeUndefined(); expect(session.getToolByName(newTool.name)).toBeDefined(); - expect(session.getMountedXdevToolNames()).toContain(newTool.name); + expect(session.getActiveToolNames()).toContain(newTool.name); + }); + + it("keeps mixed-case plugin devices mounted when MCP tools refresh", async () => { + const xdevState = createTestXdevState(); + const { session, toolRegistry } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { + xdev: xdevState, + }); + const pluginTool = { ...createBasicTool("CaseAdd", "Case Add"), loadMode: "discoverable" as const }; + toolRegistry.set(pluginTool.name, pluginTool); + xdevState.mountedNames.add(pluginTool.name); + + await session.refreshMCPTools([ + createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus"), + ]); + + expect(session.getMountedXdevToolNames()).toContain("CaseAdd"); + expect(session.getToolByName("CaseAdd")).toBeDefined(); }); }); diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index 1edccfd7e..89d3e413b 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -176,7 +176,13 @@ describe("auto thinking classifier helpers", () => { | undefined; expect(effort).toBe(Effort.High); - expect(options).toMatchObject({ disableReasoning: true, maxTokens: 1024 }); + // The cap must exceed Anthropic's 1024-token minimum thinking budget so an + // Anthropic-dialect proxy (LiteLLM/Vertex) that downgrades the disabled + // request to the lowest reasoning effort still satisfies + // `max_tokens > thinking.budget_tokens` (issue #8610). + expect(options?.disableReasoning).toBe(true); + expect(options?.maxTokens).toBe(4096); + expect(options?.maxTokens).toBeGreaterThan(1024); }); function createOnlineFixture(targetModel: Model, answer: string, maxEffort: "xhigh" | "max" = "xhigh") { diff --git a/packages/coding-agent/test/biome-client.test.ts b/packages/coding-agent/test/biome-client.test.ts index b97b6a0db..3a731af70 100644 --- a/packages/coding-agent/test/biome-client.test.ts +++ b/packages/coding-agent/test/biome-client.test.ts @@ -130,3 +130,44 @@ describe("BiomeClient format", () => { expect(result).toBe(content); }); }); + +describe("BiomeClient lint", () => { + test("surfaces Biome 2.x --reporter=json diagnostics", async () => { + const tempDir = await makeTempDir(); + await Bun.write( + path.join(tempDir, "biome.json"), + `${JSON.stringify({ linter: { enabled: true, rules: { recommended: true } } })}\n`, + ); + const targetFile = path.join(tempDir, "lint-me.ts"); + // `x == 2` triggers lint/suspicious/noDoubleEquals (a recommended rule). + await Bun.write(targetFile, "const x: number = 1;\nif (x == 2) {\n}\n"); + + const diagnostics = await new BiomeClient(biomeConfig(repoBiome), tempDir).lint(targetFile); + + const doubleEquals = diagnostics.find(d => d.code === "lint/suspicious/noDoubleEquals"); + expect(doubleEquals).toBeDefined(); + expect(doubleEquals?.source).toBe("biome"); + expect(doubleEquals?.severity).toBe(1); + expect(doubleEquals?.message).toContain("=="); + // Biome reports `==` at line 2, columns 7-9 (1-indexed); LSP ranges are + // 0-indexed, so the mapping must land on line 1, characters 6-8. + expect(doubleEquals?.range).toEqual({ + start: { line: 1, character: 6 }, + end: { line: 1, character: 8 }, + }); + }); + + test("returns no diagnostics for a clean file", async () => { + const tempDir = await makeTempDir(); + await Bun.write( + path.join(tempDir, "biome.json"), + `${JSON.stringify({ linter: { enabled: true, rules: { recommended: true } } })}\n`, + ); + const targetFile = path.join(tempDir, "clean.ts"); + await Bun.write(targetFile, "export const value = 1;\n"); + + const diagnostics = await new BiomeClient(biomeConfig(repoBiome), tempDir).lint(targetFile); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/builtin-names.test.ts b/packages/coding-agent/test/builtin-names.test.ts new file mode 100644 index 000000000..f5288f182 --- /dev/null +++ b/packages/coding-agent/test/builtin-names.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "bun:test"; +import { normalizeToolName } from "../src/tools/builtin-names"; + +describe("normalizeToolName", () => { + it("maps legacy builtin aliases without touching plugin case", () => { + expect(normalizeToolName("search")).toBe("grep"); + expect(normalizeToolName("Read")).toBe("read"); + expect(normalizeToolName("CaseAdd")).toBe("CaseAdd"); + expect(normalizeToolName("Constructor")).toBe("Constructor"); + }); +}); diff --git a/packages/coding-agent/test/collab/discarded-entry-marker.test.ts b/packages/coding-agent/test/collab/discarded-entry-marker.test.ts new file mode 100644 index 000000000..6ae073760 --- /dev/null +++ b/packages/coding-agent/test/collab/discarded-entry-marker.test.ts @@ -0,0 +1,95 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { importRoomKey } from "@oh-my-pi/pi-coding-agent/collab/crypto"; +import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host"; +import { COLLAB_PROTO, type CollabFrame, parseCollabLink } from "@oh-my-pi/pi-coding-agent/collab/protocol"; +import { CollabSocket } from "@oh-my-pi/pi-coding-agent/collab/relay-client"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { installInMemoryRelay, uninstallInMemoryRelay } from "./helpers/in-memory-relay"; + +function makeHostContext(manager: SessionManager): InteractiveModeContext { + return { + settings: { get: () => "" }, + sessionManager: manager, + session: { + isStreaming: false, + queuedMessageCount: 0, + sessionName: "discard marker", + model: undefined, + thinkingLevel: undefined, + subscribe: () => () => {}, + emitNotice: () => {}, + promptCustomMessage: () => Promise.resolve(), + abort: () => Promise.resolve(), + }, + eventBus: undefined, + statusLine: { + setCollabStatus: () => {}, + invalidate: () => {}, + getCachedContextBreakdown: () => ({ usedTokens: 0, contextWindow: 0 }), + }, + ui: { requestRender: () => {} }, + showStatus: () => {}, + collabHost: undefined, + } as unknown as InteractiveModeContext; +} + +beforeAll(installInMemoryRelay); +afterAll(uninstallInMemoryRelay); + +describe("discarded entry branch replication", () => { + it("keeps the pre-discard conversation connected on a guest snapshot", async () => { + const manager = SessionManager.inMemory(); + const priorId = manager.appendMessage({ role: "user", content: "prior", timestamp: Date.now() }); + const discardedId = manager.appendMessage({ + role: "assistant", + content: [], + api: "mock", + provider: "mock", + model: "mock", + 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(), + }); + await manager.discardEntryDurably(discardedId); + const markerId = manager.getBranch().at(-1)?.id; + if (!markerId) throw new Error("Expected a durable branch marker"); + const reminderId = manager.appendMessage({ role: "developer", content: "retry", timestamp: Date.now() }); + + const host = new CollabHost(makeHostContext(manager)); + let socket: CollabSocket | undefined; + try { + await host.start("ws://localhost:8788"); + const parsed = parseCollabLink(host.link); + if ("error" in parsed) throw new Error(parsed.error); + const key = await importRoomKey(parsed.key); + socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key }); + const frames: CollabFrame[] = []; + const complete = Promise.withResolvers(); + socket.onFrame = frame => { + frames.push(frame); + if (frame.t === "snapshot-chunk" && frame.final) complete.resolve(); + }; + socket.onOpen = () => socket?.send({ t: "hello", proto: COLLAB_PROTO, name: "replication test" }); + socket.connect(); + await complete.promise; + + const guest = SessionManager.inMemory(); + for (const frame of frames) { + if (frame.t !== "snapshot-chunk") continue; + for (const entry of frame.entries) guest.ingestReplicatedEntry(entry); + } + expect(guest.getBranch().map(entry => entry.id)).toEqual([priorId, markerId, reminderId]); + } finally { + socket?.close(); + await host.stop("test done"); + } + }); +}); diff --git a/packages/coding-agent/test/date-cwd-reminder.test.ts b/packages/coding-agent/test/date-cwd-reminder.test.ts new file mode 100644 index 000000000..f08ed16ca --- /dev/null +++ b/packages/coding-agent/test/date-cwd-reminder.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { Api, Context, Message, Model, ModelSpec } from "@oh-my-pi/pi-ai"; +import { clearCustomApis, registerCustomApi } from "@oh-my-pi/pi-ai"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { + injectDateCwdReminder, + renderDateCwdReminder, + withDateCwdReminder, +} from "@oh-my-pi/pi-coding-agent/session/date-cwd-reminder"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { formatLocalCalendarDate } from "@oh-my-pi/pi-coding-agent/utils/local-date"; +import { normalizePromptPath } from "@oh-my-pi/pi-coding-agent/utils/prompt-path"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { createAssistantMessage } from "./helpers/agent-session-setup"; + +describe("date-cwd-reminder", () => { + afterEach(() => { + clearCustomApis(); + }); + + describe("renderDateCwdReminder", () => { + it("renders a system-reminder block carrying the date and cwd with a do-not-repeat instruction", () => { + const reminder = renderDateCwdReminder("2026-08-14", "C:/work/omp"); + + expect(reminder.startsWith("")).toBe(true); + expect(reminder.endsWith("")).toBe(true); + expect(reminder).toContain("2026-08-14"); + expect(reminder).toContain("C:/work/omp"); + expect(reminder).toContain("Do not repeat"); + }); + }); + + describe("injectDateCwdReminder", () => { + it("prepends the reminder to the first user message with string content without mutating the input", () => { + const messages: Message[] = [{ role: "user", content: "hello", timestamp: 1 }, createAssistantMessage("hi")]; + const original = [...messages]; + + const out = injectDateCwdReminder(messages, "x"); + + expect(out).not.toBe(messages); + expect(out[0]).toEqual({ + role: "user", + content: "x\n\nhello", + timestamp: 1, + }); + expect(out[1]).toBe(messages[1]); + expect(messages).toEqual(original); + }); + + it("prepends a text part before image parts when the first user message has array content", () => { + const messages: Message[] = [ + { + role: "user", + content: [{ type: "image", data: "img", mimeType: "image/png" }], + timestamp: 1, + }, + ]; + + const out = injectDateCwdReminder(messages, "x"); + + expect(out[0]?.content).toEqual([ + { type: "text", text: "x" }, + { type: "image", data: "img", mimeType: "image/png" }, + ]); + }); + + it("returns the input unchanged when there is no user message", () => { + const messages: Message[] = [createAssistantMessage("hi")]; + + expect(injectDateCwdReminder(messages, "x")).toBe(messages); + expect(injectDateCwdReminder([], "x")).toEqual([]); + }); + + it("reuses the same injected message object for the same pristine first user message and reminder", () => { + // The append-only context path hands back fresh array copies every turn + // but reuses the same message objects; the injected first-turn message + // must keep its identity so the stable prefix is preserved (and the + // provider prompt cache is not churned by fresh clones). + const pristine: Message = { role: "user", content: "first", timestamp: 1 }; + const reminder = "x"; + + const first = injectDateCwdReminder([pristine], reminder)[0]!; + const second = injectDateCwdReminder([pristine], reminder)[0]!; + expect(second).toBe(first); + + // A changed reminder (e.g. midnight rollover) must re-inject fresh. + const refreshed = injectDateCwdReminder([pristine], "y")[0]!; + expect(refreshed).not.toBe(first); + expect(refreshed.content).toContain("y"); + }); + + it("does not double-wrap when the first user message already carries the reminder", () => { + const reminder = "x"; + const messages: Message[] = [{ role: "user", content: `${reminder}\n\nfirst`, timestamp: 1 }]; + + expect(injectDateCwdReminder(messages, reminder)).toBe(messages); + }); + }); + + describe("withDateCwdReminder", () => { + it("leaves NULL_PROMPT-style contexts (empty system prompt) untouched", () => { + const context: Context = { systemPrompt: [], messages: [{ role: "user", content: "hi", timestamp: 1 }] }; + expect(withDateCwdReminder(context, "2026-08-14", "/cwd")).toBe(context); + }); + + it("injects the reminder into the first user message and keeps the system prompt bytes", () => { + const systemPrompt = ["PROJECT\n\n- Must act.\n"]; + const context: Context = { + systemPrompt, + messages: [{ role: "user", content: "do the thing", timestamp: 1 }], + }; + + const out = withDateCwdReminder(context, "2026-08-14", "/work/omp"); + + expect(out).not.toBe(context); + expect(out.systemPrompt).toBe(systemPrompt); + expect(out.messages[0]).toEqual({ + role: "user", + content: `${renderDateCwdReminder("2026-08-14", "/work/omp")}\n\ndo the thing`, + timestamp: 1, + }); + }); + }); +}); + +describe("date-cwd reminder on the provider wire", () => { + const sessions: Array<{ dispose(): Promise }> = []; + + afterEach(async () => { + clearCustomApis(); + for (const session of sessions.splice(0)) { + await session.dispose(); + } + }); + + it("keeps the date/cwd out of the system prompt and pins the reminder to the first user turn across requests", async () => { + using tempDir = TempDir.createSync("@pi-date-cwd-reminder-"); + const api = "test-date-cwd-reminder"; + const contexts: Context[] = []; + registerCustomApi(api, (_model, context) => { + contexts.push(context); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + const message = createAssistantMessage("ok"); + stream.push({ type: "text_delta", contentIndex: 0, delta: "ok", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }); + const model = buildModel({ + id: "date-cwd-reminder", + name: "Date cwd reminder", + api, + provider: "managed-primary", + baseUrl: "http://127.0.0.1:8080/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 1024, + } as ModelSpec) as Model; + const authStorage = await AuthStorage.create(tempDir.join("auth.db")); + authStorage.setRuntimeApiKey(model.provider, "test-key"); + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + const { session } = await createAgentSession({ + cwd: tempDir.path(), + agentDir: tempDir.path(), + sessionManager: SessionManager.inMemory(tempDir.path()), + authStorage, + modelRegistry, + settings: Settings.isolated({ "compaction.enabled": false }), + model, + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + taskDepth: 1, + agentId: "SubAgent", + }); + sessions.push(session); + + try { + await session.sendUserMessage("first"); + + expect(contexts).toHaveLength(1); + // The volatile line must no longer live in the system prompt: open-weight + // chat templates render tool schemas after the system content, so any + // per-request byte there invalidates the whole tool-schema cache (#7404). + const systemPrompt = contexts[0]!.systemPrompt?.join("\n") ?? ""; + expect(systemPrompt).not.toContain("Today"); + expect(systemPrompt).not.toContain("current working directory"); + expect(systemPrompt).not.toContain(formatLocalCalendarDate()); + + const firstUser = contexts[0]!.messages[0]!; + expect(firstUser.role).toBe("user"); + const firstText = + typeof firstUser.content === "string" ? firstUser.content : JSON.stringify(firstUser.content); + expect(firstText).toContain(""); + expect(firstText).toContain(formatLocalCalendarDate()); + expect(firstText).toContain(normalizePromptPath(tempDir.path())); + + // A second request must re-emit byte-identical reminder bytes so the + // conversation prefix (system + tools + first turn) stays cached. + await session.sendUserMessage("second"); + expect(contexts).toHaveLength(2); + const secondFirst = contexts[1]!.messages[0]!; + expect(secondFirst.role).toBe("user"); + expect(typeof secondFirst.content).toBe(typeof firstUser.content); + expect(secondFirst.content).toEqual(firstUser.content); + } finally { + authStorage.close(); + } + }); +}); diff --git a/packages/coding-agent/test/debug/report-bundle-sessions.test.ts b/packages/coding-agent/test/debug/report-bundle-sessions.test.ts new file mode 100644 index 000000000..cc20c6272 --- /dev/null +++ b/packages/coding-agent/test/debug/report-bundle-sessions.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createReportBundle } from "@oh-my-pi/pi-coding-agent/debug/report-bundle"; +import { getConfigRootDir, removeWithRetries, setAgentDir } from "@oh-my-pi/pi-utils"; + +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; +const originalXdgStateHome = process.env.XDG_STATE_HOME; +const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); +let cleanupRoot: string | undefined; + +afterEach(async () => { + if (originalXdgStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalXdgStateHome; + } + if (originalAgentDir) { + setAgentDir(originalAgentDir); + } else { + setAgentDir(fallbackAgentDir); + delete process.env.PI_CODING_AGENT_DIR; + } + if (cleanupRoot) { + await removeWithRetries(cleanupRoot); + cleanupRoot = undefined; + } +}); + +async function archiveMembers(archivePath: string): Promise { + const archive = new Bun.Archive(await Bun.file(archivePath).bytes()); + return [...(await archive.files()).keys()].sort(); +} + +describe("report bundle sessions", () => { + it("bundles only the current session's subtree, not unrelated co-located sessions", async () => { + cleanupRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-report-sessions-")); + const xdgStateHome = path.join(cleanupRoot, "state"); + await fs.mkdir(path.join(xdgStateHome, "omp"), { recursive: true }); + process.env.XDG_STATE_HOME = xdgStateHome; + setAgentDir(fallbackAgentDir); + + const sessionsDir = path.join(cleanupRoot, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Current session and its artifacts subtree: a genuine subagent transcript + // plus a nested sub-subagent transcript one level deeper. + const sessionFile = path.join(sessionsDir, "2026-08-15T00-00-00_CURRENT.jsonl"); + await Bun.write(sessionFile, '{"type":"session","id":"CURRENT"}\n'); + const artifactsDir = sessionFile.slice(0, -6); + await fs.mkdir(path.join(artifactsDir, "SubTask"), { recursive: true }); + await Bun.write(path.join(artifactsDir, "SubTask.jsonl"), '{"type":"session","id":"SubTask"}\n'); + await Bun.write(path.join(artifactsDir, "SubTask", "NestedTask.jsonl"), '{"type":"session","id":"NestedTask"}\n'); + + // Unrelated top-level sessions co-located in the sessions root. + await Bun.write( + path.join(sessionsDir, "2026-08-10T00-00-00_OTHERA.jsonl"), + '{"type":"session","secret":"private-a"}\n', + ); + await Bun.write( + path.join(sessionsDir, "2026-08-12T00-00-00_OTHERB.jsonl"), + '{"type":"session","secret":"private-b"}\n', + ); + + const result = await createReportBundle({ sessionFile }); + const members = await archiveMembers(result.path); + await fs.rm(result.path, { force: true }); + + // Genuine subtree is captured recursively. + expect(members).toContain("artifacts/SubTask.jsonl"); + expect(members).toContain("artifacts/SubTask/NestedTask.jsonl"); + // Unrelated sessions never appear anywhere in the archive. + expect(members.some(name => name.includes("OTHERA") || name.includes("OTHERB"))).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/discovery/claude-tools.test.ts b/packages/coding-agent/test/discovery/claude-tools.test.ts new file mode 100644 index 000000000..8138e821a --- /dev/null +++ b/packages/coding-agent/test/discovery/claude-tools.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import { type CustomTool, toolCapability } from "@oh-my-pi/pi-coding-agent/capability/tool"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { clearClaudePluginRootsCache } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; +import { removeWithRetries } from "@oh-my-pi/pi-utils"; + +describe("Claude Code custom tool discovery", () => { + let root = ""; + let home = ""; + let project = ""; + let originalHome: string | undefined; + let originalClaudeConfigDir: string | undefined; + + beforeEach(async () => { + resetSettingsForTest(); + clearClaudePluginRootsCache(); + clearFsCache(); + originalHome = process.env.HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; + root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-claude-tools-")); + home = path.join(root, "home"); + project = path.join(root, "project"); + process.env.HOME = home; + vi.spyOn(os, "homedir").mockReturnValue(home); + await fs.mkdir(path.join(project, ".git"), { recursive: true }); + const settings = await Settings.init({ inMemory: true, cwd: project }); + initializeWithSettings(settings); + }); + + afterEach(async () => { + resetSettingsForTest(); + clearClaudePluginRootsCache(); + clearFsCache(); + vi.restoreAllMocks(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + await removeWithRetries(root); + }); + + test("discovers only JavaScript and TypeScript modules", async () => { + const userTools = path.join(home, ".claude", "tools"); + const projectTools = path.join(project, ".claude", "tools"); + await fs.mkdir(userTools, { recursive: true }); + await fs.mkdir(projectTools, { recursive: true }); + await Promise.all([ + fs.writeFile(path.join(userTools, "user-tool.ts"), "export default () => ({});\n"), + fs.writeFile(path.join(projectTools, "project-tool.js"), "export default () => ({});\n"), + fs.writeFile(path.join(userTools, "helper.sh"), "#!/bin/sh\n"), + fs.writeFile(path.join(projectTools, "helper.bash"), "#!/bin/bash\n"), + fs.writeFile(path.join(projectTools, "helper.py"), "print('helper')\n"), + fs.writeFile(path.join(projectTools, "notes.md"), "# Notes\n"), + ]); + + const result = await loadCapability(toolCapability.id, { + cwd: project, + providers: ["claude"], + }); + const tools = result.items + .map(tool => ({ name: tool.name, level: tool.level })) + .sort((a, b) => a.name.localeCompare(b.name)); + + expect(result.warnings).toEqual([]); + expect(tools).toEqual([ + { name: "project-tool", level: "project" }, + { name: "user-tool", level: "user" }, + ]); + }); + + test("filters non-module files from marketplace plugin tools", async () => { + const pluginsDir = path.join(home, ".claude", "plugins"); + const pluginPath = path.join(root, "fixture-plugin"); + const toolsDir = path.join(pluginPath, "tools"); + await fs.mkdir(pluginsDir, { recursive: true }); + await fs.mkdir(toolsDir, { recursive: true }); + await fs.writeFile( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { + "fixture@market": [ + { + scope: "user", + installPath: pluginPath, + version: "1.0.0", + installedAt: "2026-01-01T00:00:00Z", + lastUpdated: "2026-01-01T00:00:00Z", + }, + ], + }, + }), + ); + await Promise.all([ + fs.writeFile(path.join(toolsDir, "plugin-tool.ts"), "export default () => ({});\n"), + fs.writeFile(path.join(toolsDir, "helper.sh"), "#!/bin/sh\n"), + fs.writeFile(path.join(toolsDir, "notes.md"), "# Notes\n"), + ]); + + const result = await loadCapability(toolCapability.id, { + cwd: project, + providers: ["claude-plugins"], + }); + + expect(result.warnings).toEqual([]); + expect(result.items.map(tool => tool.name)).toEqual(["plugin-tool"]); + }); +}); diff --git a/packages/coding-agent/test/discovery/helpers.test.ts b/packages/coding-agent/test/discovery/helpers.test.ts index 422bc57b3..7755948ba 100644 --- a/packages/coding-agent/test/discovery/helpers.test.ts +++ b/packages/coding-agent/test/discovery/helpers.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import { parseFrontmatter } from "@oh-my-pi/pi-utils"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { clearCache } from "@oh-my-pi/pi-coding-agent/capability/fs"; +import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types"; +import { loadFilesFromDir } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; +import { parseFrontmatter, removeSyncWithRetries } from "@oh-my-pi/pi-utils"; describe("parseFrontmatter", () => { const parse = (content: string) => parseFrontmatter(content, { source: "tests:frontmatter", level: "off" }); @@ -147,3 +153,53 @@ Body content`; expect(result.body).toBe("Body content"); }); }); + +describe("loadFilesFromDir recursion", () => { + let tempDir!: string; + let ctx!: LoadContext; + + const write = (rel: string, content: string) => { + const full = path.join(tempDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + }; + + beforeEach(() => { + clearCache(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-loadfiles-recursion-")); + ctx = { cwd: tempDir, home: tempDir, repoRoot: tempDir }; + // Top-level tool plus a Python-venv-style frontend asset nested below it, + // mirroring the ~/.codex/tools/mineru/Lib/site-packages layout from #8552. + write("my-tool.ts", "export default () => ({});\n"); + write( + path.join("mineru", "Lib", "site-packages", "gradio", "assets", "svelte", "media-query-D37ajmZt.js"), + "window.matchMedia;\n", + ); + }); + + afterEach(() => { + clearCache(); + removeSyncWithRetries(tempDir); + }); + + const names = (dir: string, recursive?: boolean) => + loadFilesFromDir<{ name: string }>(ctx, dir, "test", "user", { + extensions: ["ts", "js"], + recursive, + transform: (_name, _content, filePath) => ({ name: path.relative(dir, filePath) }), + }).then(r => r.items.map(i => i.name).sort()); + + // Regression for #8552: the non-recursive default must NOT descend into the + // venv subtree. The native glob defaults recursive=true, so before the fix + // `*.{ts,js}` was rewritten to `**/*.{ts,js}` and imported the Svelte asset. + test("default scan stays top-level and skips the venv subtree", async () => { + expect(await names(tempDir)).toEqual(["my-tool.ts"]); + }); + + test("recursive:true still walks the whole subtree", async () => { + expect(await names(tempDir, true)).toEqual([ + path.join("mineru", "Lib", "site-packages", "gradio", "assets", "svelte", "media-query-D37ajmZt.js"), + "my-tool.ts", + ]); + }); +}); diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 0950650df..f4694a1e2 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -698,6 +698,28 @@ describe("extensions discovery", () => { expect(loadedHook?.handlers.has("tool_call")).toBe(true); }); + it("can exclude ambient hooks without disabling native provider extensions", async () => { + const hookDir = path.join(getProjectAgentDir(tempDir.path()), "hooks", "pre"); + fs.mkdirSync(hookDir, { recursive: true }); + const hookPath = path.join(hookDir, "models-poison.ts"); + fs.writeFileSync( + hookPath, + `export default function(pi) { + pi.on("tool_call", async () => ({ block: true, reason: "blocked by hook" })); + }`, + ); + const nativeExtensionPath = path.join(extensionsDir, "provider.ts"); + fs.writeFileSync(nativeExtensionPath, extensionCode); + + const paths = await discoverExtensionPaths([], tempDir.path(), undefined, { + ambient: true, + includeAmbientHooks: false, + }); + + expect(paths).toContain(nativeExtensionPath); + expect(paths).not.toContain(hookPath); + }); + it("keeps discovered hooks separate from disabled extension-module ids", async () => { const extensionPath = path.join(extensionsDir, "guard.ts"); fs.writeFileSync(extensionPath, extensionCode); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 41e40fab9..6de740abe 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -10,6 +10,7 @@ import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ExtensionRuntime, loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { EXTENSION_HANDLER_TIMEOUT_MS, @@ -1241,6 +1242,38 @@ describe("ExtensionRunner", () => { }); describe("handler timeouts", () => { + const initializeRunner = (runner: ExtensionRunner, uiContext: ExtensionUIContext): void => { + runner.initialize( + { + sendMessage: () => {}, + sendUserMessage: () => {}, + appendEntry: () => {}, + setLabel: () => {}, + getActiveTools: () => [], + getAllTools: () => [], + setActiveTools: async () => {}, + getCommands: () => [], + setModel: async () => false, + getThinkingLevel: () => undefined, + setThinkingLevel: () => {}, + getSessionName: () => undefined, + setSessionName: async () => {}, + }, + { + getModel: () => undefined, + isIdle: () => true, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: async () => {}, + getSystemPrompt: () => [], + }, + undefined, + uiContext, + ); + }; + it("times out session_start handlers, emits an error, and continues to sibling extensions", async () => { const hangExtensionPath = path.join(tempDir.path(), "hang-session-start.ts"); const fastExtensionPath = path.join(tempDir.path(), "fast-session-start.ts"); @@ -1354,7 +1387,7 @@ describe("ExtensionRunner", () => { }); }); - it("times out tool_call handlers with fail-closed policy so a hung extension cannot indefinitely block tool execution (#3948)", async () => { + it("uses the configured tool_call timeout and fails closed so a hung extension cannot block execution (#3948)", async () => { const hangExtensionPath = path.join(tempDir.path(), "hang-tool-call.ts"); fs.writeFileSync( hangExtensionPath, @@ -1374,14 +1407,14 @@ describe("ExtensionRunner", () => { tempDir.path(), sessionManager, modelRegistry, + undefined, + Settings.isolated({ "extensionHandlers.toolCallTimeoutMs": 10 }), ); const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); const errors: Array<{ extensionPath: string; event: string; error: string }> = []; runner.onError(err => { errors.push(err); }); - testSetExtensionHandlerTimeoutMs(10); - const executeCalls: unknown[] = []; const tool: AgentTool = { name: "sleepy", @@ -1422,6 +1455,62 @@ describe("ExtensionRunner", () => { warnSpy.mockRestore(); }); + it("falls back to the default tool_call timeout for invalid configured values", async () => { + const extensionPath = path.join(tempDir.path(), "invalid-timeout-tool-call.ts"); + fs.writeFileSync( + extensionPath, + ` + export default function(pi) { + pi.on("tool_call", async () => { + await Promise.withResolvers().promise; + }); + } + `, + ); + const loaded = await loadTestExtensions([extensionPath]); + + vi.useFakeTimers(); + try { + for (const configuredTimeout of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + const runner = new ExtensionRunner( + loaded.extensions, + loaded.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + undefined, + Settings.isolated({ "extensionHandlers.toolCallTimeoutMs": configuredTimeout }), + ); + let settled = false; + const decision = runner + .emitToolCall({ + type: "tool_call", + toolName: "guarded", + toolCallId: "invalid-timeout-call", + input: {}, + }) + .then(result => { + settled = true; + return result; + }); + + vi.advanceTimersByTime(EXTENSION_HANDLER_TIMEOUT_MS - 1); + expect(settled).toBe(false); + + vi.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + vi.advanceTimersByTime(0); + expect(await decision).toEqual({ + block: true, + reason: `Extension ${extensionPath} timed out after ${EXTENSION_HANDLER_TIMEOUT_MS}ms`, + }); + } + } finally { + vi.useRealTimers(); + } + }); + it("fails closed when a tool_call handler registration cannot activate", async () => { const extensionPath = path.join(tempDir.path(), "tool-call-registration.ts"); fs.writeFileSync( @@ -1534,19 +1623,19 @@ describe("ExtensionRunner", () => { expect(errors).toEqual([]); }); - it("aborts a tool_call handler's confirmation before returning its timeout block", async () => { + it("pauses a tool_call handler timeout during standard and custom dialogs, then resumes its budget", async () => { const extensionPath = path.join(tempDir.path(), "confirm-tool-call.ts"); - const markerPath = path.join(tempDir.path(), "confirm-settled.txt"); fs.writeFileSync( extensionPath, ` - import * as fs from "node:fs"; - export default function(pi) { pi.on("tool_call", async (_event, ctx) => { ctx.ui.notify("Waiting for confirmation"); + await new Promise(resolve => setTimeout(resolve, 8)); await ctx.ui.confirm("High-risk command", "Allow this command?"); - fs.writeFileSync(${JSON.stringify(markerPath)}, "settled"); + await ctx.ui.custom(() => ({})); + ctx.ui.notify("Custom settled"); + await Promise.withResolvers().promise; }); } `, @@ -1561,66 +1650,222 @@ describe("ExtensionRunner", () => { modelRegistry, ); const dialog = Promise.withResolvers(); + const handlerStarted = Promise.withResolvers(); + const confirmationStarted = Promise.withResolvers(); + const customStarted = Promise.withResolvers(); + const customCompleted = Promise.withResolvers(); let dialogSignal: AbortSignal | undefined; - const notify = vi.fn(); + const notify: ExtensionUIContext["notify"] = message => { + if (message === "Waiting for confirmation") handlerStarted.resolve(); + if (message === "Custom settled") customCompleted.resolve(); + }; const confirm: ExtensionUIContext["confirm"] = async (_title, _message, dialogOptions) => { dialogSignal = dialogOptions?.signal; + confirmationStarted.resolve(); + dialogSignal?.addEventListener("abort", () => dialog.resolve(false), { once: true }); + return await dialog.promise; + }; + const customDialog = Promise.withResolvers(); + let customSignal: AbortSignal | undefined; + const custom: ExtensionUIContext["custom"] = async (...args: Parameters) => { + customSignal = args[1]?.signal; + await args[0](undefined as never, undefined as never, undefined as never, () => {}); + customStarted.resolve(); + await customDialog.promise; + return undefined as T; + }; + const uiPrototype = Object.create(runner.getUIContext(), { + confirm: { value: confirm }, + custom: { value: custom }, + notify: { value: notify }, + }); + const uiContext: ExtensionUIContext = Object.create(uiPrototype); + initializeRunner(runner, uiContext); + vi.useFakeTimers(); + let now = 0; + const performanceNow = vi.spyOn(performance, "now").mockImplementation(() => now); + try { + testSetExtensionHandlerTimeoutMs(25); + + const tool: AgentTool = { + name: "guarded", + label: "Guarded", + description: "must not execute after the extension gate times out", + parameters: Type.Object({}), + strict: true, + execute: async () => ({ content: [{ type: "text", text: "ran" }] }), + }; + const wrapped = new ExtensionToolWrapper(tool, runner); + + const execution = wrapped.execute("tool-call-id", {}); + await handlerStarted.promise; + expect(dialogSignal).toBeUndefined(); + + now = 8; + vi.advanceTimersByTime(8); + await confirmationStarted.promise; + expect(dialogSignal).toBeDefined(); + + now = 108; + vi.advanceTimersByTime(100); + expect(dialogSignal?.aborted).toBe(false); + + dialog.resolve(true); + await customStarted.promise; + expect(customSignal).toBeDefined(); + expect(customSignal?.aborted).toBe(false); + + now = 208; + vi.advanceTimersByTime(100); + expect(customSignal?.aborted).toBe(false); + + customDialog.resolve(); + await customCompleted.promise; + + now = 225; + vi.advanceTimersByTime(17); + await Promise.resolve(); + await Promise.resolve(); + vi.advanceTimersByTime(0); + await expect(execution).rejects.toThrow(`Extension ${extensionPath} timed out after 25ms`); + } finally { + performanceNow.mockRestore(); + vi.useRealTimers(); + } + }); + + it("charges async custom factory setup to the handler timeout until the dialog is presented", async () => { + const extensionPath = path.join(tempDir.path(), "pending-custom-factory.ts"); + fs.writeFileSync( + extensionPath, + ` + export default function(pi) { + pi.on("tool_call", async (_event, ctx) => { + await ctx.ui.custom(async () => { + ctx.ui.notify("Factory started"); + await Promise.withResolvers().promise; + }); + }); + } + `, + ); + + const result = await loadTestExtensions([extensionPath]); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const factoryStarted = Promise.withResolvers(); + const notify: ExtensionUIContext["notify"] = message => { + if (message === "Factory started") factoryStarted.resolve(); + }; + const custom: ExtensionUIContext["custom"] = async (...args: Parameters) => { + await args[0](undefined as never, undefined as never, undefined as never, () => {}); + return undefined as T; + }; + const uiPrototype = Object.create(runner.getUIContext(), { + custom: { value: custom }, + notify: { value: notify }, + }); + const uiContext: ExtensionUIContext = Object.create(uiPrototype); + initializeRunner(runner, uiContext); + vi.useFakeTimers(); + try { + testSetExtensionHandlerTimeoutMs(10); + let settled = false; + const decision = runner + .emitToolCall({ + type: "tool_call", + toolName: "guarded", + toolCallId: "pending-custom-factory", + input: {}, + }) + .then(value => { + settled = true; + return value; + }); + + await factoryStarted.promise; + vi.advanceTimersByTime(10); + for (let i = 0; i < 3; i++) await Promise.resolve(); + vi.advanceTimersByTime(0); + for (let i = 0; i < 5; i++) await Promise.resolve(); + + expect(settled).toBe(true); + expect(await decision).toEqual({ + block: true, + reason: `Extension ${extensionPath} timed out after 10ms`, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a pending confirmation and blocks tool execution when the outer dispatch aborts (#4223)", async () => { + const extensionPath = path.join(tempDir.path(), "confirm-abort-tool-call.ts"); + fs.writeFileSync( + extensionPath, + ` + export default function(pi) { + pi.on("tool_call", async (_event, ctx) => { + await ctx.ui.confirm("High-risk command", "Allow this command?"); + }); + } + `, + ); + + const result = await loadTestExtensions([extensionPath]); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + let dialogSignal: AbortSignal | undefined; + const dialog = Promise.withResolvers(); + const confirmationStarted = Promise.withResolvers(); + const confirm: ExtensionUIContext["confirm"] = async (_title, _message, dialogOptions) => { + dialogSignal = dialogOptions?.signal; + confirmationStarted.resolve(); dialogSignal?.addEventListener("abort", () => dialog.resolve(false), { once: true }); return await dialog.promise; }; const uiPrototype = Object.create(runner.getUIContext(), { confirm: { value: confirm }, - notify: { value: notify }, }); const uiContext: ExtensionUIContext = Object.create(uiPrototype); - runner.initialize( - { - sendMessage: () => {}, - sendUserMessage: () => {}, - appendEntry: () => {}, - setLabel: () => {}, - getActiveTools: () => [], - getAllTools: () => [], - setActiveTools: async () => {}, - getCommands: () => [], - setModel: async () => false, - getThinkingLevel: () => undefined, - setThinkingLevel: () => {}, - getSessionName: () => undefined, - setSessionName: async () => {}, - }, - { - getModel: () => undefined, - isIdle: () => true, - abort: () => {}, - hasPendingMessages: () => false, - shutdown: () => {}, - getContextUsage: () => undefined, - compact: async () => {}, - getSystemPrompt: () => [], - }, - undefined, - uiContext, - ); - testSetExtensionHandlerTimeoutMs(10); + initializeRunner(runner, uiContext); + let executed = false; const tool: AgentTool = { name: "guarded", label: "Guarded", - description: "must not execute after the extension gate times out", + description: "must not execute after the dispatch aborts", parameters: Type.Object({}), strict: true, - execute: async () => ({ content: [{ type: "text", text: "ran" }] }), + execute: async () => { + executed = true; + return { content: [{ type: "text", text: "ran" }] }; + }, }; const wrapped = new ExtensionToolWrapper(tool, runner); - await expect(wrapped.execute("tool-call-id", {})).rejects.toThrow( - `Extension ${extensionPath} timed out after 10ms`, - ); - expect(notify).toHaveBeenCalledWith("Waiting for confirmation"); + const controller = new AbortController(); + const execution = wrapped.execute("tool-call-id", {} as never, controller.signal); + await confirmationStarted.promise; + + expect(dialogSignal).toBeDefined(); + expect(dialogSignal?.aborted).toBe(false); + + controller.abort(); + await expect(execution).rejects.toThrow(); expect(dialogSignal?.aborted).toBe(true); - expect(fs.readFileSync(markerPath, "utf8")).toBe("settled"); + expect(executed).toBe(false); }); }); @@ -1988,54 +2233,64 @@ describe("ExtensionRunner", () => { delete globalState.__approvalEvents; }); - it("does not present approval before the tool preview is ready", async () => { - const result = await loadTestExtensions(); - const runner = new ExtensionRunner( - result.extensions, - result.runtime, - tempDir.path(), - sessionManager, - modelRegistry, - ); - const preview = Promise.withResolvers(); - const order: string[] = []; - runner.setToolApprovalPreviewWaiter(async toolCallId => { - order.push(`preview_wait:${toolCallId}`); - await preview.promise; - order.push("preview_ready"); - }); - initializeRunner( - runner, - vi.fn(async () => { - order.push("ui_select"); - return "Approve"; - }), - ); - - const wrapper = new ExtensionToolWrapper(approvalTool, runner); - const execution = (wrapper as ExtensionToolWrapper).execute("call-preview", {}, undefined, undefined, { - sessionManager, - modelRegistry, - model: undefined, - isIdle: () => true, - hasQueuedMessages: () => false, - abort: () => {}, - settings: { - get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}), - } as never, - toolCall: { - batchId: "batch-preview", - index: 0, - total: 1, - toolCalls: [{ id: "call-preview", name: "dangerous_tool" }], + it("does not present approval before canonical or wire-aliased tool previews are ready", async () => { + const cases = [ + { tool: approvalTool, wireName: "dangerous_tool", toolCallId: "call-preview" }, + { + tool: { ...approvalTool, name: "edit", customWireName: "apply_patch" }, + wireName: "apply_patch", + toolCallId: "call-aliased-preview", }, - }); - await Promise.resolve(); - expect(order).toEqual(["preview_wait:call-preview"]); + ]; + for (const { tool, wireName, toolCallId } of cases) { + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const preview = Promise.withResolvers(); + const order: string[] = []; + runner.setToolApprovalPreviewWaiter(async waitedToolCallId => { + order.push(`preview_wait:${waitedToolCallId}`); + await preview.promise; + order.push("preview_ready"); + }); + initializeRunner( + runner, + vi.fn(async () => { + order.push("ui_select"); + return "Approve"; + }), + ); - preview.resolve(); - await execution; - expect(order).toEqual(["preview_wait:call-preview", "preview_ready", "ui_select"]); + const wrapper = new ExtensionToolWrapper(tool, runner); + const execution = (wrapper as ExtensionToolWrapper).execute(toolCallId, {}, undefined, undefined, { + sessionManager, + modelRegistry, + model: undefined, + isIdle: () => true, + hasQueuedMessages: () => false, + abort: () => {}, + settings: { + get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}), + } as never, + toolCall: { + batchId: `batch-${toolCallId}`, + index: 0, + total: 1, + toolCalls: [{ id: toolCallId, name: wireName }], + }, + }); + await Promise.resolve(); + expect(order).toEqual([`preview_wait:${toolCallId}`]); + + preview.resolve(); + await execution; + expect(order).toEqual([`preview_wait:${toolCallId}`, "preview_ready", "ui_select"]); + } }); it("emits resolved false when approval is denied", async () => { diff --git a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts index 868484a40..f49748752 100644 --- a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts +++ b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts @@ -142,6 +142,33 @@ describe("InteractiveMode plan.defaultOnStartup", () => { expect(session?.getActiveToolNames()).toContain("read"); }); + it("keeps the welcome banner synchronized across startup and later model switches", async () => { + Settings.instance.set("startup.quiet", false); + const settings = Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false }); + settings.setModelRole("plan", "anthropic/claude-haiku-4-5:high"); + const created = createHarness(settings); + const initialModel = session?.model; + if (!initialModel) throw new Error("Expected initial model"); + + await created.init({ suppressWelcomeIntro: true }); + + const planModel = session?.model; + if (!planModel) throw new Error("Expected plan model"); + expect(planModel.id).toBe("claude-haiku-4-5"); + const rendered = Bun.stripANSI(created.ui.render(120).join("\n")); + expect(rendered).toContain(planModel.name); + expect(rendered).not.toContain(initialModel.name); + + const requestRenderSpy = vi.spyOn(created.ui, "requestRender"); + requestRenderSpy.mockClear(); + await session!.setModel(initialModel); + + expect(requestRenderSpy).toHaveBeenCalled(); + const switched = Bun.stripANSI(created.ui.render(120).join("\n")); + expect(switched).toContain(initialModel.name); + expect(switched).not.toContain(planModel.name); + }); + it("activates write when entering plan mode even if it was hidden by discoveryMode (issue #3165)", async () => { // `plan-mode-active.md` instructs the agent to draft the plan file with // `write` and refine it with `edit`. Under `tools.discoveryMode === "all"` diff --git a/packages/coding-agent/test/interactive-mode-editor-component.test.ts b/packages/coding-agent/test/interactive-mode-editor-component.test.ts index 5599d7096..85bea4906 100644 --- a/packages/coding-agent/test/interactive-mode-editor-component.test.ts +++ b/packages/coding-agent/test/interactive-mode-editor-component.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, setSystemTime, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -9,10 +9,67 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TUI } from "@oh-my-pi/pi-tui"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { StressRenderScheduler } from "../../tui/test/render-stress-scheduler"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal"; class TestModalEditor extends CustomEditor {} +class RenderCountingTUI extends TUI { + renderCount = 0; + + override render(width: number): readonly string[] { + this.renderCount++; + return super.render(width); + } +} + +async function expectTwoDirectShimmerFrames( + tui: RenderCountingTUI, + terminal: VirtualTerminal, + writes: readonly string[], + keyword: string, +): Promise { + const renderCount = tui.renderCount; + const viewport = terminal.getViewport().map(row => Bun.stripANSI(row).trimEnd()); + const bufferPosition = terminal.getBufferPosition(); + const scrollback = terminal + .getScrollBuffer() + .slice(0, bufferPosition.baseY) + .map(row => Bun.stripANSI(row).trimEnd()); + const cursor = terminal.getCursor(); + const writesBeforeFirstPhase = writes.length; + + expect(viewport.join("\n")).toContain(keyword); + expect(bufferPosition.baseY).toBeGreaterThan(0); + + vi.advanceTimersByTime(CustomEditor.SHIMMER_FRAME_MS); + await terminal.flush(); + const firstPhaseWrites = writes.slice(writesBeforeFirstPhase); + expect(firstPhaseWrites.length).toBeGreaterThan(0); + expect(firstPhaseWrites.join("")).toContain("\x1b[38"); + + const writesBeforeSecondPhase = writes.length; + vi.advanceTimersByTime(CustomEditor.SHIMMER_FRAME_MS); + await terminal.flush(); + const secondPhaseWrites = writes.slice(writesBeforeSecondPhase); + expect(secondPhaseWrites.length).toBeGreaterThan(0); + expect(secondPhaseWrites.join("")).toContain("\x1b[38"); + expect(secondPhaseWrites.join("")).not.toBe(firstPhaseWrites.join("")); + + expect(tui.renderCount).toBe(renderCount); + expect(terminal.getViewport().map(row => Bun.stripANSI(row).trimEnd())).toEqual(viewport); + expect(terminal.getBufferPosition()).toEqual(bufferPosition); + expect( + terminal + .getScrollBuffer() + .slice(0, bufferPosition.baseY) + .map(row => Bun.stripANSI(row).trimEnd()), + ).toEqual(scrollback); + expect(terminal.getCursor()).toEqual(cursor); +} + describe("InteractiveMode.setEditorComponent", () => { let tempDir: TempDir; let authStorage: AuthStorage; @@ -73,4 +130,51 @@ describe("InteractiveMode.setEditorComponent", () => { expect(mode.editor.onEscape).toBeDefined(); expect(refreshSpy).toHaveBeenCalled(); }); + + it("direct-writes focused shimmer frames without disturbing terminal state before or after replacement", async () => { + const terminal = new VirtualTerminal(80, 8, 1_000); + terminal.write(Array.from({ length: 12 }, (_unused, index) => `seed-${index}\r\n`).join("")); + const writes: string[] = []; + const write = terminal.write.bind(terminal); + vi.spyOn(terminal, "write").mockImplementation((data: string) => { + writes.push(data); + write(data); + }); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(terminal, true, { renderScheduler: scheduler }); + const initialEditor = mode.editor; + let replacementEditor: CustomEditor | undefined; + + vi.spyOn(mode, "refreshSlashCommandState").mockResolvedValue(); + vi.useFakeTimers(); + setSystemTime(0); + try { + mode.ui = tui; + initialEditor.setUseTerminalCursor(true); + initialEditor.magicKeywordsEnabledOverride = true; + initialEditor.setText("please orchestrate this draft"); + tui.addChild(mode.editorContainer); + tui.setFocus(initialEditor); + tui.start(); + await scheduler.drain(terminal); + + await expectTwoDirectShimmerFrames(tui, terminal, writes, "orchestrate"); + initialEditor.setShimmerRepaintHandler(undefined); + + mode.setEditorComponent((_tui, editorTheme) => new TestModalEditor(editorTheme)); + replacementEditor = mode.editor; + replacementEditor.magicKeywordsEnabledOverride = true; + replacementEditor.setText("please workflowz this draft"); + await scheduler.drain(terminal); + + await expectTwoDirectShimmerFrames(tui, terminal, writes, "workflowz"); + } finally { + initialEditor.setShimmerRepaintHandler(undefined); + replacementEditor?.setShimmerRepaintHandler(undefined); + tui.stop(); + await terminal.flush(); + vi.useRealTimers(); + setSystemTime(); + } + }); }); diff --git a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts index de6d15781..ab166c6e7 100644 --- a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -127,4 +128,24 @@ describe("InteractiveMode LSP startup welcome banner", () => { } satisfies LspStartupEvent); expect(showWarningSpy).not.toHaveBeenCalled(); }); + + it("surfaces a sanitized warning when session persistence fails", async () => { + await mode.init(); + await session.sessionManager.ensureOnDisk(); + const showWarning = vi.spyOn(mode, "showWarning").mockImplementation(() => {}); + const writeFailure = vi.spyOn(fs, "writeSync").mockImplementation(() => { + throw Object.assign(new Error("ENOSPC:\tdisk full\n\u001b[31mretry later\u001b[0m"), { code: "ENOSPC" }); + }); + session.sessionManager.appendCustomEntry("persistence-failure-probe", {}); + + expect(showWarning).toHaveBeenCalledTimes(1); + const warning = showWarning.mock.calls[0]?.[0] ?? ""; + expect(warning).toContain("Session persistence failed: ENOSPC:"); + expect(warning).toContain("Unsaved entries remain in memory"); + expect(warning).not.toContain("\t"); + expect(warning).not.toContain("\n"); + expect(warning).not.toContain("\u001b"); + writeFailure.mockRestore(); + session.sessionManager.appendCustomEntry("persistence-recovery-probe", {}); + }); }); diff --git a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts index 7e9a60258..ed0612c81 100644 --- a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts +++ b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts @@ -10,7 +10,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; -import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; +import { Agent, type AgentTool, type StreamFn } from "@oh-my-pi/pi-agent-core"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; @@ -23,7 +24,7 @@ import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; +import { createAssistantMessage, createInMemoryAuthStorage } from "./helpers/agent-session-setup"; function stubTool(name: string): AgentTool { return { @@ -59,18 +60,14 @@ class ExitFaultStorage extends FileSessionStorage { return { started: started.promise, release: release.resolve }; } - override async readTextSlices( - filePath: string, - prefixBytes: number, - suffixBytes: number, - ): Promise<[string, string]> { + override async readText(filePath: string): Promise { const gate = this.#readGate; if (gate?.filePath === filePath) { this.#readGate = undefined; gate.started.resolve(); await gate.release.promise; } - return super.readTextSlices(filePath, prefixBytes, suffixBytes); + return super.readText(filePath); } override async writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise { @@ -86,6 +83,7 @@ describe("InteractiveMode vibe mode toggle", () => { let tempDir: TempDir; let authStorage: AuthStorage; let session: AgentSession; + let streamFn: StreamFn | undefined; let mode: InteractiveMode; let modelRegistry: ModelRegistry; let storage: ExitFaultStorage; @@ -103,6 +101,9 @@ describe("InteractiveMode vibe mode toggle", () => { await Settings.init({ inMemory: true, cwd: tempDir.path() }); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); + // prompt() preflights credentials via modelRegistry.getApiKey; the + // in-memory auth storage has no anthropic key, so stub it. + vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); const registryTools = [stubTool("read"), stubTool("todo")]; storage = new ExitFaultStorage(); @@ -114,6 +115,10 @@ describe("InteractiveMode vibe mode toggle", () => { tools: [], messages: [], }, + streamFn: (...args) => { + if (!streamFn) throw new Error("No test stream configured"); + return streamFn(...args); + }, }), sessionManager: SessionManager.create(tempDir.path(), tempDir.path(), storage), settings: Settings.isolated({}), @@ -161,6 +166,151 @@ describe("InteractiveMode vibe mode toggle", () => { expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo"]); }); + it("cancels an in-flight model turn before removing Vibe tools", async () => { + const started = Promise.withResolvers(); + streamFn = (_model, _context, options) => { + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + options?.signal?.addEventListener( + "abort", + () => stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }), + { once: true }, + ); + started.resolve(); + }); + return stream; + }; + await mode.handleVibeModeCommand(); + const prompt = session.prompt("Delegate this"); + await started.promise; + expect(session.isStreaming).toBe(true); + + await mode.handleVibeModeCommand(); + await prompt; + + expect(session.isStreaming).toBe(false); + expect(session.getToolByName("vibe_spawn")).toBeUndefined(); + }); + + it("holds a user steer queued during Vibe teardown until the tools are removed", async () => { + const toolNamesPerCall: string[][] = []; + const firstStarted = Promise.withResolvers(); + streamFn = (_model, context, options) => { + toolNamesPerCall.push((context.tools ?? []).map(tool => tool.name)); + const isFirst = toolNamesPerCall.length === 1; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + if (isFirst) { + options?.signal?.addEventListener( + "abort", + () => stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }), + { once: true }, + ); + firstStarted.resolve(); + } else { + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Resumed") }); + } + }); + return stream; + }; + + await mode.handleVibeModeCommand(); + const prompt = session.prompt("Delegate this"); + await firstStarted.promise; + + const abortSettled = Promise.withResolvers(); + const releaseTeardown = Promise.withResolvers(); + const abort = session.abort.bind(session); + vi.spyOn(session, "abort").mockImplementation(async options => { + await abort(options); + abortSettled.resolve(); + await releaseTeardown.promise; + }); + const exit = mode.handleVibeModeCommand(); + await abortSettled.promise; + // Queue while teardown is still guarded. The regular queue path clears its + // retry block, but must not clear the independent mode-exit suppression. + await session.steer("and then do the other thing"); + // Drain the microtasks in which an unguarded schedule calls + // agent.continue(). The queued steer must remain owned by the queue until + // teardown releases. + for (let index = 0; index < 5; index++) await Promise.resolve(); + expect(session.agent.peekSteeringQueue()).toHaveLength(1); + expect(toolNamesPerCall.length).toBe(1); + releaseTeardown.resolve(); + + await exit; + await prompt; + await session.waitForIdle(); + + expect(toolNamesPerCall.length).toBe(2); + for (const name of VIBE_TOOL_NAMES) { + expect(toolNamesPerCall[1]).not.toContain(name); + } + expect(session.getVibeModeState()).toBeUndefined(); + expect(session.getToolByName("vibe_spawn")).toBeUndefined(); + }); + + it("holds IRC wakes during Vibe teardown until the tools are removed", async () => { + const toolNamesPerCall: string[][] = []; + const firstStarted = Promise.withResolvers(); + streamFn = (_model, context, options) => { + toolNamesPerCall.push((context.tools ?? []).map(tool => tool.name)); + const isFirst = toolNamesPerCall.length === 1; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + if (isFirst) { + options?.signal?.addEventListener( + "abort", + () => stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }), + { once: true }, + ); + firstStarted.resolve(); + } else { + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Resumed") }); + } + }); + return stream; + }; + + await mode.handleVibeModeCommand(); + const prompt = session.prompt("Delegate this"); + await firstStarted.promise; + await session.deliverIrcMessage({ id: "m1", from: "peer", to: "me", body: "first", ts: Date.now() }); + + const abortSettled = Promise.withResolvers(); + const releaseTeardown = Promise.withResolvers(); + const abort = session.abort.bind(session); + vi.spyOn(session, "abort").mockImplementation(async options => { + await abort(options); + abortSettled.resolve(); + await releaseTeardown.promise; + }); + const exit = mode.handleVibeModeCommand(); + await abortSettled.promise; + await session.deliverIrcMessage({ id: "m2", from: "peer", to: "me", body: "second", ts: Date.now() }); + for (let index = 0; index < 5; index++) await Promise.resolve(); + expect(toolNamesPerCall).toHaveLength(1); + releaseTeardown.resolve(); + + await exit; + await prompt; + await session.waitForIdle(); + + expect(toolNamesPerCall).toHaveLength(2); + for (const name of VIBE_TOOL_NAMES) { + expect(toolNamesPerCall[1]).not.toContain(name); + } + expect( + session.agent.state.messages.filter( + message => message.role === "custom" && message.customType === "irc:incoming", + ), + ).toHaveLength(2); + }); + it("keeps a same-named non-built-in Todo tool unavailable in Vibe mode", async () => { const model = session.model; if (!model) throw new Error("Expected active model"); diff --git a/packages/coding-agent/test/interactive-mode-working-accent.test.ts b/packages/coding-agent/test/interactive-mode-working-accent.test.ts index 93f648948..b72706447 100644 --- a/packages/coding-agent/test/interactive-mode-working-accent.test.ts +++ b/packages/coding-agent/test/interactive-mode-working-accent.test.ts @@ -97,6 +97,7 @@ describe("InteractiveMode working-message session accent cache", () => { // the animating loader out of immutable native scrollback. startStableLoader(mode); expect(statusContainer.getNativeScrollbackLiveRegionStart()).toBe(0); + expect(statusContainer.isNativeScrollbackLiveRegionPinned?.()).toBe(true); }); it("reuses one computed accent across loader spinner and message colorizers", async () => { diff --git a/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts b/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts index 2105882e8..dbf8cd186 100644 --- a/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts +++ b/packages/coding-agent/test/issue-2750-subagent-runtime-fallback.test.ts @@ -49,6 +49,7 @@ function createYieldingSession(fallback: "served" | "unproven" = "served"): Agen getEnabledToolNames: () => ["yield"], setActiveToolsByName: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, subscribe: (listener: (event: { type: string; [key: string]: unknown }) => void) => { listeners.push(listener); return () => {}; diff --git a/packages/coding-agent/test/issue-905-repro.test.ts b/packages/coding-agent/test/issue-905-repro.test.ts index 63aea3398..af8378b69 100644 --- a/packages/coding-agent/test/issue-905-repro.test.ts +++ b/packages/coding-agent/test/issue-905-repro.test.ts @@ -14,15 +14,18 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { AuthStorage } from "@oh-my-pi/pi-ai"; import { runModelsListing } from "@oh-my-pi/pi-coding-agent/cli/models-cli"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; let tmp: TempDir; let extPath: string; let explicitPackagePath: string; let ambientExtPath: string; +let ambientHookMarkerPath: string; +let configuredHookMarkerPath: string; let dbPath: string; let shutdownExtPath: string; let shutdownPath: string; @@ -64,11 +67,28 @@ beforeAll(async () => { ); explicitPackagePath = tmp.join("explicit-package"); ambientExtPath = tmp.join("ambient.ts"); + ambientHookMarkerPath = tmp.join("ambient-hook-loaded"); + configuredHookMarkerPath = tmp.join("configured-hook-loaded"); await fs.mkdir(tmp.join("explicit-package", "src"), { recursive: true }); + await fs.mkdir(tmp.join("explicit-package", "hooks", "pre"), { recursive: true }); + const hookDir = path.join(getProjectAgentDir(tmp.path()), "hooks", "pre"); + await fs.mkdir(hookDir, { recursive: true }); + await fs.writeFile( + path.join(hookDir, "models-poison.ts"), + `await Bun.write(${JSON.stringify(ambientHookMarkerPath)}, "loaded"); +export default function () {} +`, + ); await fs.writeFile( tmp.join("explicit-package", "package.json"), JSON.stringify({ name: "explicit-package", omp: { extensions: ["./src/main.ts"] } }), ); + await fs.writeFile( + tmp.join("explicit-package", "hooks", "pre", "models-poison.ts"), + `await Bun.write(${JSON.stringify(configuredHookMarkerPath)}, "loaded"); +export default function () {} +`, + ); await fs.writeFile( tmp.join("explicit-package", "src", "main.ts"), `export default function (pi) { @@ -147,6 +167,38 @@ test("omp models surfaces extension-registered providers (issue #905)", async () } }); +test("omp models does not execute ambient hooks while retaining explicit providers", async () => { + const authStorage = await AuthStorage.create(":memory:"); + try { + const modelRegistry = new ModelRegistry(authStorage); + const captured: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + captured.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stdout.write; + + try { + await runModelsListing({ + modelRegistry, + cwd: tmp.path(), + action: "ls", + additionalExtensionPaths: [explicitPackagePath], + }); + } finally { + process.stdout.write = originalWrite; + } + + const output = captured.join(""); + expect(output).toContain("explicit-gw"); + expect(output).toContain("explicit-model"); + expect(await Bun.file(ambientHookMarkerPath).exists()).toBe(false); + expect(await Bun.file(configuredHookMarkerPath).exists()).toBe(false); + } finally { + authStorage.close(); + } +}); + test("omp models emits extension shutdown after listing (issue #6297)", async () => { const authStorage = await AuthStorage.create(":memory:"); try { diff --git a/packages/coding-agent/test/job-renderer-preview.test.ts b/packages/coding-agent/test/job-renderer-preview.test.ts index 24bec5a56..499097787 100644 --- a/packages/coding-agent/test/job-renderer-preview.test.ts +++ b/packages/coding-agent/test/job-renderer-preview.test.ts @@ -240,7 +240,7 @@ describe("job renderer task-result preview", () => { details: { op: "jobs" as const, jobs: [], - agents: [{ id: "Worker", parentId: "Main", activity: "grepping the tree", ageMs: 65_000 }], + agents: [{ id: "Worker", parentId: "Main", activity: "grepping the tree", ageMs: 65_000, live: true }], }, }; const component = hubToolRenderer.renderResult( @@ -258,7 +258,7 @@ describe("job renderer task-result preview", () => { it("keeps a sealed bare-poll result visible when it carries an agent roster", () => { const result = { content: [{ type: "text" as const, text: "No running background jobs to wait for." }], - details: { op: "wait" as const, jobs: [], agents: [{ id: "Worker", ageMs: 1_000 }] }, + details: { op: "wait" as const, jobs: [], agents: [{ id: "Worker", ageMs: 1_000, live: false }] }, }; const component = hubToolRenderer.renderResult( result, @@ -268,6 +268,9 @@ describe("job renderer task-result preview", () => { ); const output = Bun.stripANSI((component.render(120) as readonly string[]).join("\n")); expect(output).toContain("Worker"); + // A ref claiming `running` with no turn in flight is flagged, not shown + // as live work. + expect(output).toContain("no turn"); }); }); }); diff --git a/packages/coding-agent/test/launch/daemon-prune.test.ts b/packages/coding-agent/test/launch/daemon-prune.test.ts new file mode 100644 index 000000000..7ca02286e --- /dev/null +++ b/packages/coding-agent/test/launch/daemon-prune.test.ts @@ -0,0 +1,80 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { pruneDeadDaemonRuntimeDirs } from "../../src/launch/presence"; + +const STALE = new Date(Date.now() - 30 * 60_000); +let deadPid = 0; + +async function scope( + root: string, + name: string, + init: { pid?: number | "dead"; clients?: number[]; stale?: boolean }, +): Promise { + const dir = path.join(root, name); + await fs.mkdir(path.join(dir, "clients"), { recursive: true }); + if (init.pid !== undefined) { + const pid = init.pid === "dead" ? deadPid : init.pid; + await Bun.write(path.join(dir, "broker.pid"), JSON.stringify({ pid, instanceId: name })); + } + for (const clientPid of init.clients ?? []) { + await Bun.write( + path.join(dir, "clients", `${clientPid}-x.json`), + JSON.stringify({ pid: clientPid, id: `${clientPid}-x`, projectDir: dir }), + ); + } + if (init.stale) await fs.utimes(dir, STALE, STALE); + return dir; +} + +describe("pruneDeadDaemonRuntimeDirs", () => { + beforeAll(async () => { + // A definitely-dead PID: spawn a process and reap it. + const proc = Bun.spawn(["true"]); + await proc.exited; + deadPid = proc.pid; + }); + + it("removes only scopes with a dead broker, no live clients, and past the stale grace", async () => { + using tempDir = TempDir.createSync("@omp-daemon-prune-"); + const daemons = path.join(tempDir.path(), "run", "daemons"); + await fs.mkdir(daemons, { recursive: true }); + + const current = await scope(daemons, "current000000000", { pid: "dead", stale: true }); + await scope(daemons, "deadstale0000000", { pid: "dead", stale: true }); + await scope(daemons, "livebroker000000", { pid: process.pid, stale: true }); + await scope(daemons, "liveclient000000", { clients: [process.pid], stale: true }); + await scope(daemons, "deadfresh0000000", { pid: "dead" }); + // Machine-global daemon container must never be swept as a project scope. + await fs.mkdir(path.join(daemons, "global", "some-service"), { recursive: true }); + await fs.utimes(path.join(daemons, "global"), STALE, STALE); + + await pruneDeadDaemonRuntimeDirs(current); + + const remaining = new Set(await fs.readdir(daemons)); + expect(remaining.has("deadstale0000000")).toBe(false); // pruned + expect(remaining.has("current000000000")).toBe(true); // never prunes itself + expect(remaining.has("livebroker000000")).toBe(true); // live broker + expect(remaining.has("liveclient000000")).toBe(true); // live client presence + expect(remaining.has("deadfresh0000000")).toBe(true); // within stale grace + expect(remaining.has("global")).toBe(true); // global container skipped + }); + + it("does not sweep sibling machine-global service runtimes", async () => { + using tempDir = TempDir.createSync("@omp-daemon-prune-global-"); + const globalRoot = path.join(tempDir.path(), "run", "daemons", "global"); + const current = await scope(globalRoot, "current-service", { pid: "dead", stale: true }); + const sibling = await scope(globalRoot, "persistent-service", { pid: "dead", stale: true }); + + await pruneDeadDaemonRuntimeDirs(current); + + expect(await fs.exists(sibling)).toBe(true); + }); + + it("does nothing when the runtime root does not exist", async () => { + using tempDir = TempDir.createSync("@omp-daemon-prune-missing-"); + const current = path.join(tempDir.path(), "run", "daemons", "hash0000000000000"); + await expect(pruneDeadDaemonRuntimeDirs(current)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/lsp/idle.test.ts b/packages/coding-agent/test/lsp/idle.test.ts new file mode 100644 index 000000000..bb68836c9 --- /dev/null +++ b/packages/coding-agent/test/lsp/idle.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "bun:test"; +import { isIdleClient, sendRequest } from "@oh-my-pi/pi-coding-agent/lsp/client"; +import type { LspClient } from "@oh-my-pi/pi-coding-agent/lsp/types"; + +const IDLE_TIMEOUT_MS = 60_000; + +/** + * Minimal in-memory LSP client. `stdin` is a sink so `sendRequest` can register + * a pending request without a real server process; nothing ever answers, so the + * request stays in flight until the test settles it by hand. The rest of `proc` + * mirrors the shape the other hand-built clients in this repo use, so any + * teardown path that runs is looking at a whole process, not a hole. + */ +function makeClient(): LspClient { + return { + name: "test-lsp", + cwd: process.cwd(), + config: { command: "test-lsp", fileTypes: [".ts"], rootMarkers: [] }, + proc: { + exited: new Promise(() => {}), + exitCode: null, + stdin: { + write(chunk: string | Uint8Array) { + return typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.byteLength; + }, + flush: () => 0, + }, + stdout: new ReadableStream(), + peekStderr: () => "", + kill() {}, + } as unknown as LspClient["proc"], + requestId: 0, + diagnostics: new Map(), + diagnosticsVersion: 0, + openFiles: new Map(), + pendingRequests: new Map(), + messageBuffer: new Uint8Array(), + isReading: false, + status: "ready", + lastActivity: Date.now(), + writeQueue: Promise.resolve(), + activeProgressTokens: new Set(), + projectLoaded: Promise.resolve(), + resolveProjectLoaded: () => {}, + }; +} + +/** + * Settle a pending request the way the message reader does: it removes the + * entry from `pendingRequests` *before* invoking the stored resolver, so the + * map drains on the reader's side, not inside the resolver. + */ +function settle(client: LspClient, id: number, outcome: { value: unknown } | { error: Error }): void { + const entry = client.pendingRequests.get(id); + if (!entry) throw new Error(`no pending request with id ${id}`); + client.pendingRequests.delete(id); + if ("error" in outcome) entry.reject(outcome.error); + else entry.resolve(outcome.value); +} + +describe("idle checker (#8390)", () => { + it("never reports a client with an in-flight request as idle", async () => { + const client = makeClient(); + const pending = sendRequest(client, "textDocument/hover", {}, undefined, 10 * IDLE_TIMEOUT_MS); + // One clock read drives both the client's timestamp and every sweep below. + // Re-reading `Date.now()` per assertion would make the margins depend on + // how long the test itself took, which a loaded CI runner does not bound. + const now = Date.now(); + // The request was *sent* long ago and is still outstanding: exactly the + // shape that used to be torn down mid-flight. + client.lastActivity = now - 10 * IDLE_TIMEOUT_MS; + + expect(client.pendingRequests.size).toBe(1); + + // Control: the previous checker was `now - lastActivity > timeoutMs` with + // no in-flight check, so this exact client was reapable — the request + // below would have been rejected with "LSP client shutdown" mid-flight. + // Keep both assertions together so a regression that drops the guard + // fails loudly instead of silently agreeing with the old behaviour. + expect(now - client.lastActivity > IDLE_TIMEOUT_MS).toBe(true); + expect(isIdleClient(client, now, IDLE_TIMEOUT_MS)).toBe(false); + // Not even an arbitrarily distant sweep may reap it while work is in flight. + expect(isIdleClient(client, now + 24 * 60 * 60_000, IDLE_TIMEOUT_MS)).toBe(false); + + settle(client, 1, { value: { contents: "ok" } }); + await expect(pending).resolves.toEqual({ contents: "ok" }); + }); + + it("reports a quiet client as idle once the window elapses", () => { + const client = makeClient(); + const now = Date.now(); + client.lastActivity = now - 10 * IDLE_TIMEOUT_MS; + + expect(client.pendingRequests.size).toBe(0); + expect(isIdleClient(client, now, IDLE_TIMEOUT_MS)).toBe(true); + }); + + it("keeps a client below the idle window alive", () => { + const client = makeClient(); + // Both the stamp and the sweep come from one clock read: the boundary + // case is exactly `IDLE_TIMEOUT_MS - 1`, so even 2ms of scheduling drift + // between two `Date.now()` calls would push the sweep past the window and + // flip this assertion on a busy runner. + const now = Date.now(); + client.lastActivity = now; + + expect(isIdleClient(client, now + IDLE_TIMEOUT_MS - 1, IDLE_TIMEOUT_MS)).toBe(false); + // The far side of the same boundary, from the same clock read. + expect(isIdleClient(client, now + IDLE_TIMEOUT_MS + 1, IDLE_TIMEOUT_MS)).toBe(true); + }); + + it("refreshes lastActivity when a request settles so a fresh answer is not reaped", async () => { + const client = makeClient(); + const pending = sendRequest(client, "textDocument/hover", {}, undefined, 10 * IDLE_TIMEOUT_MS); + const sentAt = Date.now() - 10 * IDLE_TIMEOUT_MS; + client.lastActivity = sentAt; + + settle(client, 1, { value: { contents: "ok" } }); + await expect(pending).resolves.toEqual({ contents: "ok" }); + + // The map has drained, so the pending-request guard no longer applies — + // only the refreshed timestamp stands between a just-answered client and + // the next sweep. + expect(client.pendingRequests.size).toBe(0); + expect(client.lastActivity).toBeGreaterThan(sentAt); + expect(isIdleClient(client, Date.now(), IDLE_TIMEOUT_MS)).toBe(false); + // It does become eligible again once the full window passes with no work. + expect(isIdleClient(client, client.lastActivity + IDLE_TIMEOUT_MS + 1, IDLE_TIMEOUT_MS)).toBe(true); + }); + + it("refreshes lastActivity when a request settles with an error", async () => { + const client = makeClient(); + const pending = sendRequest(client, "textDocument/hover", {}, undefined, 10 * IDLE_TIMEOUT_MS); + const sentAt = Date.now() - 10 * IDLE_TIMEOUT_MS; + client.lastActivity = sentAt; + + settle(client, 1, { error: new Error("LSP error -32601: Unknown request") }); + await expect(pending).rejects.toThrow(/-32601/); + + expect(client.pendingRequests.size).toBe(0); + expect(client.lastActivity).toBeGreaterThan(sentAt); + expect(isIdleClient(client, Date.now(), IDLE_TIMEOUT_MS)).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/marketplace/manager.test.ts b/packages/coding-agent/test/marketplace/manager.test.ts index 1097ee250..1f608832b 100644 --- a/packages/coding-agent/test/marketplace/manager.test.ts +++ b/packages/coding-agent/test/marketplace/manager.test.ts @@ -9,6 +9,8 @@ import { PluginManager } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/m import { MarketplaceManager, readInstalledPluginsRegistry, + readMarketplacesRegistry, + writeMarketplacesRegistry, } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; import * as piUtils from "@oh-my-pi/pi-utils"; import { removeSyncWithRetries } from "@oh-my-pi/pi-utils"; @@ -168,6 +170,34 @@ describe("MarketplaceManager", () => { expect(new Date(updated.updatedAt) >= new Date(added.addedAt)).toBe(true); }); + it("updateMarketplace expands a home-relative catalog path", async () => { + const fakeHome = fs.mkdtempSync(path.join(ctx.tmpDir, "home-")); + const homedirSpy = spyOn(os, "homedir").mockReturnValue(fakeHome); + const registryPath = path.join(ctx.tmpDir, "marketplaces.json"); + const originalCwd = process.cwd(); + const cwd = path.join(ctx.tmpDir, "cwd"); + + try { + const added = await ctx.manager.addMarketplace(FIXTURE_DIR); + const catalogPath = "~/.omp/plugins/cache/marketplaces/test-marketplace/marketplace.json"; + const registry = await readMarketplacesRegistry(registryPath); + await writeMarketplacesRegistry(registryPath, { + ...registry, + marketplaces: [{ ...added, catalogPath }], + }); + fs.mkdirSync(cwd); + process.chdir(cwd); + + await ctx.manager.updateMarketplace("test-marketplace"); + + expect(fs.existsSync(path.join(fakeHome, catalogPath.slice(2)))).toBe(true); + expect(fs.existsSync(path.join(cwd, catalogPath))).toBe(false); + } finally { + process.chdir(originalCwd); + homedirSpy.mockRestore(); + } + }); + // ── Plugin discovery ─────────────────────────────────────────────────── it("listAvailablePlugins → returns catalog entries", async () => { diff --git a/packages/coding-agent/test/mcp-exa-filter.test.ts b/packages/coding-agent/test/mcp-exa-filter.test.ts new file mode 100644 index 000000000..cb36019f9 --- /dev/null +++ b/packages/coding-agent/test/mcp-exa-filter.test.ts @@ -0,0 +1,79 @@ +/** + * Regression: Exa MCP servers are filtered out by default because the native + * Exa integration covers `web_search_exa`. But a config that explicitly + * requests Exa tools the native integration does NOT provide (e.g. + * `web_fetch_exa`, `web_search_advanced_exa`) must stay mounted as an MCP + * server instead of being dropped. + */ +import { describe, expect, test } from "bun:test"; +import type { SourceMeta } from "@oh-my-pi/pi-coding-agent/capability/types"; +import { filterExaMCPServers } from "@oh-my-pi/pi-coding-agent/mcp/config"; +import type { MCPServerConfig } from "@oh-my-pi/pi-coding-agent/mcp/types"; + +const SOURCE: SourceMeta = { + provider: "test", + providerName: "Test", + path: "/tmp/mcp.json", + level: "user", +}; + +describe("Exa MCP filtering", () => { + test("filters an exa server restricted to the native web_search_exa tool", () => { + const configs: Record = { + exa: { type: "http", url: "https://mcp.exa.ai/mcp?tools=web_search_exa&exaApiKey=sk-1" }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(result.configs).toEqual({}); + expect(result.exaApiKeys).toEqual(["sk-1"]); + }); + + test("keeps an exa server that requests tools beyond the native integration", () => { + const configs: Record = { + exa: { + type: "http", + url: "https://mcp.exa.ai/mcp?tools=web_search_exa,web_fetch_exa,web_search_advanced_exa&exaApiKey=sk-1", + }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(Object.keys(result.configs)).toEqual(["exa"]); + expect(result.sources.exa).toEqual(SOURCE); + expect(result.exaApiKeys).toEqual(["sk-1"]); + }); + + test("filters an exa server with no tools restriction", () => { + const configs: Record = { + exa: { type: "http", url: "https://mcp.exa.ai/mcp" }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(result.configs).toEqual({}); + }); + + test("keeps a stdio exa server that requests extra tools", () => { + const configs: Record = { + exa: { + type: "stdio", + command: "npx", + args: ["-y", "exa-mcp-server", "--tools=web_search_exa,web_fetch_exa"], + }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(Object.keys(result.configs)).toEqual(["exa"]); + }); + + test("keeps a stdio exa server with a separate tools argument", () => { + const configs: Record = { + exa: { + type: "stdio", + command: "npx", + args: ["-y", "exa-mcp-server", "--tools", "web_search_exa,web_fetch_exa"], + }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(Object.keys(result.configs)).toEqual(["exa"]); + }); +}); diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index d98f84d44..25073fd13 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -1158,7 +1158,7 @@ describe("ModelRegistry runtime discovery", () => { const plain = registry.find("llama.cpp", "llama-3.1-8b"); expect(plain?.reasoning).toBe(false); expect(plain?.api).toBe("openai-responses"); - expect(plain?.baseUrl).toBe("http://127.0.0.1:8080"); + expect(plain?.baseUrl).toBe("http://127.0.0.1:8080/v1"); expect((plain?.compat as DialectFields | undefined)?.reasoningDisableMode).not.toBe("qwen-template-false"); }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index cb31133a1..ba4d5c121 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1056,7 +1056,9 @@ describe("ModelRegistry", () => { const model = registry.find("custom-local", "gpt-5.4"); expect(model?.contextWindow).toBe(1_000_000); - expect(model?.baseUrl).toBe("http://127.0.0.1:8080"); + // llama.cpp discovery probes the bare root (`/models`, `/props`); chat + // traffic must go to the OpenAI-compatible `/v1` prefix. + expect(model?.baseUrl).toBe("http://127.0.0.1:8080/v1"); }); test("discoverable custom compat survives refresh", async () => { diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index f8f665441..6d0e2d86d 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -437,6 +437,36 @@ describe("pickDefaultAvailableModel", () => { expect(result?.provider).toBe("zhipu-coding-plan"); expect(result?.id).toBe("glm-5.1"); }); + + test("prefers SuperGrok over paid xAI when both defaults are present", () => { + const paid = buildModel({ + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500000, + maxTokens: 500000, + }); + const oauth = buildModel({ + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-responses", + provider: "xai-oauth", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 500000, + maxTokens: 500000, + }); + + expect(pickDefaultAvailableModel([paid, oauth])?.provider).toBe("xai-oauth"); + expect(pickDefaultAvailableModel([paid])?.provider).toBe("xai"); + }); }); describe("parseModelPattern", () => { diff --git a/packages/coding-agent/test/modes/components/ask-dialog.test.ts b/packages/coding-agent/test/modes/components/ask-dialog.test.ts index aa5251d59..a4056e1ee 100644 --- a/packages/coding-agent/test/modes/components/ask-dialog.test.ts +++ b/packages/coding-agent/test/modes/components/ask-dialog.test.ts @@ -187,7 +187,7 @@ describe("AskDialogComponent", () => { ]); }); - it("multi-select: Space and Enter both toggle without advancing; Submit tab confirms", () => { + it("multi-select: Space toggles options; Enter submits the current selection", () => { const onSubmit = vi.fn(); const onCancel = vi.fn(); const onPrompt = vi.fn(); @@ -207,22 +207,24 @@ describe("AskDialogComponent", () => { onPrompt, }); - // Space on Option A - toggles without advancing + // Space toggles without submitting. component.handleInput(SPACE); expect(onSubmit).not.toHaveBeenCalled(); - // Down to Option B, Enter - toggles B, still no submit and no movement - component.handleInput(DOWN); - component.handleInput(ENTER); + // Space again toggles the same option back off. + component.handleInput(SPACE); expect(onSubmit).not.toHaveBeenCalled(); - // Tab to the Submit tab (present even for a single multi question), - // Enter confirms the selection. - component.handleInput(TAB); + // Space once more re-selects it. + component.handleInput(SPACE); + expect(onSubmit).not.toHaveBeenCalled(); + + // Enter submits the current selection without toggling the focused + // option (issue #8252). component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A", "Option B"]); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]); }); it("tab-state persistence: answer question 0, Tab forward, Tab back, answer still present", () => { @@ -596,6 +598,70 @@ describe("AskDialogComponent", () => { expect(onSubmit.mock.calls[0][0].results[0].customInput).toBe("custom detail"); }); + it("multi-question, multi-select: Enter on a plain option advances, does not submit", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const onPrompt = vi.fn(); + + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose multiple?", + options: [{ label: "Option A" }, { label: "Option B" }], + multi: true, + }, + { + id: "q2", + question: "Second question?", + options: [{ label: "Option C" }, { label: "Option D" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel, + onPrompt, + }); + expect(render(component)).toContain("Space toggle · Enter next"); + + // Space toggles Option A; Enter on the plain option row confirms and + // advances to Q2 instead of submitting the whole dialog (#8265 review). + component.handleInput(SPACE); + component.handleInput(ENTER); + expect(onSubmit).not.toHaveBeenCalled(); + + // On Q2: Down to Option D and Enter advances to the Submit tab. + component.handleInput(DOWN); + component.handleInput(ENTER); + expect(onSubmit).not.toHaveBeenCalled(); + + // On the Submit tab Enter submits once with both answers. + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results).toEqual([ + { + id: "q1", + question: "Choose multiple?", + options: ["Option A", "Option B"], + multi: true, + selectedOptions: ["Option A"], + customInput: undefined, + note: undefined, + timedOut: undefined, + }, + { + id: "q2", + question: "Second question?", + options: ["Option C", "Option D"], + multi: false, + selectedOptions: ["Option D"], + customInput: undefined, + note: undefined, + timedOut: undefined, + }, + ]); + }); + it("defers a timeout that fires during a pending prompt and honors the resolved custom input", async () => { vi.useFakeTimers(); const deferred = Promise.withResolvers(); @@ -933,7 +999,7 @@ describe("AskDialogComponent", () => { } }); - it("single-question multi-select: Enter toggles instead of submitting", () => { + it("single-question multi-select: Enter submits the current selection immediately", () => { const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { @@ -949,43 +1015,36 @@ describe("AskDialogComponent", () => { onCancel: vi.fn(), onPrompt: vi.fn(), }); + expect(render(component)).toContain("Space toggle · Enter submit"); - // Enter on Option B toggles it — no submit, no tab movement. - component.handleInput(DOWN); - component.handleInput(ENTER); - expect(onSubmit).not.toHaveBeenCalled(); - - // The toggle registered: Submit tab confirms only Option B. - component.handleInput(TAB); - component.handleInput(ENTER); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]); - }); - - it("multi-select: Enter on a checked option toggles it off; empty answer submits from Submit tab", () => { - const onSubmit = vi.fn(); - const questions: ExtensionAskDialogQuestion[] = [ - { - id: "q1", - question: "Choose multiple?", - options: [{ label: "Option A" }, { label: "Option B" }], - multi: true, - }, - ]; - - const component = new AskDialogComponent(questions, { - onSubmit, - onCancel: vi.fn(), - onPrompt: vi.fn(), - }); - - // Space checks Option A, Enter on the same row unchecks it. + // Space selects Option A; Enter submits right away — no need to + // discover the Submit tab (issue #8252). component.handleInput(SPACE); component.handleInput(ENTER); - // Submit tab warns about the unanswered question but still submits. - component.handleInput(TAB); - expect(render(component).toLowerCase()).toContain("unanswered"); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]); + }); + + it("multi-select: Enter submits an empty selection instead of dead-ending", () => { + const onSubmit = vi.fn(); + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Choose multiple?", + options: [{ label: "Option A" }, { label: "Option B" }], + multi: true, + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onPrompt: vi.fn(), + }); + + // Enter with nothing selected submits the empty selection rather than + // toggling or blocking on the Submit tab. component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); @@ -1250,6 +1309,47 @@ describe("AskDialogComponent", () => { expect(matches?.length ?? 0).toBeLessThan(10); }); + it("wraps long option labels onto indented continuation lines instead of truncating", () => { + const onSubmit = vi.fn(); + const tail = "UNIQUE_TAIL_MARKER_8654"; + const longLabel = `${"This is a deliberately long option label ".repeat(4)}${tail}`; + const questions: ExtensionAskDialogQuestion[] = [ + { + id: "q1", + question: "Pick one?", + options: [{ label: longLabel }, { label: "Short" }], + }, + ]; + + const component = new AskDialogComponent(questions, { + onSubmit, + onCancel: vi.fn(), + onPrompt: vi.fn(), + }); + + const output = render(component); + // The unique label tail must be present — no ellipsis truncation. + expect(output).toContain(tail); + expect(output).not.toContain("…"); + // The first line carries the cursor glyph; continuation lines are + // indented under the marker so the cursor stays visually anchored. + const lines = output.split("\n"); + const first = lines.find(line => line.includes("This is a deliberately")) ?? ""; + const continuation = lines.find(line => line.includes("option label") && !line.includes("❯")) ?? ""; + expect(first).toMatch(/│ ❯/); + expect(continuation).toMatch(/│ {3}/); + }); + + it("does not wrap an option label that fits the dialog content width", () => { + const component = new AskDialogComponent( + [{ id: "q1", question: "Pick one?", options: [{ label: "x".repeat(70) }] }], + { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn() }, + ); + + const output = render(component); + expect(output.split("\n").filter(line => line.includes("x"))).toHaveLength(1); + }); + it("Other editor cancel returns to the option list without submitting", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve(undefined)); const onSubmit = vi.fn(); diff --git a/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts b/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts index 5046ec552..46ba9d3f6 100644 --- a/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts +++ b/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts @@ -86,14 +86,14 @@ describe("ToolExecutionComponent detached task freeze", () => { vi.restoreAllMocks(); }); - function makeComponent(live: () => boolean) { + function makeComponent(live: () => boolean, uncommitted: () => boolean = () => true) { const requestRender = vi.fn(); const requestComponentRender = vi.fn(); const ui = { requestRender, requestComponentRender } as unknown as TUI; const component = new ToolExecutionComponent( "task", { agent: "scout", id: "Anna", description: "scout auth", assignment: "investigate the auth flow" }, - { liveRegion: { isBlockInLiveRegion: () => live() } }, + { liveRegion: { isBlockInLiveRegion: () => live(), isBlockUncommitted: () => uncommitted() } }, undefined, ui, ); @@ -119,6 +119,25 @@ describe("ToolExecutionComponent detached task freeze", () => { expect(stripVTControlCharacters(frameA)).toContain("scouting the auth flow"); }); + it("reveals the latest hidden progress before freezing the card", () => { + const { component } = makeComponent(() => false); + component.setToolActivityVisible(false); + + component.updateResult(asyncSnapshot("initial hidden progress"), true); + component.updateResult(asyncSnapshot("latest hidden progress"), true); + expect(component.render(100)).toEqual([]); + + component.setToolActivityVisible(true); + const revealed = stripVTControlCharacters(component.render(100).join("\n")); + expect(revealed).toContain("latest hidden progress"); + expect(revealed).not.toContain("initial hidden progress"); + + component.updateResult(asyncSnapshot("progress after entering history"), true); + const frozen = stripVTControlCharacters(component.render(100).join("\n")); + expect(frozen).toContain("latest hidden progress"); + expect(frozen).not.toContain("progress after entering history"); + }); + it("drops partial snapshots after the freeze but still applies the final result", () => { vi.useFakeTimers(); let live = true; diff --git a/packages/coding-agent/test/modes/components/welcome.test.ts b/packages/coding-agent/test/modes/components/welcome.test.ts index f1d5997d1..d74a7a5ea 100644 --- a/packages/coding-agent/test/modes/components/welcome.test.ts +++ b/packages/coding-agent/test/modes/components/welcome.test.ts @@ -3,7 +3,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { pickWeightedTip, WelcomeComponent } from "@oh-my-pi/pi-coding-agent/modes/components/welcome"; import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -describe("WelcomeComponent tips", () => { +describe("WelcomeComponent", () => { beforeAll(async () => { await Settings.init({ inMemory: true }); await initTheme(false); @@ -61,4 +61,12 @@ describe("WelcomeComponent tips", () => { expect(newMax).toBeGreaterThan(ordinaryMax); expect(pickWeightedTip([], 0.5)).toBe(""); }); + + it("keeps the right column visible when a long model name fits", () => { + const modelName = "DeepSeek V4 Flash (2x usage)"; + const output = new WelcomeComponent("17.3.4", modelName, "opencode-go").render(55).join("\n"); + + expect(output).toContain(modelName); + expect(output).toContain("Recent sessions"); + }); }); diff --git a/packages/coding-agent/test/modes/controllers/extension-ui-controller.test.ts b/packages/coding-agent/test/modes/controllers/extension-ui-controller.test.ts index fafbaba79..7f3aea80a 100644 --- a/packages/coding-agent/test/modes/controllers/extension-ui-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/extension-ui-controller.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeAll, describe, expect, it, type Mock, vi } from "bun:test"; import { Container, type OverlayOptions, setKeybindings } from "@oh-my-pi/pi-tui"; import { KeybindingsManager } from "../../../src/config/keybindings"; import type { ExtensionAskDialogQuestion, ExtensionUIContext } from "../../../src/extensibility/extensions"; @@ -316,4 +316,37 @@ describe("ExtensionUiController custom overlay", () => { margin: 0, }); }); + + it("rejects and restores the editor when a custom factory fails", async () => { + const harness = makeHarness(); + const ui = await harness.init(); + const failure = new Error("custom factory failed"); + + await expect(ui.custom(() => Promise.reject(failure))).rejects.toBe(failure); + + expect(harness.editorContainer.children).toEqual([harness.editor]); + expect(harness.setFocus).toHaveBeenLastCalledWith(harness.editor); + }); + + it("aborts a pending custom factory and disposes its late component", async () => { + const harness = makeHarness(); + const ui = await harness.init(); + harness.editor.setText("draft before factory"); + const controller = new AbortController(); + const factory = Promise.withResolvers(); + const component = new Container() as Container & { dispose: Mock<() => void> }; + component.dispose = vi.fn(); + + const pending = ui.custom(() => factory.promise, { signal: controller.signal }); + harness.editor.setText("draft typed while factory is pending"); + controller.abort(); + + await expect(pending).rejects.toBe(controller.signal.reason); + factory.resolve(component); + await flushMicrotasks(); + + expect(component.dispose).toHaveBeenCalledTimes(1); + expect(harness.editorContainer.children).toEqual([harness.editor]); + expect(harness.editor.getText()).toBe("draft typed while factory is pending"); + }); }); diff --git a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts index 0f2e4cc8a..50a23d40e 100644 --- a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts @@ -211,7 +211,7 @@ describe("TanCommandController", () => { harness.tempDir.path(), harness.parentFile.slice(0, -6), undefined, - { suppressBreadcrumb: true, sessionFile: expect.stringMatching(/Tan-.+\.jsonl$/) }, + { copyArtifacts: false, suppressBreadcrumb: true, sessionFile: expect.stringMatching(/Tan-.+\.jsonl$/) }, ); expect(harness.register).toHaveBeenCalledWith("task", "/tan write the release note", expect.any(Function), { ownerId: MAIN_AGENT_ID, diff --git a/packages/coding-agent/test/modes/theme/birch-contrast.test.ts b/packages/coding-agent/test/modes/theme/birch-contrast.test.ts new file mode 100644 index 000000000..9a6c08a4f --- /dev/null +++ b/packages/coding-agent/test/modes/theme/birch-contrast.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; +import { loadTheme } from "../../../src/modes/theme/loader"; + +describe("Birch card contrast", () => { + it("renders user and custom cards with explicit dark foregrounds", async () => { + const birch = await loadTheme("birch", { mode: "truecolor" }); + const darkInk = "\x1b[38;2;40;40;32m"; + + expect(birch.fg("userMessageText", "user")).toBe(`${darkInk}user\x1b[39m`); + expect(birch.fg("customMessageText", "custom")).toBe(`${darkInk}custom\x1b[39m`); + }); +}); diff --git a/packages/coding-agent/test/registry/agent-lifecycle.test.ts b/packages/coding-agent/test/registry/agent-lifecycle.test.ts index 611e79de4..0f5d12619 100644 --- a/packages/coding-agent/test/registry/agent-lifecycle.test.ts +++ b/packages/coding-agent/test/registry/agent-lifecycle.test.ts @@ -145,6 +145,86 @@ describe("AgentLifecycleManager", () => { expect(ref?.sessionFile).toBe("/tmp/3-Sub.jsonl"); }); + it("reclaimDeadCorpse frees a parked, session-less, unadopted id and refuses live/adopted refs (#8490)", async () => { + // A corpse: registered running, then parked with no session and no adoption + // (the isolated-run finalize / interrupted-construction outcome). It cannot + // be revived and would otherwise poison its id for the process lifetime. + const corpse = registry.register({ + id: "Corpse-Sub", + displayName: "task", + kind: "sub", + session: null, + sessionFile: "/tmp/Corpse-Sub.jsonl", + status: "running", + }); + registry.setStatus("Corpse-Sub", "parked", corpse); + await expect(lifecycle.ensureLive("Corpse-Sub")).rejects.toThrow(/parked and cannot be revived/); + + // A live ref is never reclaimed. + const live = makeSessionStub(); + registry.register({ + id: "Live-Sub", + displayName: "task", + kind: "sub", + session: live.session, + status: "running", + }); + expect(await lifecycle.reclaimDeadCorpse("Live-Sub", registry.get("Live-Sub")!)).toBe(false); + expect(registry.get("Live-Sub")?.session).toBe(live.session); + + // An adopted (revivable) parked agent is never reclaimed. + const adopted = registry.register({ + id: "Adopted-Sub", + displayName: "task", + kind: "sub", + session: null, + sessionFile: "/tmp/Adopted-Sub.jsonl", + status: "parked", + }); + lifecycle.adopt("Adopted-Sub", { idleTtlMs: 0, revive: async () => makeSessionStub().session }, adopted); + expect(await lifecycle.reclaimDeadCorpse("Adopted-Sub", adopted)).toBe(false); + expect(registry.get("Adopted-Sub")).toBe(adopted); + + // A stale expected ref (points at a different agent) is never reclaimed. + expect(await lifecycle.reclaimDeadCorpse("Corpse-Sub", adopted)).toBe(false); + + // The corpse is reclaimed, and its id becomes registerable again. + expect(await lifecycle.reclaimDeadCorpse("Corpse-Sub", corpse)).toBe(true); + expect(registry.get("Corpse-Sub")).toBeUndefined(); + const respawn = registry.registerIfAvailable( + { id: "Corpse-Sub", displayName: "task", kind: "sub", session: null, status: "running" }, + null, + ); + expect(respawn?.status).toBe("running"); + expect(registry.get("Corpse-Sub")).toBe(respawn); + }); + + it("reclaimDeadCorpse preserves an unadopted parked ref when its persisted session can cold-revive", async () => { + const revived = makeSessionStub(); + const cold = registry.register({ + id: "Cold-Sub", + displayName: "task", + kind: "sub", + session: null, + sessionFile: "/tmp/Cold-Sub.jsonl", + status: "parked", + }); + let factoryCalls = 0; + lifecycle.setPersistedSubagentReviverFactory(async ref => { + factoryCalls++; + expect(ref).toBe(cold); + return async () => revived.session; + }, 0); + + expect(await lifecycle.reclaimDeadCorpse("Cold-Sub", cold)).toBe(false); + expect(registry.get("Cold-Sub")).toBe(cold); + expect(factoryCalls).toBe(1); + + // The preserved ref remains messageable through the normal cold-revive path. + expect(await lifecycle.ensureLive("Cold-Sub")).toBe(revived.session); + expect(registry.get("Cold-Sub")?.session).toBe(revived.session); + }); + it("concurrent ensureLive calls during a slow revive coalesce into one reviver run", async () => { const gate = deferred(); const revived = makeSessionStub(); diff --git a/packages/coding-agent/test/registry/persisted-mid-spawn-stub.test.ts b/packages/coding-agent/test/registry/persisted-mid-spawn-stub.test.ts new file mode 100644 index 000000000..5cf92c448 --- /dev/null +++ b/packages/coding-agent/test/registry/persisted-mid-spawn-stub.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import { registerPersistedSubagents } from "@oh-my-pi/pi-coding-agent/registry/persisted-agents"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +function sessionHeader(id: string): string { + return JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id, + timestamp: "2026-08-13T17:14:48.125Z", + cwd: "/tmp", + }); +} + +async function registerFrom(dir: string): Promise { + const registry = new AgentRegistry(); + await registerPersistedSubagents(registry, path.join(dir, "main.jsonl")); + return registry; +} + +describe("registerPersistedSubagents mid-spawn stubs", () => { + it("does not park a child that only has the SessionManager header", async () => { + using tempDir = TempDir.createSync("@omp-mid-spawn-stub-"); + const dir = tempDir.path(); + await Bun.write(path.join(dir, "main.jsonl"), `${sessionHeader("main")}\n`); + await Bun.write( + path.join(dir, "main", "Adversary.jsonl"), + `${JSON.stringify({ type: "title", v: 1, title: "", updatedAt: "2026-08-13T17:14:48.125Z", pad: " " })}\n${sessionHeader("adversary")}\n`, + ); + + const registry = await registerFrom(dir); + expect(registry.get("Adversary")).toBeUndefined(); + }); + + it("still parks a finished child that recorded session_init", async () => { + using tempDir = TempDir.createSync("@omp-mid-spawn-init-"); + const dir = tempDir.path(); + await Bun.write(path.join(dir, "main.jsonl"), `${sessionHeader("main")}\n`); + await Bun.write( + path.join(dir, "main", "Worker.jsonl"), + `${[ + sessionHeader("worker"), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:49.000Z", + systemPrompt: "review", + task: "review the diff", + tools: ["read"], + agent: "adversarial-reviewer", + }), + ].join("\n")}\n`, + ); + + const registry = await registerFrom(dir); + expect(registry.get("Worker")?.status).toBe("parked"); + expect(registry.get("Worker")?.sessionFile).toBe(path.join(dir, "main", "Worker.jsonl")); + }); + + it("still parks a legacy child that has messages but no session_init", async () => { + using tempDir = TempDir.createSync("@omp-mid-spawn-legacy-"); + const dir = tempDir.path(); + await Bun.write(path.join(dir, "main.jsonl"), `${sessionHeader("main")}\n`); + await Bun.write( + path.join(dir, "main", "Legacy.jsonl"), + `${[ + sessionHeader("legacy"), + JSON.stringify({ + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-08-13T17:14:49.000Z", + message: { role: "user", content: "hello", timestamp: 1 }, + }), + ].join("\n")}\n`, + ); + + const registry = await registerFrom(dir); + expect(registry.get("Legacy")?.status).toBe("parked"); + }); + + it("does not replace a live generation claimed while metadata is being read", async () => { + using tempDir = TempDir.createSync("@omp-mid-spawn-claim-"); + const dir = tempDir.path(); + const childFile = path.join(dir, "main", "Worker.jsonl"); + await Bun.write(path.join(dir, "main.jsonl"), `${sessionHeader("main")}\n`); + await Bun.write( + childFile, + `${[ + sessionHeader("worker"), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:49.000Z", + systemPrompt: "review", + task: "review the diff", + tools: ["read"], + }), + ].join("\n")}\n`, + ); + + const registry = new AgentRegistry(); + const liveSession = {} as AgentSession; + const originalGet = registry.get.bind(registry); + let injectClaim = true; + registry.get = id => { + const current = originalGet(id); + if (id === "Worker" && injectClaim && !current) { + injectClaim = false; + queueMicrotask(() => { + registry.register({ + id, + displayName: id, + kind: "sub", + parentId: "main", + session: liveSession, + sessionFile: childFile, + status: "running", + }); + }); + } + return current; + }; + + await registerPersistedSubagents(registry, path.join(dir, "main.jsonl")); + + expect(originalGet("Worker")?.status).toBe("running"); + expect(originalGet("Worker")?.session).toBe(liveSession); + }); +}); diff --git a/packages/coding-agent/test/sdk-session-isolation.test.ts b/packages/coding-agent/test/sdk-session-isolation.test.ts index 826898550..0ba6f35bc 100644 --- a/packages/coding-agent/test/sdk-session-isolation.test.ts +++ b/packages/coding-agent/test/sdk-session-isolation.test.ts @@ -12,6 +12,7 @@ import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent- import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import * as secrets from "@oh-my-pi/pi-coding-agent/secrets"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; @@ -233,6 +234,59 @@ describe("createAgentSession session storage isolation", () => { expect(replacement).toMatchObject({ status: "idle", session: null }); }); + it("reclaims an unrevivable parked generation before a fresh same-id spawn", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-generation-corpse-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwd = path.join(tempDir, "project"); + fs.mkdirSync(cwd, { recursive: true }); + AgentLifecycleManager.resetGlobalForTests(); + AgentRegistry.resetGlobalForTests(); + const lifecycle = AgentLifecycleManager.global(); + const registry = AgentRegistry.global(); + const corpse = registry.register({ + id: "reused-worker", + displayName: "dead generation", + kind: "sub", + parentId: "Main", + session: null, + sessionFile: path.join(tempDir, "old-worker.jsonl"), + status: "parked", + }); + + let session: AgentSession | undefined; + try { + ({ session } = await createAgentSession({ + cwd, + agentDir: path.join(tempDir, "agent"), + modelRegistry: sharedModelRegistry, + settings: Settings.isolated(), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + agentRegistry: registry, + agentId: "reused-worker", + agentDisplayName: "fresh generation", + parentTaskPrefix: "reused-worker", + parentAgentId: "Main", + taskDepth: 1, + expectedAgentRef: null, + })); + const replacement = registry.get("reused-worker"); + expect(replacement).toBeDefined(); + expect(replacement).not.toBe(corpse); + expect(replacement?.session).toBe(session); + } finally { + await session?.dispose(); + await lifecycle.dispose(); + AgentLifecycleManager.resetGlobalForTests(); + AgentRegistry.resetGlobalForTests(); + } + }); + it("reuses the exact parked ref authorized for revival", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-generation-revive-${Snowflake.next()}-`)); tempDirs.push(tempDir); diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index 82c220278..0bf313358 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -166,6 +166,25 @@ describe("selector setting side effects", () => { }); } + for (const enabled of [false, true]) { + it(`rebuilds the transcript when display.showTokenUsage=${enabled} changes in /settings`, () => { + const rebuildChatFromMessages = vi.fn(); + const resetDisplay = vi.fn(); + const controller = new SelectorController({ + rebuildChatFromMessages, + ui: { resetDisplay }, + } as unknown as InteractiveModeContext); + + controller.handleSettingChange("display.showTokenUsage", enabled); + + expect(rebuildChatFromMessages).toHaveBeenCalledTimes(1); + expect(resetDisplay).toHaveBeenCalledTimes(1); + expect(rebuildChatFromMessages.mock.invocationCallOrder[0]).toBeLessThan( + resetDisplay.mock.invocationCallOrder[0], + ); + }); + } + it("clears stale default role thinking when auto is selected", async () => { const testTheme = await getThemeByName("dark"); if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); diff --git a/packages/coding-agent/test/session-loader-stream.test.ts b/packages/coding-agent/test/session-loader-stream.test.ts index a0900f904..6f72a60c8 100644 --- a/packages/coding-agent/test/session-loader-stream.test.ts +++ b/packages/coding-agent/test/session-loader-stream.test.ts @@ -16,6 +16,7 @@ import { serializeTitleSlot } from "@oh-my-pi/pi-coding-agent/session/session-ti const ISO = "2026-06-29T12:00:00.000Z"; const HEADER = { type: "session", version: 3, id: "s1", timestamp: ISO, cwd: "/tmp" }; + const msg = (id: string, parentId: string, text: string) => ({ type: "message", id, @@ -92,7 +93,39 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => { expect(titleSlot?.title).toBe("Visitor"); expect(entryIds(visited)).toEqual(["s1", "m1", "m2"]); + expect(visited[0]).toMatchObject({ title: "Visitor", titleSource: "user" }); + expect((await sessionLoader.loadEntriesFromFileStream(file)).malformedRecords).toBe(1); }); + + it("visits a large journal before reading its tail", async () => { + const largeText = "x".repeat(1024 * 1024); + const slotLine = serializeTitleSlot({ title: "Visitor", source: "user", updatedAt: ISO }); + const lines = [slotLine, JSON.stringify({ ...HEADER, title: "stale", titleSource: "generated" })]; + for (let index = 1; index <= 9; index++) { + lines.push(JSON.stringify(msg(`m${index}`, index === 1 ? "s1" : `m${index - 1}`, largeText))); + } + const file = await writeTemp(`${lines.join("\n")}\n`); + expect(fs.statSync(file).size).toBeGreaterThan(8 * 1024 * 1024); + + let visited = 0; + let headerTitle: string | undefined; + let headerTitleSource: string | undefined; + await sessionLoader.visitEntriesFromFile(file, entry => { + visited++; + if (entry.type === "session") { + headerTitle = entry.title; + headerTitleSource = entry.titleSource; + } + if (visited === 1) fs.truncateSync(file, 0); + }); + + // A collecting load reads the tail before the first callback and would + // still visit every in-memory entry after the file is truncated. + expect(visited).toBeLessThan(10); + expect(headerTitle).toBe("Visitor"); + expect(headerTitleSource).toBe("user"); + }); + it("does not revisit entries before a malformed line spanning stream chunks", async () => { const content = [ JSON.stringify(HEADER), @@ -167,6 +200,7 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => { expect(entryTypes(stream.entries)).toEqual(["session", "message", "message"]); const ids = messageIds(stream.entries); expect(ids).toEqual(["m1", "m2"]); // valid entries kept in order, malformed skipped + expect(stream.malformedRecords).toBe(1); }); it("matches parseSessionContent when there is no title slot (header is the first line)", async () => { @@ -217,5 +251,6 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => { const stream = await sessionLoader.loadEntriesFromFileStream(missing); expect(stream.entries).toEqual([]); expect(stream.titleSlot).toBeUndefined(); + expect(stream.malformedRecords).toBe(0); }); }); diff --git a/packages/coding-agent/test/session-manager-immediate-persist.test.ts b/packages/coding-agent/test/session-manager-immediate-persist.test.ts index 045ac116c..d46251a63 100644 --- a/packages/coding-agent/test/session-manager-immediate-persist.test.ts +++ b/packages/coding-agent/test/session-manager-immediate-persist.test.ts @@ -198,6 +198,24 @@ describe("SessionManager JSONL software-crash durability", () => { expect(toolResult).toBeDefined(); }); + it("rewrites a malformed resumed tail before appending another entry", async () => { + const cwd = makeTempDir("@pi-malformed-tail-cwd-"); + const manager = SessionManager.create(cwd, path.join(cwd, "sessions")); + manager.appendMessage(assistantMessage("seed")); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected session file"); + await manager.close(); + + fs.appendFileSync(sessionFile, '{"type":"message","id":"torn","message":{"role":"user","content":"lost'); + const resumed = await SessionManager.open(sessionFile); + resumed.appendMessage({ role: "user", content: "after resume", timestamp: Date.now() }); + + const entries = readJsonl(sessionFile); + expect(entries.map(entryKind)).toEqual(["session", "assistant", "user"]); + expect(messageContent(entries[2] ?? {})).toBe("after resume"); + await resumed.close(); + }); + it("keeps pre-assistant sessions out of history during shutdown", async () => { const cwd = makeTempDir("@pi-empty-session-cwd-"); const sessionDir = path.join(cwd, "sessions"); @@ -307,10 +325,7 @@ describe("SessionManager JSONL software-crash durability", () => { expect(afterKinds).toEqual(crashKinds); }); - it("latches the first hot-path write failure before append returns", () => { - // H2: async append + discarded Promise meant ENOSPC/EIO only latched after a - // later flush/append. appendSync must latch `#diskFailure` on the first call - // without throwing through unguarded turn-loop callers. + it("alerts once and retries all in-memory entries after a transient write failure", () => { const cwd = makeTempDir("@pi-write-fail-cwd-"); const sessionDir = path.join(cwd, "sessions"); const manager = SessionManager.create(cwd, sessionDir); @@ -320,40 +335,91 @@ describe("SessionManager JSONL software-crash durability", () => { manager.appendMessage(assistantMessage("seed")); manager.appendMessage({ role: "user", content: "ok-user", timestamp: Date.now() }); - let failWrites = true; - const origWrite = fs.writeSync.bind(fs) as typeof fs.writeSync; - const writeSpy = spyOn(fs, "writeSync").mockImplementation(((...args: Parameters) => { - if (failWrites) { - const err = new Error("ENOSPC: no space left on device") as NodeJS.ErrnoException; - err.code = "ENOSPC"; - throw err; - } - return origWrite(...args); - }) as typeof fs.writeSync); + const writeSpy = spyOn(fs, "writeSync").mockImplementation(() => { + throw Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" }); + }); + const failures: Error[] = []; + manager.onPersistenceError(error => { + failures.push(error); + }); try { - let threw = false; - try { - manager.appendMessage({ role: "user", content: "should-fail-user", timestamp: Date.now() }); - } catch { - threw = true; - } - // Unguarded turn-loop contract: appendMessage itself must not throw. - expect(threw).toBe(false); - - // Failure is latched before return — flushSync / next append surface it. + expect(() => + manager.appendMessage({ role: "user", content: "failed-user", timestamp: Date.now() }), + ).not.toThrow(); expect(() => manager.flushSync()).toThrow("ENOSPC"); - expect(() => manager.appendMessage({ role: "user", content: "next-user", timestamp: Date.now() })).toThrow( - "ENOSPC", - ); + expect(failures).toHaveLength(1); - const users = parseJsonlLenient>(fs.readFileSync(sessionFile, "utf8")) + writeSpy.mockRestore(); + expect(() => + manager.appendMessage({ role: "user", content: "recovered-user", timestamp: Date.now() }), + ).not.toThrow(); + expect(() => manager.flushSync()).not.toThrow(); + + const users = readJsonl(sessionFile) .filter(entry => entry.type === "message" && messageRole(entry) === "user") .map(entry => messageContent(entry)); - expect(users).toEqual(["ok-user"]); + expect(users).toEqual(["ok-user", "failed-user", "recovered-user"]); + expect(failures).toHaveLength(1); } finally { - failWrites = false; writeSpy.mockRestore(); } }); + + it("reparents metadata children when durably discarding an entry", async () => { + const cwd = makeTempDir("@pi-discard-metadata-cwd-"); + const sessionDir = path.join(cwd, "sessions"); + const manager = SessionManager.create(cwd, sessionDir); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file path"); + + const priorId = manager.appendMessage(assistantMessage("prior turn")); + const discardedId = manager.appendMessage(assistantMessage("")); + const serviceTierId = manager.appendServiceTierChange(null); + await manager.discardEntryDurably(discardedId); + await manager.close(); + + const reloaded = await SessionManager.open(sessionFile, sessionDir); + const branch = reloaded.getBranch(); + expect(branch.some(entry => entry.id === discardedId)).toBe(false); + expect(branch).toContainEqual(expect.objectContaining({ id: serviceTierId, parentId: priorId })); + expect(branch.at(-1)).toMatchObject({ + type: "branch_summary", + summary: "", + details: { kind: "discarded-entry-branch", discardedEntryId: discardedId }, + parentId: serviceTierId, + }); + await reloaded.close(); + }); + + it("persists a branch marker when a discarded entry has content children", async () => { + const cwd = makeTempDir("@pi-discard-content-cwd-"); + const sessionDir = path.join(cwd, "sessions"); + const manager = SessionManager.create(cwd, sessionDir); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file path"); + + const priorId = manager.appendMessage(assistantMessage("prior turn")); + const discardedId = manager.appendMessage(assistantMessage("")); + const contentChildId = manager.appendMessage({ + role: "user", + content: "preserve off branch", + timestamp: Date.now(), + }); + await manager.discardEntryDurably(discardedId); + await manager.close(); + + const reloaded = await SessionManager.open(sessionFile, sessionDir); + const branch = reloaded.getBranch(); + expect(reloaded.getEntries()).toContainEqual(expect.objectContaining({ id: discardedId })); + expect(reloaded.getEntries()).toContainEqual(expect.objectContaining({ id: contentChildId })); + expect(branch.some(entry => entry.id === discardedId || entry.id === contentChildId)).toBe(false); + expect(branch.at(-1)).toMatchObject({ + type: "branch_summary", + summary: "", + details: { kind: "discarded-entry-branch", discardedEntryId: discardedId }, + parentId: priorId, + }); + await reloaded.close(); + }); }); diff --git a/packages/coding-agent/test/session-manager/signature-persistence.test.ts b/packages/coding-agent/test/session-manager/signature-persistence.test.ts index 849bc986a..bec4db6a6 100644 --- a/packages/coding-agent/test/session-manager/signature-persistence.test.ts +++ b/packages/coding-agent/test/session-manager/signature-persistence.test.ts @@ -278,16 +278,16 @@ describe("SessionManager signature persistence", () => { await reloaded.close(); }, 15_000); - it("preserves oversized Anthropic web-search results byte-for-byte across reload", async () => { + it("preserves oversized Anthropic server-tool results byte-for-byte across reload", async () => { using tempDir = TempDir.createSync("@pi-session-anthropic-server-tool-persistence-"); const session = SessionManager.create(tempDir.path(), tempDir.path()); - const encryptedContent = `ENCRYPTED_WEB_SEARCH_RESULT_${"W".repeat(600_000)}`; + const oversizedPayload = "W".repeat(600_000); const serverToolContent: AssistantMessage["content"] = [ { type: "anthropicServerTool", block: { type: "server_tool_use", - id: "srvtoolu_search", + id: "srvtoolu_web_search", name: "web_search", input: { query: "current UTC date" }, }, @@ -296,8 +296,28 @@ describe("SessionManager signature persistence", () => { type: "anthropicServerTool", block: { type: "web_search_tool_result", - tool_use_id: "srvtoolu_search", - content: [{ type: "web_search_result", encrypted_content: encryptedContent }], + tool_use_id: "srvtoolu_web_search", + content: [{ type: "web_search_result", encrypted_content: `ENCRYPTED_WEB_SEARCH_${oversizedPayload}` }], + }, + }, + { + type: "anthropicServerTool", + block: { + type: "server_tool_use", + id: "srvtoolu_tool_search", + name: "tool_search_tool_bm25", + input: { query: "read" }, + }, + }, + { + type: "anthropicServerTool", + block: { + type: "tool_search_tool_result", + tool_use_id: "srvtoolu_tool_search", + content: { + type: "tool_search_tool_search_result", + tool_references: [{ type: "tool_reference", tool_name: `READ_TOOL_${oversizedPayload}` }], + }, }, }, ]; diff --git a/packages/coding-agent/test/session-storage.test.ts b/packages/coding-agent/test/session-storage.test.ts index 9c4d0f131..59b92dad6 100644 --- a/packages/coding-agent/test/session-storage.test.ts +++ b/packages/coding-agent/test/session-storage.test.ts @@ -150,6 +150,24 @@ describe("FileSessionStorage writer", () => { await expect(writer.append("two\n")).rejects.toThrow("disk full"); await expect(writer.close()).rejects.toThrow("disk full"); }); + + it("rolls back bytes from a partial append before surfacing the error", () => { + const sessionPath = path.join(tempDir, "partial-append.jsonl"); + fs.writeFileSync(sessionPath, "complete\n"); + const writer = storage.openWriter(sessionPath); + vi.spyOn(fs, "writeSync") + .mockImplementationOnce(() => { + fs.appendFileSync(sessionPath, "par"); + return 3; + }) + .mockImplementation(() => { + throw Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" }); + }); + const appendSync = writer.appendSync?.bind(writer); + if (!appendSync) throw new Error("File writer must expose appendSync"); + expect(() => appendSync("partial entry\n")).toThrow("ENOSPC"); + expect(fs.readFileSync(sessionPath, "utf8")).toBe("complete\n"); + }); }); describe("FileSessionStorage.deleteSessionWithArtifacts", () => { diff --git a/packages/coding-agent/test/session/peek-session-init.test.ts b/packages/coding-agent/test/session/peek-session-init.test.ts index 2f2e0e652..119457741 100644 --- a/packages/coding-agent/test/session/peek-session-init.test.ts +++ b/packages/coding-agent/test/session/peek-session-init.test.ts @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { FileSessionStorage, MemorySessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; import { TempDir } from "@oh-my-pi/pi-utils"; const tempDirs: TempDir[] = []; +const LARGE_SESSION_BYTES = 9 * 1024 * 1024; function makeTempDir(prefix: string): string { const dir = TempDir.createSync(prefix); @@ -16,6 +18,15 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map(dir => dir.remove())); }); +class LargeFileSessionStorage extends FileSessionStorage { + override statSync(filePath: string) { + return { ...super.statSync(filePath), size: LARGE_SESSION_BYTES }; + } + override async readText(): Promise { + throw new Error("Large sessions must stream"); + } +} + function assistantMessage(text: string) { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected built-in anthropic model to exist"); @@ -67,6 +78,36 @@ describe("SessionManager.peekSessionInit", () => { expect(peek?.init?.restrictToolNames).toBe(true); }); + it("streams large file-backed sessions without a full read", async () => { + const cwd = makeTempDir("@pi-peek-stream-"); + const manager = SessionManager.create(cwd, path.join(cwd, "sessions")); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file path"); + + manager.appendSessionInit({ systemPrompt: "first", task: "task", tools: ["read"], spawns: "" }); + manager.appendSessionInit({ systemPrompt: "second", task: "task", tools: ["read"], spawns: "" }); + manager.appendMessage(assistantMessage("journal tail")); + + const peek = await SessionManager.peekSessionInit(sessionFile, new LargeFileSessionStorage()); + expect(peek?.cwd).toBe(manager.getCwd()); + expect(peek?.init?.systemPrompt).toBe("second"); + }); + + it("preserves non-file storage behavior", async () => { + const cwd = makeTempDir("@pi-peek-memory-"); + const storage = new MemorySessionStorage(); + const manager = SessionManager.create(cwd, path.join(cwd, "sessions"), storage); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file path"); + manager.appendSessionInit({ systemPrompt: "first", task: "task", tools: ["read"], spawns: "" }); + manager.appendSessionInit({ systemPrompt: "second", task: "task", tools: ["read"], spawns: "" }); + manager.appendMessage(assistantMessage("journal tail")); + + const peek = await SessionManager.peekSessionInit(sessionFile, storage); + expect(peek?.cwd).toBe(manager.getCwd()); + expect(peek?.init?.systemPrompt).toBe("second"); + }); + it("returns init: null for a session file with no session_init (a main/legacy session)", async () => { const cwd = makeTempDir("@pi-peek-legacy-"); const manager = SessionManager.create(cwd, path.join(cwd, "sessions")); @@ -79,6 +120,43 @@ describe("SessionManager.peekSessionInit", () => { expect(peek?.init).toBeNull(); }); + it("returns null when the first entry is not a session header", async () => { + const file = path.join(makeTempDir("@pi-peek-invalid-header-"), "invalid.jsonl"); + const content = [ + { + type: "session_init", + id: "invalid-first", + parentId: null, + timestamp: "2026-08-15T00:00:00.000Z", + systemPrompt: "invalid", + task: "task", + tools: [], + }, + { + type: "session", + version: 3, + id: "late-header", + timestamp: "2026-08-15T00:00:00.000Z", + cwd: "/wrong", + }, + { + type: "session_init", + id: "late-init", + parentId: "late-header", + timestamp: "2026-08-15T00:00:00.000Z", + systemPrompt: "late", + task: "task", + tools: [], + }, + ] + .map(entry => JSON.stringify(entry)) + .join("\n"); + await Bun.write(file, `${content}\n`); + + expect(await SessionManager.peekSessionInit(file)).toBeNull(); + expect(await SessionManager.peekSessionInit(file, new LargeFileSessionStorage())).toBeNull(); + }); + it("returns null for a file that cannot be read", async () => { const peek = await SessionManager.peekSessionInit(path.join(makeTempDir("@pi-peek-missing-"), "nope.jsonl")); expect(peek).toBeNull(); diff --git a/packages/coding-agent/test/session/session-manager-fork.test.ts b/packages/coding-agent/test/session/session-manager-fork.test.ts index ed99247fd..d3e78e4bf 100644 --- a/packages/coding-agent/test/session/session-manager-fork.test.ts +++ b/packages/coding-agent/test/session/session-manager-fork.test.ts @@ -23,6 +23,30 @@ interface JsonlMessageEntry { }; } +async function createSessionWithArtifacts(root: string): Promise<{ + cwd: string; + sessionDir: string; + sourceFile: string; + sourceArtifactsDir: string; +}> { + const cwd = path.join(root, "project"); + const sessionDir = path.join(root, "sessions"); + const sourceFile = path.join(sessionDir, "source.jsonl"); + const sourceArtifactsDir = sourceFile.slice(0, -".jsonl".length); + const sourceHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: "source-with-artifacts", + timestamp: new Date().toISOString(), + cwd, + }; + await fs.mkdir(path.join(sourceArtifactsDir, "nested"), { recursive: true }); + await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); + await Bun.write(path.join(sourceArtifactsDir, "1.read.log"), "tool output"); + await Bun.write(path.join(sourceArtifactsDir, "nested", "result.txt"), "nested output"); + return { cwd, sessionDir, sourceFile, sourceArtifactsDir }; +} + describe("SessionManager.forkFrom", () => { it("suppresses terminal breadcrumbs while preserving source history under a new parented session", async () => { using tempDir = TempDir.createSync("@omp-session-fork-"); @@ -87,4 +111,64 @@ describe("SessionManager.forkFrom", () => { setAgentDir(previousAgentDir); } }); + + it("copies source artifacts recursively into the fork by default", async () => { + using tempDir = TempDir.createSync("@omp-session-fork-artifacts-"); + const { cwd, sessionDir, sourceFile, sourceArtifactsDir } = await createSessionWithArtifacts(tempDir.path()); + + const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { + suppressBreadcrumb: true, + }); + const forkFile = forked.getSessionFile(); + if (!forkFile) throw new Error("expected forked session file"); + const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); + + expect(await Bun.file(path.join(forkArtifactsDir, "1.read.log")).text()).toBe("tool output"); + expect(await Bun.file(path.join(forkArtifactsDir, "nested", "result.txt")).text()).toBe("nested output"); + expect(await Bun.file(path.join(sourceArtifactsDir, "1.read.log")).text()).toBe("tool output"); + }); + + it("does not copy artifacts when the caller opts out", async () => { + using tempDir = TempDir.createSync("@omp-session-fork-no-artifacts-"); + const { cwd, sessionDir, sourceFile } = await createSessionWithArtifacts(tempDir.path()); + + const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { + copyArtifacts: false, + suppressBreadcrumb: true, + }); + const forkFile = forked.getSessionFile(); + if (!forkFile) throw new Error("expected forked session file"); + const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); + + expect(await Bun.file(path.join(forkArtifactsDir, "1.read.log")).exists()).toBe(false); + }); + + it("does not treat an extensionless source's parent directory as artifacts", async () => { + using tempDir = TempDir.createSync("@omp-session-fork-extensionless-"); + const cwd = path.join(tempDir.path(), "project"); + const sessionDir = path.join(tempDir.path(), "sessions"); + const forkDir = path.join(tempDir.path(), "forks"); + const sourceFile = path.join(sessionDir, "source"); + const unrelatedFile = path.join(sessionDir, "unrelated.txt"); + const sourceHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: "extensionless-source", + timestamp: new Date().toISOString(), + cwd, + }; + await fs.mkdir(sessionDir, { recursive: true }); + await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); + await Bun.write(unrelatedFile, "must not be copied"); + + const forked = await SessionManager.forkFrom(sourceFile, cwd, forkDir, undefined, { + suppressBreadcrumb: true, + }); + const forkFile = forked.getSessionFile(); + if (!forkFile) throw new Error("expected forked session file"); + const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); + + expect(await Bun.file(path.join(forkArtifactsDir, "unrelated.txt")).exists()).toBe(false); + expect(await Bun.file(unrelatedFile).text()).toBe("must not be copied"); + }); }); diff --git a/packages/coding-agent/test/shake.test.ts b/packages/coding-agent/test/shake.test.ts index 98be7bec2..906333c49 100644 --- a/packages/coding-agent/test/shake.test.ts +++ b/packages/coding-agent/test/shake.test.ts @@ -372,7 +372,7 @@ describe("AgentSession shake", () => { }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); - await Bun.sleep(20); + await session.waitForIdle(); expect(shakeSpy).toHaveBeenCalledWith("elide", expect.anything()); const start = events.filter(e => e.type === "auto_compaction_start"); @@ -580,7 +580,7 @@ describe("AgentSession shake", () => { }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); - await Bun.sleep(50); + await session.waitForIdle(); // Shake fires once. The pre-fix bug auto-continued, which would re-trigger shake // on the next agent_end. The fix replaces that loop with a one-shot fallback. @@ -634,7 +634,7 @@ describe("AgentSession shake", () => { }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); - await Bun.sleep(50); + await session.waitForIdle(); expect(shakeSpy).toHaveBeenCalledTimes(1); @@ -705,7 +705,7 @@ describe("AgentSession shake", () => { session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); - await Bun.sleep(50); + await session.waitForIdle(); expect(shakeSpy).toHaveBeenCalledTimes(1); const fullStart = events.find( diff --git a/packages/coding-agent/test/stats-cli.test.ts b/packages/coding-agent/test/stats-cli.test.ts new file mode 100644 index 000000000..0283ad10f --- /dev/null +++ b/packages/coding-agent/test/stats-cli.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as statsCli from "../src/cli/stats-cli"; +import Stats from "../src/commands/stats"; +import * as theme from "../src/modes/theme/theme"; +import { parseStatsDashboardArgs } from "../src/slash-commands/helpers/stats-dashboard"; + +const TEST_CONFIG = { bin: "omp", version: "0.0.0-test", commands: new Map() }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("stats dashboard host arguments", () => { + it("forwards the real omp stats flags to the dashboard runner", async () => { + vi.spyOn(theme, "initTheme").mockResolvedValue(); + const runStatsCommand = vi.spyOn(statsCli, "runStatsCommand").mockResolvedValue(); + const command = new Stats(["--host", "::", "--port", "3850"], TEST_CONFIG); + + await command.run(); + + expect(runStatsCommand).toHaveBeenCalledWith({ + port: 3850, + host: "::", + json: false, + summary: false, + }); + }); + + it("keeps the slash command loopback-only unless a host is requested", () => { + expect(parseStatsDashboardArgs("")).toEqual({ port: 3847, host: "127.0.0.1" }); + expect(parseStatsDashboardArgs("--host 0.0.0.0")).toEqual({ port: 3847, host: "0.0.0.0" }); + }); +}); diff --git a/packages/coding-agent/test/streaming-output-scrollback.test.ts b/packages/coding-agent/test/streaming-output-scrollback.test.ts index 873277f6d..1df65ac2a 100644 --- a/packages/coding-agent/test/streaming-output-scrollback.test.ts +++ b/packages/coding-agent/test/streaming-output-scrollback.test.ts @@ -496,6 +496,70 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = expect(lines.map(line => Bun.stripANSI(line)).join("\n")).toContain("ctrl+o"); }); + test("hidden todo snapshot does not clip settled rows from a later streaming response", async () => { + const rows = 8; + stubStdoutRows(rows); + const term = new VirtualTerminal(60, rows); + Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined }); + const scheduler = makeDrainableScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + tui.setScrollbackRebuild(false); + const transcript = new TranscriptContainer(); + transcript.setToolActivityVisible(false); + transcript.addChild(new StaticBlock(["user: run the plan"])); + + const todo = new ToolExecutionComponent("todo", { op: "init" }, {}, undefined, tui, process.cwd()); + todo.updateResult( + { + content: [{ type: "text", text: "" }], + details: { + phases: [ + { + name: "Workflow", + tasks: [{ content: "Stream the response", status: "in_progress" }], + }, + ], + storage: "session", + }, + }, + false, + ); + transcript.addChild(todo); + + const assistant = new AssistantMessageComponent(undefined, true); + transcript.addChild(assistant); + tui.addChild(transcript); + tui.addChild(new Footer(4)); + + const text = Array.from( + { length: 30 }, + (_, index) => `stream-row-${index} with stable content that must remain in terminal history.`, + ).join("\n\n"); + + try { + tui.start(); + scheduler.flush(); + await term.flush(); + + for (const partialText of streamingPrefixes(text, 300)) { + assistant.updateContent(makeAssistantMessage([{ type: "text", text: partialText }]), { + transient: true, + }); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + } + + const midStreamRows = plainScrollBuffer(term); + expect(midStreamRows.some(row => row.includes("stream-row-0 "))).toBe(true); + } finally { + todo.seal(); + assistant.dispose(); + tui.stop(); + await term.flush(); + } + }); + test("streams live assistant thinking and answer rows into native scrollback before finalize", async () => { const rows = 8; stubStdoutRows(rows); diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index b38c8e574..4d8cd6504 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -39,7 +39,11 @@ describe("SYSTEM.md prompt assembly", () => { afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); - it("renders an absolute cwd beneath the user's home directory", async () => { + it("keeps per-request date/cwd out of the system prompt footer", async () => { + // The date/cwd line was moved out of the system prompt and onto the first + // user turn (#7404): any byte that changes per request at the tail of the + // system block invalidates the tool-schema prefix cache on open-weight + // providers. The footer must not interpolate the cwd or the date. const projectDir = path.join(os.homedir(), "project"); const { systemPrompt } = await buildSystemPrompt({ cwd: projectDir, @@ -59,8 +63,9 @@ describe("SYSTEM.md prompt assembly", () => { const promptText = systemPrompt.join("\n\n"); const normalizedProjectDir = projectDir.replace(/\\/g, "/"); - // cwd interpolation: the quoted absolute path appears in the footer line. - expect(promptText).toContain(`'${normalizedProjectDir}'`); + expect(promptText).not.toContain(normalizedProjectDir); + expect(promptText).not.toContain("Today"); + expect(promptText).not.toContain("current working directory"); }); it("renders SYSTEM.md exactly once when it is used as the custom base prompt", async () => { @@ -156,13 +161,14 @@ describe("SYSTEM.md prompt assembly", () => { }); const promptText = systemPrompt.join("\n\n"); - const normalizedProjectDir = projectDir.replace(/\\/g, "/"); const appendMatches = promptText.match(new RegExp(escapeRegExp(appendPrompt), "g")) ?? []; expect(systemPrompt).toHaveLength(2); expect(promptText).toContain("CLI custom prompt"); expect(promptText).toContain(""); expect(promptText).toContain(""); - expect(promptText).toContain(`'${normalizedProjectDir}'`); + // The project/environment footer survives even though the date/cwd line was + // relocated out of it; is rendered only by that footer. + expect(promptText).toContain(""); expect(appendMatches).toHaveLength(1); expect(promptText).not.toContain("Discovered project SYSTEM prompt"); }); diff --git a/packages/coding-agent/test/system-prompt-model.test.ts b/packages/coding-agent/test/system-prompt-model.test.ts index ed07a7dd9..a1edda2e8 100644 --- a/packages/coding-agent/test/system-prompt-model.test.ts +++ b/packages/coding-agent/test/system-prompt-model.test.ts @@ -34,31 +34,21 @@ async function expectPromptDateFromStartupTimezone(options: { await Bun.write( scenarioPath, `import { setSystemTime } from "bun:test"; -import { buildSystemPrompt } from ${JSON.stringify(path.resolve(import.meta.dir, "../src/system-prompt.ts"))}; +import { renderDateCwdReminder } from ${JSON.stringify( + path.resolve(import.meta.dir, "../src/session/date-cwd-reminder.ts"), + )}; +import { formatLocalCalendarDate } from ${JSON.stringify(path.resolve(import.meta.dir, "../src/utils/local-date.ts"))}; setSystemTime(new Date(process.env.OMP_TEST_NOW!)); try { - const { systemPrompt } = await buildSystemPrompt({ - cwd: process.cwd(), - contextFiles: [], - skills: [], - rules: [], - toolNames: [], - workspaceTree: { - rootPath: process.cwd(), - rendered: "", - truncated: false, - totalLines: 0, - agentsMdFiles: [], - }, - activeRepoContext: null, - }); - const rendered = systemPrompt.join("\\n\\n"); - if (!rendered.includes(\`Today: \${process.env.OMP_EXPECTED_DATE}\`)) { - throw new Error(\`Prompt did not contain expected local date:\\n\${rendered}\`); + // The date/cwd reminder is built per request in the startup local timezone; + // the system prompt no longer embeds the date (#7404). + const reminder = renderDateCwdReminder(formatLocalCalendarDate(), "/cwd"); + if (!reminder.includes(\`Today: \${process.env.OMP_EXPECTED_DATE}\`)) { + throw new Error(\`Reminder did not contain expected local date:\\n\${reminder}\`); } - if (rendered.includes(\`Today: \${process.env.OMP_REJECTED_DATE}\`)) { - throw new Error(\`Prompt contained rejected UTC date:\\n\${rendered}\`); + if (reminder.includes(\`Today: \${process.env.OMP_REJECTED_DATE}\`)) { + throw new Error(\`Reminder contained rejected UTC date:\\n\${reminder}\`); } } finally { setSystemTime(); @@ -114,7 +104,7 @@ describe("system prompt model identifier", () => { expect(systemPrompt.join("\n\n")).toContain("Model: anthropic/claude-opus-4"); }); - it("renders the prompt date from the startup local timezone rather than UTC", async () => { + it("renders the first-turn reminder date from the startup local timezone rather than UTC", async () => { await expectPromptDateFromStartupTimezone({ tempDir, tempHomeDir, diff --git a/packages/coding-agent/test/task/autoload-skills.test.ts b/packages/coding-agent/test/task/autoload-skills.test.ts index 2796182d8..0f3f1dba0 100644 --- a/packages/coding-agent/test/task/autoload-skills.test.ts +++ b/packages/coding-agent/test/task/autoload-skills.test.ts @@ -56,6 +56,7 @@ function createMockSession( abort: async () => {}, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, } as unknown as AgentSession; } diff --git a/packages/coding-agent/test/task/executor-async-quiescence.test.ts b/packages/coding-agent/test/task/executor-async-quiescence.test.ts index d5b376ae8..0fe0fb101 100644 --- a/packages/coding-agent/test/task/executor-async-quiescence.test.ts +++ b/packages/coding-agent/test/task/executor-async-quiescence.test.ts @@ -161,6 +161,7 @@ function createAsyncSession( }, dispose: options.dispose ?? (async () => {}), setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, }; harness.session = session as unknown as AgentSession; return harness; diff --git a/packages/coding-agent/test/task/executor-launch-startup.test.ts b/packages/coding-agent/test/task/executor-launch-startup.test.ts index c78ae9ac2..85b771ff5 100644 --- a/packages/coding-agent/test/task/executor-launch-startup.test.ts +++ b/packages/coding-agent/test/task/executor-launch-startup.test.ts @@ -68,6 +68,7 @@ it("overlaps registry refresh with session-file opening and session setup", asyn abort: async () => {}, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, } as unknown as AgentSession; vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async () => { sessionCreationStarted.resolve(); diff --git a/packages/coding-agent/test/task/executor-pass-through.test.ts b/packages/coding-agent/test/task/executor-pass-through.test.ts index 51aba195c..1b783f6fb 100644 --- a/packages/coding-agent/test/task/executor-pass-through.test.ts +++ b/packages/coding-agent/test/task/executor-pass-through.test.ts @@ -50,6 +50,7 @@ function createMockSession(onPrompt: (params: { emit: (event: AgentSessionEvent) abort: async () => {}, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, }; return session as unknown as AgentSession; } diff --git a/packages/coding-agent/test/task/executor-prewalk.test.ts b/packages/coding-agent/test/task/executor-prewalk.test.ts index 998c35935..5141641e3 100644 --- a/packages/coding-agent/test/task/executor-prewalk.test.ts +++ b/packages/coding-agent/test/task/executor-prewalk.test.ts @@ -80,6 +80,7 @@ function yieldEmittingSession( abort: async () => {}, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, }; return session as unknown as AgentSession; } diff --git a/packages/coding-agent/test/task/executor-recent-output.test.ts b/packages/coding-agent/test/task/executor-recent-output.test.ts index e1986f26e..0762d6c59 100644 --- a/packages/coding-agent/test/task/executor-recent-output.test.ts +++ b/packages/coding-agent/test/task/executor-recent-output.test.ts @@ -193,6 +193,7 @@ function createScriptedSession( isAborted: () => aborted, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, }; // AgentSession is a concrete class; the executor consumes only this // structural subset. Deliberate documented test-double escape hatch, diff --git a/packages/coding-agent/test/task/executor-soft-budget.test.ts b/packages/coding-agent/test/task/executor-soft-budget.test.ts index d0c778899..f2c18da78 100644 --- a/packages/coding-agent/test/task/executor-soft-budget.test.ts +++ b/packages/coding-agent/test/task/executor-soft-budget.test.ts @@ -93,6 +93,7 @@ function createMockSession( setIrcWakeTurnObserver: observer => { ircWakeTurnObserver = observer; }, + subscribeRunState: () => () => {}, deliverIrcMessage: async msg => { const record: CustomMessage = { role: "custom", @@ -352,7 +353,21 @@ describe("runSubprocess soft request budget", () => { const rootSessionFile = `${tempDir.path()}/main.jsonl`; const workerSessionFile = `${tempDir.path()}/main/${id}.jsonl`; await Bun.write(rootSessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-13T17:14:48.000Z", cwd: "/tmp" }), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:48.000Z", + systemPrompt: "system", + task: "work", + tools: ["read"], + }), + ].join("\n"), + ); const controller = new AbortController(); // abort #1 = budget soft-stop (abortSent still false); abort #2 = // budget hard-abort's abortActiveSession (abortReason already "budget"). @@ -394,7 +409,21 @@ describe("runSubprocess soft request budget", () => { const rootSessionFile = `${tempDir.path()}/main.jsonl`; const workerSessionFile = `${tempDir.path()}/main/${id}.jsonl`; await Bun.write(rootSessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-13T17:14:48.000Z", cwd: "/tmp" }), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:48.000Z", + systemPrompt: "system", + task: "work", + tools: ["read"], + }), + ].join("\n"), + ); const promptStarted = Promise.withResolvers(); const promptStopped = Promise.withResolvers(); const handle = createMockSession( diff --git a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts index bdfea201f..bc80846bb 100644 --- a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts +++ b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts @@ -80,6 +80,7 @@ function createMockSession( abort: async () => {}, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, }; return session as unknown as AgentSession; diff --git a/packages/coding-agent/test/task/executor-wall-clock.test.ts b/packages/coding-agent/test/task/executor-wall-clock.test.ts index b03267093..d12b2bf06 100644 --- a/packages/coding-agent/test/task/executor-wall-clock.test.ts +++ b/packages/coding-agent/test/task/executor-wall-clock.test.ts @@ -32,6 +32,7 @@ function createHangingSession(): HangingSessionHandle { const { promise: hang, resolve: releaseHang } = Promise.withResolvers(); const session: Partial = { setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, @@ -123,6 +124,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { const settings = Settings.isolated({ "task.maxRuntimeMs": 0 }); const fastSession: Partial = { setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, @@ -212,6 +214,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { const lateSession = { dispose: async () => lateDisposed.resolve(), setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, } as unknown as AgentSession; let lateInstall = registry.get("late-generation"); vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async (options = {}) => { @@ -251,6 +254,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { const replacementSession = { dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, } as unknown as AgentSession; const replacement = registry.register({ id: "late-generation", @@ -279,6 +283,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { let abortCount = 0; const session: Partial = { setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, @@ -358,6 +363,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { let abortCountBeforeYieldExecutionEnd: number | undefined; const session: Partial = { setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, @@ -459,6 +465,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { const promptCalls: Array<{ text: string; options?: PromptOptions }> = []; const session: Partial = { setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, @@ -590,6 +597,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { let abortCountAfterFollowingTurn: number | undefined; const session: Partial = { setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, @@ -674,6 +682,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { const settings = Settings.isolated({ "task.maxRuntimeMs": 0 }); const fastSession: Partial = { setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, state: { messages: [] } as never, agent: { state: { systemPrompt: ["test"] } } as never, extensionRunner: undefined as never, diff --git a/packages/coding-agent/test/task/persisted-revive.test.ts b/packages/coding-agent/test/task/persisted-revive.test.ts index c99fee83e..7ef12682a 100644 --- a/packages/coding-agent/test/task/persisted-revive.test.ts +++ b/packages/coding-agent/test/task/persisted-revive.test.ts @@ -57,6 +57,7 @@ function createRevivedSession(activeToolNames: string[][]): RevivedSessionHandle setIrcWakeTurnObserver: (next: IrcWakeObserver | undefined) => { observer = next; }, + subscribeRunState: () => () => {}, getLastAssistantMessage: () => undefined, } as unknown as AgentSession; return { session, observer: () => observer }; diff --git a/packages/coding-agent/test/task/subagent-lsp.test.ts b/packages/coding-agent/test/task/subagent-lsp.test.ts index 85961c2bc..0adcb61a9 100644 --- a/packages/coding-agent/test/task/subagent-lsp.test.ts +++ b/packages/coding-agent/test/task/subagent-lsp.test.ts @@ -86,6 +86,7 @@ function createYieldingSession(): AgentSession { abort: async () => {}, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, } as unknown as AgentSession; } diff --git a/packages/coding-agent/test/task/task-guards.test.ts b/packages/coding-agent/test/task/task-guards.test.ts index b0726dcf3..6d93fcad5 100644 --- a/packages/coding-agent/test/task/task-guards.test.ts +++ b/packages/coding-agent/test/task/task-guards.test.ts @@ -109,6 +109,7 @@ function createFakeSession(config: FakeSessionConfig = {}): FakeSessionHandle { }, dispose: async () => {}, setIrcWakeTurnObserver: () => {}, + subscribeRunState: () => () => {}, }; return { session: session as AgentSession, diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index e1a2c7715..443d81d39 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -1607,6 +1607,84 @@ describe("AskTool rich ask dialog", () => { expect(abort).toHaveBeenCalledTimes(1); }); + it("accepts an empty multi-select submission instead of aborting", async () => { + const tool = new AskTool(createSession()); + const abort = vi.fn(); + const askDialog = vi.fn().mockResolvedValue({ + kind: "submit", + results: [ + { + id: "q1", + question: "Choose?", + options: ["A", "B"], + multi: true, + selectedOptions: [], + customInput: undefined, + timedOut: undefined, + }, + ], + }); + const context = createContext({ askDialog, abort }); + + const result = await tool.execute( + "call-empty-multi", + { + questions: [{ id: "q1", question: "Choose?", options: [{ label: "A" }, { label: "B" }], multi: true }], + }, + undefined, + undefined, + context, + ); + + expect(abort).not.toHaveBeenCalled(); + expect(result.details?.selectedOptions).toEqual([]); + expect(result.content[0]?.type).toBe("text"); + if (result.content[0]?.type === "text") { + expect(stripAnsi(result.content[0].text)).toContain("User did not select any options"); + } + }); + + it("formats an empty multi-select answer in a multi-question response as an empty selection", async () => { + const tool = new AskTool(createSession()); + const askDialog = vi.fn().mockResolvedValue({ + kind: "submit", + results: [ + { + id: "q1", + question: "Choose any?", + options: ["A", "B"], + multi: true, + selectedOptions: [], + }, + { + id: "q2", + question: "Choose one?", + options: ["C", "D"], + multi: false, + selectedOptions: ["C"], + }, + ], + }); + + const result = await tool.execute( + "call-empty-multi-among-many", + { + questions: [ + { id: "q1", question: "Choose any?", options: [{ label: "A" }, { label: "B" }], multi: true }, + { id: "q2", question: "Choose one?", options: [{ label: "C" }, { label: "D" }] }, + ], + }, + undefined, + undefined, + createContext({ askDialog }), + ); + + expect(result.content[0]?.type).toBe("text"); + if (result.content[0]?.type === "text") { + expect(stripAnsi(result.content[0].text)).toBe("User answers:\nq1: []\nq2: C"); + } + }); + it("returns chat redirect result when askDialog returns kind chat", async () => { const tool = new AskTool(createSession()); const abort = vi.fn(); diff --git a/packages/coding-agent/test/tools/browser-attach.test.ts b/packages/coding-agent/test/tools/browser-attach.test.ts index 2a59cf5cb..5287d48ac 100644 --- a/packages/coding-agent/test/tools/browser-attach.test.ts +++ b/packages/coding-agent/test/tools/browser-attach.test.ts @@ -1,6 +1,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { BrowserTool } from "@oh-my-pi/pi-coding-agent/tools/browser"; import { + findFreeCdpPort, pickElectronTarget, + probeCdpStatus, shouldPreserveConnectedBrowserFocus, } from "@oh-my-pi/pi-coding-agent/tools/browser/attach"; import { @@ -9,13 +14,23 @@ import { normalizeConnectedCdpUrl, releaseBrowser, } from "@oh-my-pi/pi-coding-agent/tools/browser/registry"; -import { acquireTab, releaseTab } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor"; +import { acquireTab } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor"; import type { Browser, Page, Target } from "puppeteer-core"; import { chromiumAvailable } from "./chromium-probe"; const CHROMIUM_AVAILABLE = await chromiumAvailable(); let sharedHeadless: BrowserHandle | undefined; +function makeSession(): ToolSession { + return { + cwd: process.cwd(), + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated({ "browser.headless": true }), + }; +} + interface FakePageOptions { url: string; title: string; @@ -121,32 +136,34 @@ describe("pickElectronTarget", () => { // Launches real headless Chromium; skipped where Chrome's system libraries are absent. test.skipIf(!CHROMIUM_AVAILABLE)( - "navigates a fresh attached tab to the requested URL", + "navigates a fresh attached tab and releases its handle without closing the target", async () => { const launched = sharedHeadless; if (!launched || !("browser" in launched)) throw new Error("Expected a shared Puppeteer browser"); const endpoint = new URL(launched.browser.wsEndpoint()); - let attached: BrowserHandle | undefined; + const tool = new BrowserTool(makeSession()); let opened = false; const tabName = `attach-navigation-${process.pid}-${Math.random().toString(36).slice(2)}`; const requested = "data:text/html,attached-navigation-target"; + const targetPage = (await launched.browser.pages())[0]; + if (!targetPage) throw new Error("Expected the launched browser to expose a page target"); try { - attached = await acquireBrowser( - { kind: "connected", cdpUrl: `http://${endpoint.host}` }, - { cwd: process.cwd() }, - ); - const { tab } = await acquireTab(tabName, attached, { + await tool.execute("open", { + action: "open", + name: tabName, url: requested, - waitUntil: "domcontentloaded", - timeoutMs: 10_000, + app: { cdp_url: `http://${endpoint.host}` }, }); opened = true; - expect(tab.info.url).toBe(requested); + const closeResult = await tool.execute("close", { action: "close", name: tabName }); + opened = false; + expect(closeResult.content).toEqual([{ type: "text", text: `Released managed tab "${tabName}"` }]); + expect(targetPage.isClosed()).toBe(false); + expect(targetPage.url()).toBe(requested); } finally { - if (opened) await releaseTab(tabName, { kill: false }); - else if (attached) await releaseBrowser(attached, { kill: false }); + if (opened) await tool.execute("close", { action: "close", name: tabName }); } }, 30_000, @@ -191,3 +208,56 @@ describe("pickElectronTarget", () => { 30_000, ); }); + +describe("probeCdpStatus", () => { + // Regression for #8567: a local proxy (Clash, corporate) 502s internal + // loopback addresses, so a bare fetch()/node:http probe misreports a healthy + // CDP daemon as dead. The raw-TCP probe must ignore HTTP_PROXY entirely. + test("returns the loopback status even when HTTP_PROXY 502s the request", async () => { + const cdp = Bun.serve({ port: 0, fetch: () => new Response("{}", { status: 200 }) }); + const proxy = Bun.serve({ port: 0, fetch: () => new Response("Bad Gateway", { status: 502 }) }); + const saved = { HTTP_PROXY: process.env.HTTP_PROXY, http_proxy: process.env.http_proxy }; + process.env.HTTP_PROXY = `http://127.0.0.1:${proxy.port}`; + process.env.http_proxy = `http://127.0.0.1:${proxy.port}`; + try { + const status = await probeCdpStatus(`http://127.0.0.1:${cdp.port}/json/version`, { timeoutMs: 1500 }); + expect(status).toBe(200); + } finally { + process.env.HTTP_PROXY = saved.HTTP_PROXY; + process.env.http_proxy = saved.http_proxy; + if (saved.HTTP_PROXY === undefined) delete process.env.HTTP_PROXY; + if (saved.http_proxy === undefined) delete process.env.http_proxy; + await cdp.stop(true); + await proxy.stop(true); + } + }); + + test("surfaces a non-2xx status from a live endpoint", async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("nope", { status: 503 }) }); + try { + const status = await probeCdpStatus(`http://127.0.0.1:${server.port}/json/version`, { timeoutMs: 1500 }); + expect(status).toBe(503); + } finally { + await server.stop(true); + } + }); + + test("returns null when the endpoint is unreachable", async () => { + const port = await findFreeCdpPort(); + const status = await probeCdpStatus(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 500 }); + expect(status).toBeNull(); + }); + + test("returns null when the request is already aborted", async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("{}", { status: 200 }) }); + try { + const status = await probeCdpStatus(`http://127.0.0.1:${server.port}/json/version`, { + timeoutMs: 1500, + signal: AbortSignal.abort(), + }); + expect(status).toBeNull(); + } finally { + await server.stop(true); + } + }); +}); diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index c168a7517..eba7a7db2 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -4,10 +4,14 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { chromiumExecutableProbeForTest, + resolveSharedBrowserLaunchSpec, stealthIgnoreDefaultArgsForTest, systemChromiumCandidatesForTest, } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { Browser, computeExecutablePath, detectBrowserPlatform, resolveBuildId } from "@oh-my-pi/pi-utils/browsers"; +import { APP_NAME } from "@oh-my-pi/pi-utils/dirs"; +import { PUPPETEER_REVISIONS } from "puppeteer-core/internal/revisions.js"; const EXECUTABLE_PROBE = path.resolve(import.meta.dir, "../fixtures/browser-executable-probe.ts"); @@ -44,6 +48,24 @@ describe("browser launch stealth defaults", () => { }); }); +describe("shared browser launch", () => { + it("suppresses the broker-owned blank startup window", async () => { + const previousExecutable = process.env.PUPPETEER_EXECUTABLE_PATH; + process.env.PUPPETEER_EXECUTABLE_PATH = "/test/chrome"; + try { + const launch = await resolveSharedBrowserLaunchSpec({ + headless: true, + userDataDir: "/test/profile", + }); + + expect(launch?.args).toContain("--no-startup-window"); + } finally { + if (previousExecutable === undefined) delete process.env.PUPPETEER_EXECUTABLE_PATH; + else process.env.PUPPETEER_EXECUTABLE_PATH = previousExecutable; + } + }); +}); + const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; describe("system Chromium candidates", () => { @@ -192,4 +214,54 @@ describe("browser executable selection", () => { await tempDir.remove(); } }); + + it("prefers Chrome for Testing over a detected system Chrome on macOS (#8673)", async () => { + const tempDir = TempDir.createSync("@browser-macos-cft-"); + try { + const home = path.join(tempDir.path(), "home"); + const xdgCache = path.join(tempDir.path(), "cache"); + // resolveIf() (packages/utils/src/dirs.ts) only redirects to an XDG + // root when its `/omp` dir already exists, so create them to pin + // the child's puppeteer cache to this isolated location. + for (const xdg of [xdgCache, path.join(tempDir.path(), "data"), path.join(tempDir.path(), "state")]) { + fs.mkdirSync(path.join(xdg, APP_NAME), { recursive: true }); + } + const env = { + ...process.env, + HOME: home, + XDG_CACHE_HOME: xdgCache, + XDG_DATA_HOME: path.join(tempDir.path(), "data"), + XDG_STATE_HOME: path.join(tempDir.path(), "state"), + OMP_BROWSER_PROBE_PLATFORM: "darwin", + PUPPETEER_EXECUTABLE_PATH: "", + }; + + // System Google Chrome bundle (com.google.Chrome) — the LaunchServices + // hijacker the fix must avoid selecting. + const systemChrome = path.join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + await Bun.write(systemChrome, "#!/bin/sh\necho 'Google Chrome 151'\n"); + fs.chmodSync(systemChrome, 0o755); + + // Seed the isolated Chrome for Testing binary in the child's cache so the + // probe resolves it without a network download. getPuppeteerDir() resolves + // to `/omp/puppeteer` given the dirs created above. + const cacheDir = path.join(xdgCache, APP_NAME, "puppeteer"); + const platform = detectBrowserPlatform(); + if (!platform) throw new Error("unsupported host platform for Chrome-for-Testing selection test"); + const buildId = await resolveBuildId(Browser.CHROME, platform, PUPPETEER_REVISIONS.chrome); + const chromeForTesting = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir, platform }); + await Bun.write(chromeForTesting, "#!/bin/sh\necho 'Chrome for Testing'\n"); + fs.chmodSync(chromeForTesting, 0o755); + + const result = Bun.spawnSync([process.execPath, EXECUTABLE_PROBE], { env, stdout: "pipe", stderr: "pipe" }); + const stderr = new TextDecoder().decode(result.stderr); + + expect(result.exitCode, stderr).toBe(0); + const selected = new TextDecoder().decode(result.stdout); + expect(selected).toBe(chromeForTesting); + expect(selected).not.toBe(systemChrome); + } finally { + await tempDir.remove(); + } + }); }); diff --git a/packages/coding-agent/test/tools/browser-relay-daemon.test.ts b/packages/coding-agent/test/tools/browser-relay-daemon.test.ts index 5c19250ab..8dd8abf4f 100644 --- a/packages/coding-agent/test/tools/browser-relay-daemon.test.ts +++ b/packages/coding-agent/test/tools/browser-relay-daemon.test.ts @@ -16,6 +16,64 @@ async function waitUntil(condition: () => boolean | Promise, timeoutMs: } describe("browser relay daemon", () => { + it("bypasses HTTP_PROXY when probing the loopback relay", async () => { + let relayHits = 0; + let proxyHits = 0; + const relay = Bun.serve({ + port: 0, + fetch: () => { + relayHits++; + return new Response("waiting", { status: 503 }); + }, + }); + const proxy = Bun.serve({ + port: 0, + fetch: () => { + proxyHits++; + return new Response("Bad Gateway", { status: 502 }); + }, + }); + const child = Bun.spawn( + [ + process.execPath, + "-e", + `import { probeRelayServer } from ${JSON.stringify(path.resolve(import.meta.dir, "../../src/tools/browser/relay/daemon.ts"))}; +const url = Bun.env.OMP_TEST_RELAY_URL; +if (!url) throw new Error("missing relay URL"); +process.stdout.write(String(await probeRelayServer(url)));`, + ], + { + env: { + ...process.env, + HTTP_PROXY: `http://127.0.0.1:${proxy.port}`, + http_proxy: `http://127.0.0.1:${proxy.port}`, + NO_PROXY: "", + no_proxy: "", + OMP_TEST_RELAY_URL: `http://127.0.0.1:${relay.port}`, + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toBe("true"); + expect(relayHits).toBe(1); + expect(proxyHits).toBe(0); + } finally { + if (child.exitCode === null) child.kill(); + await child.exited; + await relay.stop(true); + await proxy.stop(true); + } + }); + it("stays alive while a consumer in another project holds the global broker lease", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "omp-relay-global-")); const firstProject = path.join(home, "project-a"); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index de7f2d361..4fc49ded1 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -84,14 +84,14 @@ describe("editToolRenderer", () => { const collapsed = renderPreview(makeDiff(20), false); expect(collapsed).toContain("tail-line-20"); expect(collapsed).not.toContain("head-line-1"); - expect(collapsed).toContain("more lines above"); + expect(collapsed).toContain("content above"); expect(collapsed).toContain("(preview)"); // Within the viewport window, expanded shows the whole diff. const expanded = renderPreview(makeDiff(20), true); expect(expanded).toContain("head-line-1"); expect(expanded).toContain("tail-line-20"); - expect(expanded).not.toContain("more lines above"); + expect(expanded).not.toContain("content above"); expect(expanded).not.toContain("(preview)"); // Beyond it, expanded stays a viewport-sized tail window: an unbounded @@ -100,7 +100,7 @@ describe("editToolRenderer", () => { const expandedTall = renderPreview(makeDiff(40), true); expect(expandedTall).toContain("tail-line-40"); expect(expandedTall).not.toContain("head-line-1"); - expect(expandedTall).toContain("more lines above"); + expect(expandedTall).toContain("content above"); } finally { if (originalRowsDescriptor) { Object.defineProperty(process.stdout, "rows", originalRowsDescriptor); @@ -110,6 +110,52 @@ describe("editToolRenderer", () => { } }); + it("does not report a leading blank line as hidden content", async () => { + const uiTheme = await getUiTheme(); + const rendered = Bun.stripANSI( + editToolRenderer + .renderCall( + { file_path: "/tmp/leading-blank.ts", previewDiff: "\n+1|first-added\n+2|second-added" }, + { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "replace" } }, + uiTheme, + ) + .render(200) + .join("\n"), + ); + + expect(rendered).toContain("first-added"); + expect(rendered).toContain("second-added"); + expect(rendered).not.toContain("content above"); + }); + + it("uses a count-free marker for a discarded streaming prefix", async () => { + const uiTheme = await getUiTheme(); + const diff = [ + "@@ -1,10000 +1,12 @@", + ...Array.from({ length: 10_000 }, (_, index) => `-hidden-line-${index + 1}`), + ...Array.from({ length: 12 }, (_, index) => `+visible-tail-${index + 1}`), + "", + "", + ].join("\n"); + + const rendered = Bun.stripANSI( + editToolRenderer + .renderCall( + { file_path: "/tmp/large-preview.ts", previewDiff: diff }, + { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "replace" } }, + uiTheme, + ) + .render(200) + .join("\n"), + ); + + expect(rendered).toContain("content above"); + expect(rendered).toContain("visible-tail-12"); + expect(rendered).not.toContain("hidden-line-10000"); + expect(rendered).not.toContain("more hunks"); + expect(rendered).not.toContain("more lines above"); + }); + it("uses hashline input headers for streaming call path without apply_patch errors", async () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( @@ -255,6 +301,19 @@ describe("editToolRenderer", () => { it("caches completed diff rendering across stable frame renders", async () => { const uiTheme = await getUiTheme(); let renderDiffCalls = 0; + let statsColorCalls = 0; + const countingTheme = new Proxy(uiTheme, { + get(target, property) { + if (property === "fg") { + return (color: Parameters[0], text: string): string => { + if (color === "toolDiffAdded" && text === "+1") statsColorCalls++; + return target.fg(color, text); + }; + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); const options = { expanded: false, isPartial: false, @@ -275,17 +334,19 @@ describe("editToolRenderer", () => { }, }, options, - uiTheme, + countingTheme, { file_path: "src/example.ts" }, ); component.render(160); component.render(120); expect(renderDiffCalls).toBe(1); + expect(statsColorCalls).toBe(1); options.expanded = true; component.render(120); expect(renderDiffCalls).toBe(2); + expect(statsColorCalls).toBe(1); }); it("computes the hashline preview diff once a single-line edit finishes streaming", async () => { @@ -420,6 +481,27 @@ describe("editToolRenderer", () => { expect(lines.filter(line => line.includes("+2/-1"))).toHaveLength(1); }); + it("bounds a completed diff that contains one oversized change hunk", async () => { + const uiTheme = await getUiTheme(); + const diff = Array.from({ length: 1_000 }, (_, i) => `+${i + 1}│line ${i}`).join("\n"); + const component = editToolRenderer.renderResult( + { + content: [{ type: "text", text: "Updated demo.ts" }], + details: { diff, op: "update" }, + }, + { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, + uiTheme, + { file_path: "demo.ts" }, + ); + + const lines = component.render(160).map(line => Bun.stripANSI(line)); + const rendered = lines.join("\n"); + expect(lines.filter(line => line.includes("│line "))).toHaveLength(40); + expect(rendered).toContain("+40│line 39"); + expect(rendered).not.toContain("+41│line 40"); + expect(rendered).toContain("960 more lines"); + }); + it("renders completed edit gutters without inherited frame padding", async () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderResult( diff --git a/packages/coding-agent/test/tools/hub-list.test.ts b/packages/coding-agent/test/tools/hub-list.test.ts index 4056190d3..2e2105938 100644 --- a/packages/coding-agent/test/tools/hub-list.test.ts +++ b/packages/coding-agent/test/tools/hub-list.test.ts @@ -1,16 +1,43 @@ import { describe, expect, it } from "bun:test"; import * as path from "node:path"; import { AgentRegistry, MAIN_AGENT_ID } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; import { executeList } from "@oh-my-pi/pi-coding-agent/tools/hub/messaging"; import { TempDir } from "@oh-my-pi/pi-utils"; +function sessionHeader(id: string): string { + return JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id, + timestamp: "2026-08-13T17:14:48.125Z", + cwd: "/tmp", + }); +} + describe("hub list", () => { it("restores persisted peers after the process registry is lost", async () => { using tempDir = TempDir.createSync("@omp-hub-list-persisted-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); - await Bun.write(sessionFile, ""); - await Bun.write(workerSessionFile, ""); + await Bun.write(sessionFile, `${sessionHeader("main")}\n`); + // A settled subagent transcript: header + session_init. A header-only file + // is a mid-spawn stub and stays unregistered by design. + await Bun.write( + workerSessionFile, + `${[ + sessionHeader("worker"), + JSON.stringify({ + type: "session_init", + id: "si", + parentId: null, + timestamp: "2026-08-13T17:14:49.000Z", + systemPrompt: "review", + task: "review the diff", + tools: ["read"], + }), + ].join("\n")}\n`, + ); const registry = new AgentRegistry(); registry.register({ diff --git a/packages/coding-agent/test/tools/hub-wait.test.ts b/packages/coding-agent/test/tools/hub-wait.test.ts index 3bba9101c..8222bc09e 100644 --- a/packages/coding-agent/test/tools/hub-wait.test.ts +++ b/packages/coding-agent/test/tools/hub-wait.test.ts @@ -109,4 +109,29 @@ describe("hub unified wait", () => { expect(text).toContain("No running background jobs to wait for."); expect(result.useless).toBe(true); }); + + test("bare wait ignores a detached ref whose running status is stale", async () => { + const registry = AgentRegistry.global(); + registry.register({ id: SELF_ID, displayName: "main", kind: "main", session: null }); + registry.register({ + id: "Zombie", + displayName: "stale task", + kind: "sub", + parentId: SELF_ID, + session: null, + status: "running", + }); + + const manager = new AsyncJobManager({ onJobComplete: () => {} }); + // `timeoutMs: 0` would block forever if the stale ref still opened the + // message-wait gate; the test times out instead of asserting. + const result = await new HubTool(makeSession(manager)).execute("call_4", { op: "wait", timeoutMs: 0 }); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + expect(text).toContain("No running background jobs to wait for."); + // The stale ref is reported (not silently dropped): it is the only handle + // the caller has for clearing it with `hub cancel`. + expect(text).toContain("Zombie"); + expect(text).toContain("no turn in flight"); + }); }); diff --git a/packages/coding-agent/test/tools/irc.test.ts b/packages/coding-agent/test/tools/irc.test.ts index 84075b893..cb740fd6b 100644 --- a/packages/coding-agent/test/tools/irc.test.ts +++ b/packages/coding-agent/test/tools/irc.test.ts @@ -32,6 +32,7 @@ function makeFakeSession(): FakeSession { const delivered: IrcMessage[] = []; const relayed: CustomMessage[] = []; const session = { + isStreaming: true, deliverIrcMessage: async (msg: IrcMessage) => { if (nextError) { const err = nextError; diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 05ae245ce..9403e673b 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -3503,9 +3503,9 @@ describe("lsp regressions", () => { const loadConfigSpy = vi .spyOn(lspConfig, "loadConfig") .mockImplementation(() => configs.shift() ?? configs[0]); - const client = { proc: { kill: vi.fn() } } as unknown as LspClient; + const client = { proc: { kill: vi.fn() }, config: server } as unknown as LspClient; vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); - vi.spyOn(lspClient, "sendRequest").mockResolvedValue(null); + vi.spyOn(lspClient, "sendNotification").mockResolvedValue(undefined); const tool = new LspTool(makeLspSession(tempDir.path())); const initial = await tool.execute("reload-redetect-status", { action: "status" }); @@ -3640,8 +3640,8 @@ describe("lsp regressions", () => { describe("reload cancellation and truthful teardown (#6369)", () => { // A JSON-RPC error response the client maps to isMethodNotFoundError, so a - // non-rust server falls through from `rust-analyzer/reloadWorkspace` to the - // generic `workspace/didChangeConfiguration` reload. + // rust-analyzer server whose reloadWorkspace is unsupported falls through + // to the generic `workspace/didChangeConfiguration` reload. const methodNotFound = (id: RpcMessage["id"]): RpcMessage => ({ jsonrpc: "2.0", id, @@ -3662,7 +3662,7 @@ describe("lsp regressions", () => { // rust-analyzer/reloadWorkspace is left pending: only the caller // signal decides its fate. }); - const config: ServerConfig = { command: "fake-reload-cancel-req", fileTypes: [".ts"], rootMarkers: [] }; + const config: ServerConfig = { command: "rust-analyzer", fileTypes: [".ts"], rootMarkers: [] }; vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined }); const tool = new LspTool(makeLspSession(tempDir.path())); @@ -3699,7 +3699,7 @@ describe("lsp regressions", () => { srv.exit(0); } }); - const config: ServerConfig = { command: "fake-reload-cancel-fb", fileTypes: [".ts"], rootMarkers: [] }; + const config: ServerConfig = { command: "rust-analyzer", fileTypes: [".ts"], rootMarkers: [] }; vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined }); const tool = new LspTool(makeLspSession(tempDir.path())); @@ -3720,6 +3720,7 @@ describe("lsp regressions", () => { if (message.method === "initialize") { srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } }); } else if (message.method === "rust-analyzer/reloadWorkspace") { + expect(message.params).toBeUndefined(); srv.send(methodNotFound(message.id)); } else if (message.method === "shutdown") { srv.send({ jsonrpc: "2.0", id: message.id, result: null }); @@ -3727,7 +3728,7 @@ describe("lsp regressions", () => { srv.exit(0); } }); - const config: ServerConfig = { command: "fake-reload-fallback", fileTypes: [".ts"], rootMarkers: [] }; + const config: ServerConfig = { command: "rust-analyzer", fileTypes: [".ts"], rootMarkers: [] }; vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined }); const tool = new LspTool(makeLspSession(tempDir.path())); @@ -3758,7 +3759,7 @@ describe("lsp regressions", () => { srv.exit(0); } }); - const config: ServerConfig = { command: "fake-reload-fallback-code", fileTypes: [".ts"], rootMarkers: [] }; + const config: ServerConfig = { command: "rust-analyzer", fileTypes: [".ts"], rootMarkers: [] }; vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined }); const tool = new LspTool(makeLspSession(tempDir.path())); @@ -3773,6 +3774,43 @@ describe("lsp regressions", () => { } }); + it("does not send rust-analyzer/reloadWorkspace to a non-rust server that crashes on it (#8571)", async () => { + const tempDir = TempDir.createSync("@omp-lsp-reload-non-rust-"); + try { + let sawRustReload = false; + const server = installFakeLsp((message, srv) => { + if (message.method === "initialize") { + srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } }); + } else if (message.method === "rust-analyzer/reloadWorkspace") { + // Roslyn crashes the whole process instead of replying -32601 + // (dotnet/roslyn#84890); the gate must never let us send this. + sawRustReload = true; + srv.exit(82); + } else if (message.method === "shutdown") { + srv.send({ jsonrpc: "2.0", id: message.id, result: null }); + } else if (message.method === "exit") { + srv.exit(0); + } + }); + const config: ServerConfig = { command: "roslyn-language-server", fileTypes: [".cs"], rootMarkers: [] }; + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ + servers: { csharp: config }, + idleTimeoutMs: undefined, + }); + + const tool = new LspTool(makeLspSession(tempDir.path())); + const result = await tool.execute("reload-non-rust", { action: "reload", file: "*" }); + + expect(sawRustReload).toBe(false); + expect(textResult(result)).toContain("Reloaded csharp"); + expect(server.killed).toBe(false); + } finally { + vi.restoreAllMocks(); + await lspClient.shutdownAll(); + tempDir.removeSync(); + } + }); + it("shutdownClientInstance removes the client by identity and confirms process exit", async () => { const tempDir = TempDir.createSync("@omp-lsp-teardown-confirm-"); try { diff --git a/packages/coding-agent/test/tools/render-utils.test.ts b/packages/coding-agent/test/tools/render-utils.test.ts index 62545d5f1..c4e6cfdaf 100644 --- a/packages/coding-agent/test/tools/render-utils.test.ts +++ b/packages/coding-agent/test/tools/render-utils.test.ts @@ -286,6 +286,75 @@ describe("truncateDiffByHunk", () => { expect(idxOld).toBeLessThan(idxNew); expect(idxNew).toBeLessThan(idxTrailing); }); + it("caps one oversized change hunk at the line budget", () => { + const diff = makeHunk("+", 0, 1_000).join("\n"); + const head = truncateDiffByHunk(diff, 4, 32); + const tail = truncateDiffByHunk(diff, 4, 32, { fromTail: true }); + + expect(head.text.split("\n")).toHaveLength(32); + expect(head.text).toStartWith("+ new 0\n"); + expect(head.text).toEndWith("+ new 31"); + expect(head.hiddenLines).toBe(968); + expect(head.hiddenHunks).toBe(0); + + expect(tail.text.split("\n")).toHaveLength(32); + expect(tail.text).toStartWith("+ new 968\n"); + expect(tail.text).toEndWith("+ new 999"); + expect(tail.hiddenLines).toBe(968); + expect(tail.hiddenHunks).toBe(0); + }); + + it("keeps an exact-size change hunk unchanged", () => { + const diff = makeHunk("-", 0, 32).join("\n"); + expect(truncateDiffByHunk(diff, 4, 32)).toEqual({ + text: diff, + hiddenHunks: 0, + hiddenLines: 0, + }); + }); + it("drops surrounding context when changes exactly fill the line budget", () => { + const diff = [" leading context", ...makeHunk("+", 0, 32), " trailing context"].join("\n"); + + for (const result of [truncateDiffByHunk(diff, 4, 32), truncateDiffByHunk(diff, 4, 32, { fromTail: true })]) { + expect(result.text.split("\n")).toHaveLength(32); + expect(result.text).not.toContain("context"); + expect(result.hiddenLines).toBe(2); + expect(result.hiddenHunks).toBe(0); + } + }); + + it("does not exceed the line budget when context rounding spans multiple hunks", () => { + const diff = [ + " leading context", + ...makeHunk("+", 0, 15), + " middle context a", + " middle context b", + ...makeHunk("-", 100, 15), + " trailing context", + ].join("\n"); + + for (const result of [truncateDiffByHunk(diff, 4, 32), truncateDiffByHunk(diff, 4, 32, { fromTail: true })]) { + expect(result.text.split("\n")).toHaveLength(32); + expect(result.hiddenLines).toBe(2); + expect(result.hiddenHunks).toBe(0); + } + }); + it("does not count a removed separator as a hidden hunk", () => { + const diff = [...makeHunk("+", 0, 16), " hunk separator", ...makeHunk("-", 100, 16)].join("\n"); + + for (const result of [truncateDiffByHunk(diff, 4, 32), truncateDiffByHunk(diff, 4, 32, { fromTail: true })]) { + expect(result.text.split("\n")).toHaveLength(32); + expect(result.hiddenLines).toBe(1); + expect(result.hiddenHunks).toBe(0); + } + }); + it("reports every hunk excluded by the hunk limit", () => { + const diff = buildDiff(6, 1); + + for (const result of [truncateDiffByHunk(diff, 2, 100), truncateDiffByHunk(diff, 2, 100, { fromTail: true })]) { + expect(result.hiddenHunks).toBe(4); + } + }); }); describe("formatErrorMessage (F4 sanitization)", () => { diff --git a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts index 73f95ffe7..a880bc4e1 100644 --- a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts +++ b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { AgentMessage, SyntheticToolResultDetails } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; +import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import type { Model, Usage } from "@oh-my-pi/pi-catalog/types"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -534,4 +535,102 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recoveryFor(message, [syntheticResult("call-1")]).isRetryableError(message)).toBe(false); }); }); + + describe("HTTP/2 stream reset after resolved tool calls", () => { + const nghttp2Internal = "Stream closed with error code NGHTTP2_INTERNAL_ERROR"; + const nghttp2Refused = "Stream closed with error code NGHTTP2_REFUSED_STREAM"; + const stallMessage = "Provider stream stalled while waiting for the next event"; + + function cursorMessage(content: AssistantMessage["content"], errorMessage: string): AssistantMessage { + const message = makeMessage(content, model); + message.provider = "cursor"; + message.errorMessage = errorMessage; + return message; + } + + function execToolCall(id: string, marked = false): AssistantMessage["content"][number] { + const block: AssistantMessage["content"][number] = { + type: "toolCall", + id, + name: "bash", + arguments: { command: "pwd" }, + }; + if (marked) (block as { [kCursorExecResolved]?: true })[kCursorExecResolved] = true; + return block; + } + + function mcpToolCall(id: string): AssistantMessage["content"][number] { + return { + type: "toolCall", + id, + name: "mcp__databricks_production_execute_sql", + arguments: { query: "SELECT 1" }, + }; + } + + function realResult(toolCallId: string, toolName = "bash"): ToolResultMessage { + return { + role: "toolResult", + toolCallId, + toolName, + content: [{ type: "text", text: "/workspace" }], + isError: false, + timestamp: Date.now(), + }; + } + + function recoveryForReset(message: AssistantMessage, tail: readonly AgentMessage[]): TurnRecovery { + return new TurnRecovery(createHost(model, modelRegistry, { messages: [message as AgentMessage, ...tail] })); + } + + it("continues a Cursor NGHTTP2_INTERNAL_ERROR after a marked exec result", () => { + const message = cursorMessage([execToolCall("call-1", true)], nghttp2Internal); + const recovery = recoveryForReset(message, [realResult("call-1")]); + expect(recovery.isRetryableError(message)).toBe(false); + expect(recovery.classifyResolvedInterruptedToolTurn(message)).toBe("stream-stall"); + }); + + it("continues a Cursor NGHTTP2_REFUSED_STREAM after a marked exec result", () => { + const message = cursorMessage([execToolCall("call-1", true)], nghttp2Refused); + expect(recoveryForReset(message, [realResult("call-1")]).classifyResolvedInterruptedToolTurn(message)).toBe( + "stream-stall", + ); + }); + + it("continues a Cursor HTTP/2 reset after an unmarked MCP result", () => { + const message = cursorMessage([mcpToolCall("mcp-1")], nghttp2Internal); + const recovery = recoveryForReset(message, [realResult("mcp-1", "mcp__databricks_production_execute_sql")]); + expect(recovery.classifyResolvedInterruptedToolTurn(message)).toBe("stream-stall"); + }); + + it("does not continue a Cursor idle stall after an unmarked MCP call", () => { + const message = cursorMessage([mcpToolCall("mcp-1")], stallMessage); + const recovery = recoveryForReset(message, [realResult("mcp-1", "mcp__databricks_production_execute_sql")]); + expect(recovery.classifyResolvedInterruptedToolTurn(message)).toBeUndefined(); + }); + + it("does not continue an HTTP/2 reset whose tool call has no result", () => { + const message = cursorMessage([execToolCall("call-1", true)], nghttp2Internal); + expect(recoveryForReset(message, []).classifyResolvedInterruptedToolTurn(message)).toBeUndefined(); + }); + + it("does not continue an HTTP/2 CANCEL reset", () => { + const message = cursorMessage([execToolCall("call-1", true)], "Stream closed with error code NGHTTP2_CANCEL"); + expect( + recoveryForReset(message, [realResult("call-1")]).classifyResolvedInterruptedToolTurn(message), + ).toBeUndefined(); + }); + + it("matches a Connect-wrapped NGHTTP2 close", () => { + const message = cursorMessage( + [mcpToolCall("mcp-1")], + "Connect error failed_precondition: Error: Stream closed with error code NGHTTP2_INTERNAL_ERROR", + ); + expect( + recoveryForReset(message, [ + realResult("mcp-1", "mcp__databricks_production_execute_sql"), + ]).classifyResolvedInterruptedToolTurn(message), + ).toBe("stream-stall"); + }); + }); }); diff --git a/packages/coding-agent/test/unexpected-stop-classifier.test.ts b/packages/coding-agent/test/unexpected-stop-classifier.test.ts index 8024fd30c..48d1cb098 100644 --- a/packages/coding-agent/test/unexpected-stop-classifier.test.ts +++ b/packages/coding-agent/test/unexpected-stop-classifier.test.ts @@ -139,7 +139,12 @@ describe("classifyUnexpectedStop", () => { | undefined; expect(result).toBe(true); - expect(options).toMatchObject({ disableReasoning: true, maxTokens: 1024 }); + // Must exceed Anthropic's 1024-token minimum thinking budget so a LiteLLM/Vertex + // Anthropic route (which downgrades the disabled request to the lowest reasoning + // effort) still satisfies `max_tokens > thinking.budget_tokens` (issue #8610). + expect(options?.disableReasoning).toBe(true); + expect(options?.maxTokens).toBe(4096); + expect(options?.maxTokens).toBeGreaterThan(1024); }); }); diff --git a/packages/coding-agent/test/vibe/wait.test.ts b/packages/coding-agent/test/vibe/wait.test.ts new file mode 100644 index 000000000..ecf2563e0 --- /dev/null +++ b/packages/coding-agent/test/vibe/wait.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { AsyncJobManager } from "../../src/async/job-manager"; +import type { ToolSession } from "../../src/tools"; +import { VibeWaitTool } from "../../src/tools/vibe"; +import { VibeSessionRegistry } from "../../src/vibe/runtime"; + +const OWNER = "test-owner"; +const WORKER = "test-worker"; + +interface TestTurn { + jobId: string; + complete: (text: string) => void; +} + +let manager: AsyncJobManager; +let session: ToolSession; + +function startTurn(options?: { onDelivery?: (jobId: string, text: string) => void }): TestTurn { + const completion = Promise.withResolvers(); + if (options?.onDelivery) { + manager.registerDeliverySink(OWNER, options.onDelivery); + } + const jobId = manager.register( + "task", + "test vibe turn", + async ({ signal }) => { + const aborted = Promise.withResolvers(); + const onAbort = () => aborted.reject(new Error("cancelled")); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + try { + return await Promise.race([completion.promise, aborted.promise]); + } finally { + signal.removeEventListener("abort", onAbort); + } + }, + { ownerId: OWNER }, + ); + VibeSessionRegistry.global().registerRecordForTests({ id: WORKER, ownerId: OWNER, jobId }); + return { jobId, complete: completion.resolve }; +} + +beforeEach(() => { + manager = new AsyncJobManager({}); + session = { + getAgentId: () => OWNER, + getSessionId: () => "test-parent-session", + getSessionFile: () => null, + asyncJobManager: manager, + } as unknown as ToolSession; +}); + +afterEach(async () => { + vi.useRealTimers(); + await manager.dispose({ timeoutMs: 100 }); + VibeSessionRegistry.resetGlobalForTests(); +}); + +describe("vibe wait completion classification", () => { + it("reports a true timer expiry as timed out", async () => { + vi.useFakeTimers(); + startTurn(); + const pending = VibeSessionRegistry.global().wait(session, { timeoutMs: 10 }); + vi.advanceTimersByTime(10); + + const outcome = await pending; + + expect(outcome.timedOut).toBe(true); + expect(outcome.settled).toEqual([]); + expect(outcome.stillRunning).toEqual([WORKER]); + }); + + it("returns a settled worker result instead of timing out", async () => { + const turn = startTurn(); + const pending = VibeSessionRegistry.global().wait(session, { timeoutMs: 1_000 }); + turn.complete("worker result"); + + const outcome = await pending; + + expect(outcome.timedOut).toBe(false); + expect(outcome.settled).toEqual([ + { id: WORKER, jobId: turn.jobId, status: "completed", resultText: "worker result" }, + ]); + }); + + it("does not render an abort as an elapsed wait window, even with a long timeout", async () => { + startTurn(); + const controller = new AbortController(); + controller.abort(); + + const result = await new VibeWaitTool(session).execute("wait-call", { timeout: 900 }, controller.signal); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + expect(result.details?.wait?.timedOut).toBe(false); + expect(text).toContain("Still running"); + expect(text).not.toContain("Wait window elapsed"); + expect(text).not.toContain("re-issue vibe_wait"); + }); + + it("restores async self-delivery after an interrupted wait", async () => { + const deliveries: Array<{ jobId: string; text: string }> = []; + const turn = startTurn({ onDelivery: (jobId, text) => deliveries.push({ jobId, text }) }); + const controller = new AbortController(); + const pending = VibeSessionRegistry.global().wait(session, { + timeoutMs: 1_000, + signal: controller.signal, + }); + controller.abort(); + + const outcome = await pending; + expect(outcome.timedOut).toBe(false); + turn.complete("delivered later"); + await manager.getJob(turn.jobId)?.promise; + await manager.drainDeliveries({ timeoutMs: 1_000 }); + + expect(deliveries).toEqual([{ jobId: turn.jobId, text: "delivered later" }]); + }); + + it("returns a cancelled worker settlement without classifying it as timeout", async () => { + const turn = startTurn(); + const pending = VibeSessionRegistry.global().wait(session, { timeoutMs: 1_000 }); + manager.cancel(turn.jobId, { ownerId: OWNER }); + + const outcome = await pending; + + expect(outcome.timedOut).toBe(false); + expect(outcome.settled[0]).toMatchObject({ id: WORKER, jobId: turn.jobId, status: "cancelled" }); + }); +}); diff --git a/packages/coding-agent/test/workspace-diagnostics.test.ts b/packages/coding-agent/test/workspace-diagnostics.test.ts new file mode 100644 index 000000000..4a90d33af --- /dev/null +++ b/packages/coding-agent/test/workspace-diagnostics.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { interpretEmptyDiagnosticsResult } from "../src/lsp/workspace-diagnostics"; + +const command = ["npx", "tsc", "--noEmit"]; + +describe("interpretEmptyDiagnosticsResult", () => { + test("reports a silent non-zero exit as an unverified workspace", () => { + expect(interpretEmptyDiagnosticsResult(17, null, command)).toBe( + "Failed to run npx tsc --noEmit: the checker exited with code 17 without reporting anything, so the workspace was not verified", + ); + }); + + test("reports a signal when the checker was killed silently", () => { + expect(interpretEmptyDiagnosticsResult(137, "SIGKILL", command)).toBe( + "Failed to run npx tsc --noEmit: the checker was killed by SIGKILL without reporting anything, so the workspace was not verified", + ); + }); + + test("preserves the clean-workspace result for a successful silent checker", () => { + expect(interpretEmptyDiagnosticsResult(0, null, command)).toBe("No issues found"); + }); +}); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index b4035352a..64e84f677 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -8,6 +8,12 @@ - Recovered dangling range separators in hunk headers (`PUT 244.=:`, `CUT 5.=`) as single-line ranges (`N.=N`) instead of rejecting the header as an orphan payload line. +## [17.3.3] - 2026-08-14 + +### Fixed + +- Recovered dangling range separators in hunk headers (`PUT 244.=:`, `CUT 5.=`) as single-line ranges (`N.=N`) instead of rejecting the header as an orphan payload line. + ## [17.3.0] - 2026-08-13 ### Fixed diff --git a/packages/hashline/package.json b/packages/hashline/package.json index 4f6e0f4df..12859b824 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "17.3.4", + "version": "17.3.5", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/mnemopi/CHANGELOG.md b/packages/mnemopi/CHANGELOG.md index f6d7bf3e5..3e80f0647 100644 --- a/packages/mnemopi/CHANGELOG.md +++ b/packages/mnemopi/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.3.5] - 2026-08-16 + +### Fixed + +- Fixed an issue where transient provider failures (such as Anthropic overload or rate limit errors) were incorrectly treated as empty responses; these failures are now retried automatically before falling back. + ## [17.3.4] - 2026-08-14 ### Fixed diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index 79b6749dc..ce0b8e7e0 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "17.3.4", + "version": "17.3.5", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/mnemopi/src/core/local-llm.ts b/packages/mnemopi/src/core/local-llm.ts index 66fb39647..36615359f 100644 --- a/packages/mnemopi/src/core/local-llm.ts +++ b/packages/mnemopi/src/core/local-llm.ts @@ -5,9 +5,11 @@ import { completeSimple, type FetchImpl, type Model, + retryTransientCompletion, withAuth, } from "@oh-my-pi/pi-ai"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; +import { fetchWithRetry } from "@oh-my-pi/pi-utils"; import { type CompleteOptions, callHostLlm, getHostLlmBackend } from "./llm-backends"; import { getMnemopiRuntimeOptions, @@ -186,16 +188,18 @@ export async function callConfiguredCompletion( return null; } try { - const message = await completeSimple( - model, - { - messages: [{ role: "user", content: prompt, timestamp: Date.now() }], - }, - { - apiKey: llmApiKey() || undefined, - maxTokens: opts.maxTokens ?? llmMaxTokens(), - temperature, - }, + const message = await retryTransientCompletion(() => + completeSimple( + model, + { + messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + }, + { + apiKey: llmApiKey() || undefined, + maxTokens: opts.maxTokens ?? llmMaxTokens(), + temperature, + }, + ), ); return assistantText(message).trim() || null; } catch { @@ -350,11 +354,12 @@ export async function callRemoteLlm( if (key !== "") { headers.Authorization = `Bearer ${key}`; } - const res = await fetchImpl(`${baseUrl}/chat/completions`, { + const res = await fetchWithRetry(`${baseUrl}/chat/completions`, { method: "POST", headers, body, signal: AbortSignal.timeout(60000), + fetch: fetchImpl, }); if (res.status === 401) { throw new ProviderHttpError("mnemopi remote LLM request unauthorized (401)", 401, { headers: res.headers }); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index ed29a1901..8dd3f04aa 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [17.3.5] - 2026-08-16 + +### Fixed + +- Fixed the native `xargs` builtin panicking in `-I`/`-i` replace mode when stdin is empty; it now exits successfully without running the command, matching GNU behavior. +- Fixed inline-code foreground color incorrectly carrying into plain text when a Markdown codespan ended exactly at a soft-wrap boundary. +- Fixed `wrapTextWithAnsi` leaving a trailing space plus a stray underline open/close pair on the line above a soft wrap when a style opened immediately after that space (e.g. `read this thread https://…`), a regression from the codespan color-bleed fix: only sequences that follow visible content now ride along with the current token, while sequences after whitespace still wait for the token they style. + ## [17.3.4] - 2026-08-14 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index e3524a80b..c9dc87c07 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -279,7 +279,7 @@ export declare function __ompInstallTokioRuntime(): void * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV17_3_4(): void +export declare function __piNativesV17_3_5(): void /** * Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index bff75e361..ab0fae547 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -29,7 +29,7 @@ export const Shell = nativeBindings.Shell; // functions export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime; -export const __piNativesV17_3_4 = nativeBindings.__piNativesV17_3_4; +export const __piNativesV17_3_5 = nativeBindings.__piNativesV17_3_5; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; export const astMatch = nativeBindings.astMatch; diff --git a/packages/natives/package.json b/packages/natives/package.json index 2613ae7fc..0a4cbdd28 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "17.3.4", + "version": "17.3.5", "description": "Native Rust bindings for PDF conversion, audio, WebRTC, grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/omptype/package.json b/packages/omptype/package.json index ac1207897..52bc6992a 100644 --- a/packages/omptype/package.json +++ b/packages/omptype/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omptype", - "version": "17.3.4", + "version": "17.3.5", "description": "ArkType-compatible runtime schema validation with lazy JIT compilation", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/snapcompact/package.json b/packages/snapcompact/package.json index 72964e819..5e2451bcb 100644 --- a/packages/snapcompact/package.json +++ b/packages/snapcompact/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/snapcompact", - "version": "17.3.4", + "version": "17.3.5", "description": "Bitmap-frame context compression for vision-capable LLMs", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 35177dc9a..8540c260c 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the stats dashboard being unreachable from container hosts by accepting an explicit `--host` bind address while preserving loopback-only binding and same-origin API access by default. + ## [17.3.0] - 2026-08-13 ### Added diff --git a/packages/stats/package.json b/packages/stats/package.json index 659a562e7..732771f68 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "17.3.4", + "version": "17.3.5", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/stats/src/index.ts b/packages/stats/src/index.ts index cf7e42d5a..e20134654 100755 --- a/packages/stats/src/index.ts +++ b/packages/stats/src/index.ts @@ -4,7 +4,7 @@ import { parseArgs } from "node:util"; import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator"; import { closeDb } from "./db"; -import { startServer } from "./server"; +import { formatStatsDashboardUrl, startServer } from "./server"; export { getDashboardStats, @@ -17,7 +17,7 @@ export { } from "./aggregator"; export { closeDb } from "./db"; export { getGainDashboardStats } from "./gain-aggregator"; -export { startServer } from "./server"; +export { formatStatsDashboardUrl, startServer } from "./server"; export type { GainDashboardStats, GainSource, @@ -96,19 +96,42 @@ async function printStats(): Promise { console.log(""); } -/** - * Main CLI entry point. - */ -async function main(): Promise { +/** Parsed arguments for the standalone `omp-stats` entry point. */ +export interface StandaloneStatsArgs { + port: number; + host: string; + json: boolean; + sync: boolean; + help: boolean; +} + +/** Parse the standalone `omp-stats` arguments used by the production entry point. */ +export function parseStandaloneStatsArgs(args: string[]): StandaloneStatsArgs { const { values } = parseArgs({ + args, options: { port: { type: "string", short: "p", default: "3847" }, + host: { type: "string", default: "127.0.0.1" }, json: { type: "boolean", short: "j", default: false }, sync: { type: "boolean", short: "s", default: false }, help: { type: "boolean", short: "h", default: false }, }, allowPositionals: true, }); + return { + port: parseInt(values.port || "3847", 10), + host: values.host || "127.0.0.1", + json: values.json ?? false, + sync: values.sync ?? false, + help: values.help ?? false, + }; +} + +/** + * Main CLI entry point. + */ +async function main(): Promise { + const values = parseStandaloneStatsArgs(process.argv.slice(2)); if (values.help) { console.log(` @@ -119,6 +142,7 @@ Usage: Options: -p, --port Port for the dashboard server (default: 3847) + --host Host to bind (default: 127.0.0.1) -j, --json Output stats as JSON and exit -s, --sync Sync session files and show summary -h, --help Show this help message @@ -126,7 +150,7 @@ Options: Examples: omp-stats # Start dashboard server omp-stats --json # Print stats as JSON - omp-stats --port 8080 # Start on custom port + omp-stats --host 0.0.0.0 # Explicitly expose on all IPv4 interfaces omp-stats --sync # Sync and show summary `); return; @@ -171,9 +195,8 @@ Examples: } // Start server - const port = parseInt(values.port || "3847", 10); - const { hostname, port: actualPort } = await startServer(port); - console.log(`Dashboard available at: http://${hostname}:${actualPort}`); + const { port: actualPort } = await startServer(values.port, values.host); + console.log(`Dashboard available at: ${formatStatsDashboardUrl(values.host, actualPort)}`); console.log("Press Ctrl+C to stop\n"); // Keep process running diff --git a/packages/stats/src/port-conflict.ts b/packages/stats/src/port-conflict.ts index 74b2273a1..7c5c874d0 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -17,23 +17,28 @@ interface PortHolder { /** Header stamped on every dashboard response so reuse probes can identify us. */ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard"; +/** Header recording the server's requested bind host so reuse cannot change its exposure scope. */ +export const STATS_DASHBOARD_HOSTNAME_HEADER = "x-omp-stats-hostname"; -/** Identity-header value for dashboards enforcing loopback-only, same-origin access. */ -export const STATS_DASHBOARD_SECURITY_VERSION = "2"; +/** Identity-header value for dashboards enforcing an explicit bind host and same-origin access. */ +export const STATS_DASHBOARD_SECURITY_VERSION = "3"; /** IPv4 loopback address shared by the dashboard server and reuse probe. */ export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1"; type StatsDashboardProbe = "reusable" | "occupied" | "unreachable"; -async function probeStatsDashboard(port: number): Promise { +async function probeStatsDashboard(port: number, hostname: string): Promise { + const probeHostname = hostname === "0.0.0.0" ? STATS_DASHBOARD_HOSTNAME : hostname === "::" ? "::1" : hostname; + const urlHostname = probeHostname.includes(":") ? `[${probeHostname}]` : probeHostname; try { - const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, { + const response = await fetch(`http://${urlHostname}:${port}/api/stats/models`, { signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS), }); const reusable = response.status === 200 && response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION && + response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER) === hostname && !response.headers.has("Access-Control-Allow-Origin"); await response.body?.cancel(); return reusable ? "reusable" : "occupied"; @@ -248,16 +253,16 @@ async function reclaimStatsPort(port: number): Promise<"retry"> { * The preflight is needed on platforms that permit wildcard and loopback-specific * listeners to coexist on one port. */ -export async function prepareStatsPort(port: number): Promise<"retry" | "reuse"> { +export async function prepareStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> { if (port === 0) return "retry"; - const probe = await probeStatsDashboard(port); + const probe = await probeStatsDashboard(port, hostname); if (probe === "reusable") return "reuse"; if (probe === "occupied") return reclaimStatsPort(port); return "retry"; } /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */ -export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { - if ((await probeStatsDashboard(port)) === "reusable") return "reuse"; +export async function recoverStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> { + if ((await probeStatsDashboard(port, hostname)) === "reusable") return "reuse"; return reclaimStatsPort(port); } diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index 33dd1097d..b33cfe86e 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -26,6 +26,7 @@ import { recoverStatsPort, STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_HOSTNAME_HEADER, STATS_DASHBOARD_SECURITY_VERSION, } from "./port-conflict"; @@ -306,10 +307,16 @@ async function handleStatic(requestPath: string): Promise { return new Response("Not Found", { status: 404 }); } -function createDashboardServer(port: number) { +/** Format a dashboard origin, including brackets required by IPv6 literals. */ +export function formatStatsDashboardUrl(hostname: string, port: number): string { + const urlHostname = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname; + return `http://${urlHostname}:${port}`; +} + +function createDashboardServer(port: number, hostname: string) { const server = Bun.serve({ port, - hostname: STATS_DASHBOARD_HOSTNAME, + hostname, async fetch(req) { const url = new URL(req.url); const path = url.pathname; @@ -318,6 +325,7 @@ function createDashboardServer(port: number) { // recognize this dashboard without allowing cross-origin API reads. const dashboardHeaders: Record = { [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION, + [STATS_DASHBOARD_HOSTNAME_HEADER]: hostname, }; if (req.method === "OPTIONS") { @@ -358,37 +366,40 @@ function createDashboardServer(port: number) { /** * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener. */ -export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> { +export async function startServer( + port = 3847, + hostname = STATS_DASHBOARD_HOSTNAME, +): Promise<{ hostname: string; port: number; stop: () => void }> { await ensureClientBuild(); - const preparation = await prepareStatsPort(port); + const preparation = await prepareStatsPort(port, hostname); if (preparation === "reuse") { - return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; + return { hostname, port, stop: () => {} }; } try { - const server = createDashboardServer(port); + const server = createDashboardServer(port, hostname); return { - hostname: STATS_DASHBOARD_HOSTNAME, + hostname, port: server.port ?? port, stop: () => server.stop(), }; } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error; - const recovery = await recoverStatsPort(port); + const recovery = await recoverStatsPort(port, hostname); if (recovery === "reuse") { - return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; + return { hostname, port, stop: () => {} }; } try { - const server = createDashboardServer(port); + const server = createDashboardServer(port, hostname); return { - hostname: STATS_DASHBOARD_HOSTNAME, + hostname, port: server.port ?? port, stop: () => server.stop(), }; } catch (retryError) { - throw new Error(`Failed to start stats dashboard on port ${port} after reclaiming it.`, { + throw new Error(`Failed to start stats dashboard on ${hostname}:${port} after reclaiming it.`, { cause: retryError, }); } diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index ae92163bc..35657952f 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -4,6 +4,7 @@ import { connect, type Subprocess } from "bun"; import { STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_HOSTNAME_HEADER, STATS_DASHBOARD_SECURITY_VERSION, } from "../src/port-conflict"; import { startServer } from "../src/server"; @@ -25,6 +26,15 @@ async function tcpConnects(hostname: string, port: number): Promise { return false; } } +function getNonLoopbackHostname(): string | undefined { + const interfaces = networkInterfaces(); + for (const name in interfaces) { + for (const address of interfaces[name] ?? []) { + if (address.family === "IPv4" && !address.internal) return address.address; + } + } + return undefined; +} const holderProcesses: Array> = []; @@ -77,26 +87,34 @@ describe("startServer access", () => { const response = await fetch(`http://${server.hostname}:${server.port}/api/stats/models`); expect(response.status).toBe(200); expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER)).toBe(STATS_DASHBOARD_HOSTNAME); expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); await response.body?.cancel(); - let nonLoopbackHostname: string | undefined; - const interfaces = networkInterfaces(); - for (const name in interfaces) { - const addresses = interfaces[name] ?? []; - for (const address of addresses) { - if (address.family === "IPv4" && !address.internal) { - nonLoopbackHostname = address.address; - break; - } - } - if (nonLoopbackHostname) break; - } - expect(nonLoopbackHostname).toBeDefined(); + const nonLoopbackHostname = getNonLoopbackHostname(); expect(await tcpConnects(server.hostname, server.port)).toBe(true); - if (nonLoopbackHostname) { - expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(false); - } + if (nonLoopbackHostname) expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(false); + } finally { + server.stop(); + } + }); + + it("serves non-loopback requests only when explicitly requested", async () => { + const nonLoopbackHostname = getNonLoopbackHostname(); + if (!nonLoopbackHostname) return; + + const server = await startServer(0, "0.0.0.0"); + + try { + expect(server.hostname).toBe("0.0.0.0"); + expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(true); + + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${server.port}/api/stats/models`); + expect(response.status).toBe(200); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER)).toBe("0.0.0.0"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.body?.cancel(); } finally { server.stop(); } @@ -110,7 +128,12 @@ describe("startServer port conflicts", () => { hostname: STATS_DASHBOARD_HOSTNAME, fetch: request => new URL(request.url).pathname === "/api/stats/models" - ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION } }) + ? Response.json([], { + headers: { + [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION, + [STATS_DASHBOARD_HOSTNAME_HEADER]: STATS_DASHBOARD_HOSTNAME, + }, + }) : new Response("dashboard"), }); diff --git a/packages/stats/test/stats-cli.test.ts b/packages/stats/test/stats-cli.test.ts new file mode 100644 index 000000000..ccd6b19de --- /dev/null +++ b/packages/stats/test/stats-cli.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { parseStandaloneStatsArgs } from "../src/index"; +import { formatStatsDashboardUrl } from "../src/server"; + +describe("standalone stats CLI", () => { + it("keeps the production parser loopback-only by default", () => { + expect(parseStandaloneStatsArgs([])).toEqual({ + port: 3847, + host: "127.0.0.1", + json: false, + sync: false, + help: false, + }); + }); + + it("forwards explicit bind hosts and formats IPv6 dashboard URLs", () => { + expect(parseStandaloneStatsArgs(["--host", "::", "--port", "3850"])).toMatchObject({ + port: 3850, + host: "::", + }); + expect(formatStatsDashboardUrl("::", 3850)).toBe("http://[::]:3850"); + expect(formatStatsDashboardUrl("2001:db8::1", 3850)).toBe("http://[2001:db8::1]:3850"); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a67c956de..f4c044438 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [17.3.5] - 2026-08-16 + +### Fixed + +- Fixed long CPU-bound event-loop stalls being misclassified as system sleep and omitted from loop-blocked diagnostics. +- Fixed focused components with markers falling back to full-screen redraws instead of direct row updates, preserving cursor position and native scrollback across marker changes. + ## [17.3.4] - 2026-08-14 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 2676ae815..6857e7489 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "17.3.4", + "version": "17.3.5", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/tui/src/loop-watchdog.ts b/packages/tui/src/loop-watchdog.ts index b02bc4b9e..79264374c 100644 --- a/packages/tui/src/loop-watchdog.ts +++ b/packages/tui/src/loop-watchdog.ts @@ -6,10 +6,12 @@ export interface LoopWatchdogOptions { intervalMs?: number; /** A tick later than this past its deadline counts as a block. Default 250. */ thresholdMs?: number; - /** Overshoot beyond this likely includes system sleep, so it is suppressed. Default 60_000. */ + /** Overshoot beyond this is suppressed only when the process burned negligible CPU. Default 60_000. */ sleepMs?: number; /** Monotonic clock source; injectable for tests. Default `performance.now`. */ now?: () => number; + /** Process CPU time in ms; injectable for tests. Default `process.cpuUsage`. */ + cpuNow?: () => number; /** Timer source; injectable for tests. Default `setTimeout`. */ schedule?: (cb: () => void, ms: number) => LoopWatchdogTimer; } @@ -23,6 +25,15 @@ interface LoopWatchdogTimer { cancel?(): void; } +/** + * Fraction of a missed interval that may be process CPU time while the gap is + * still treated as system sleep. Keep this near zero: cgroup throttling and + * scheduler contention can make a CPU-bound loop consume far less CPU than wall + * time. One percent allows a little measurement/background jitter while erring + * toward reporting a severe stall instead of hiding it. + */ +const CPU_BUSY_RATIO = 0.01; + /** * Always-on event-loop lag probe. Each tick is scheduled `intervalMs` ahead of * a recorded deadline; a tick that fires `thresholdMs` past its deadline means @@ -35,18 +46,23 @@ interface LoopWatchdogTimer { * The handle is `unref`'d so the probe never keeps the process alive, and stop() * cancels the armed timer when the handle exposes `cancel` (the default * `setTimeout` handle does, via `clearTimeout`). The `#generation` guard remains - * as a fallback for injected handles that cannot cancel. An overshoot beyond - * `sleepMs` is treated as system sleep rather than a synchronous stall: the - * process could not have run JS during the missed interval, and one resume - * should not produce a multi-minute `ui.loop-blocked` record. + * as a fallback for injected handles that cannot cancel. + * + * A long overshoot is classified by CPU time rather than by duration. System + * sleep and a CPU-bound wedge both produce an arbitrarily large gap, so duration + * alone cannot tell them apart, and suppressing on duration discards exactly the + * worst stalls. Only a gap the process spent negligible CPU on is treated as + * sleep. CPU accounting is process-wide, so worker activity errs toward logging. */ export class LoopWatchdog { #intervalMs: number; #thresholdMs: number; #sleepMs: number; #now: () => number; + #cpuNow: () => number; #schedule: (cb: () => void, ms: number) => LoopWatchdogTimer; #expected = 0; + #expectedCpu = 0; #wasBlocked = false; #running = false; // Bumped by stop(); each scheduled tick captures the generation it was armed @@ -60,6 +76,12 @@ export class LoopWatchdog { this.#thresholdMs = options.thresholdMs ?? 250; this.#sleepMs = options.sleepMs ?? 60_000; this.#now = options.now ?? (() => performance.now()); + this.#cpuNow = + options.cpuNow ?? + (() => { + const usage = process.cpuUsage(); + return (usage.user + usage.system) / 1000; + }); this.#schedule = options.schedule ?? ((cb, ms) => { @@ -86,6 +108,7 @@ export class LoopWatchdog { #armTick(): void { const generation = this.#generation; this.#expected = this.#now() + this.#intervalMs; + this.#expectedCpu = this.#cpuNow(); this.#handle = this.#schedule(() => this.#tick(generation), this.#intervalMs); this.#handle.unref?.(); } @@ -93,17 +116,20 @@ export class LoopWatchdog { #tick(generation: number): void { if (!this.#running || generation !== this.#generation) return; const blockedMs = this.#now() - this.#expected; + const cpuMs = this.#cpuNow() - this.#expectedCpu; // Consume the recent phase every tick (block or not) so attribution is // scoped to the just-elapsed interval and never carries a stale phase // forward to a later, phase-less block. const phase = takeRecentLoopPhase(); if (blockedMs > this.#thresholdMs) { - if (blockedMs > this.#sleepMs) { + if (blockedMs > this.#sleepMs && cpuMs < blockedMs * CPU_BUSY_RATIO) { + // A long gap the process did not spend CPU on: it was suspended. this.#wasBlocked = false; } else if (!this.#wasBlocked) { this.#wasBlocked = true; logger.warn("ui.loop-blocked", { blockedMs: Math.round(blockedMs), + cpuMs: Math.round(cpuMs), phase: phase ?? "unknown", }); } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index b4bcdf376..c5f8e8bf8 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1772,13 +1772,76 @@ export class TUI extends Container { markers.length = keep; } + /** + * Replace the cursor markers contributed by one fixed-size root segment. + * The ledger stays sorted by absolute frame row; entries belonging to later + * siblings move as one tail and otherwise retain their identity. + */ + #replaceFrameCursorMarkers(startRow: number, lines: readonly string[]): void { + const markers = this.#frameCursorMarkers; + const endRow = startRow + lines.length; + let intervalStart = 0; + while (intervalStart < markers.length && markers[intervalStart]!.row < startRow) intervalStart++; + let intervalEnd = intervalStart; + while (intervalEnd < markers.length && markers[intervalEnd]!.row < endRow) intervalEnd++; + + let nextCount = 0; + for (let row = 0; row < lines.length; row++) { + if (lines[row]!.includes(CURSOR_MARKER)) nextCount++; + } + + const previousCount = intervalEnd - intervalStart; + const delta = nextCount - previousCount; + const previousLength = markers.length; + if (delta > 0) { + markers.length = previousLength + delta; + for (let index = previousLength - 1; index >= intervalEnd; index--) { + markers[index + delta] = markers[index]!; + } + } else if (delta < 0) { + for (let index = intervalEnd; index < previousLength; index++) { + markers[index + delta] = markers[index]!; + } + markers.length = previousLength + delta; + } + + const reusableCount = Math.min(previousCount, nextCount); + let markerSlot = intervalStart; + for (let row = 0; row < lines.length; row++) { + const line = lines[row]!; + const markerIndex = line.indexOf(CURSOR_MARKER); + if (markerIndex === -1) continue; + const absoluteRow = startRow + row; + const col = visibleWidth(line.slice(0, markerIndex)); + if (markerSlot < intervalStart + reusableCount) { + const marker = markers[markerSlot]!; + marker.row = absoluteRow; + marker.col = col; + } else { + markers[markerSlot] = { row: absoluteRow, col }; + } + markerSlot++; + } + } + + /** Strip every internal cursor sentinel from one rendered row. */ + #stripCursorMarkers(line: string, markerIndex = line.indexOf(CURSOR_MARKER)): string { + if (markerIndex === -1) return line; + let stripped = line; + while (markerIndex !== -1) { + stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length); + markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex); + } + return stripped; + } + /** * Append one row to the composed frame, stripping CURSOR_MARKER occurrences * (internal sentinels that must never reach the terminal, the committed * prefix, or the resync audit) and recording the first marker's position. */ #ingestFrameRow(line: string): void { - let markerIndex = line.indexOf(CURSOR_MARKER); + const markerIndex = line.indexOf(CURSOR_MARKER); if (markerIndex === -1) { this.#composedFrame.push(line); return; @@ -1787,12 +1850,7 @@ export class TUI extends Container { row: this.#composedFrame.length, col: visibleWidth(line.slice(0, markerIndex)), }); - let stripped = line; - while (markerIndex !== -1) { - stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length); - markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex); - } - this.#composedFrame.push(stripped); + this.#composedFrame.push(this.#stripCursorMarkers(line, markerIndex)); } #syncTerminalCursorMode(component: Component | null): void { @@ -2558,12 +2616,6 @@ export class TUI extends Container { this.requestComponentRender(component); return; } - for (const line of nextLines) { - if (line.includes(CURSOR_MARKER)) { - this.requestComponentRender(component); - return; - } - } let firstChanged = -1; let lastChanged = -1; @@ -2571,8 +2623,9 @@ export class TUI extends Container { for (let i = 0; i < nextLines.length; i++) { const frameRow = segment.start + i; const raw = nextLines[i]!; - const prepared = this.#prepareLine(raw, width); - this.#composedFrame[frameRow] = raw; + const composed = this.#stripCursorMarkers(raw); + const prepared = this.#prepareLine(composed, width); + this.#composedFrame[frameRow] = composed; this.#preparedMeta[frameRow] = prepared; this.#preparedFrame[frameRow] = prepared.line; if (previousWindow[screenStart + i] === prepared.line) continue; @@ -2580,7 +2633,8 @@ export class TUI extends Container { if (firstChanged === -1) firstChanged = i; lastChanged = i; } - segments[segmentIndex] = { ...segment, lines: nextLines }; + this.#replaceFrameCursorMarkers(segment.start, nextLines); + segment.lines = nextLines; this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length); this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start); diff --git a/packages/tui/test/component-render.test.ts b/packages/tui/test/component-render.test.ts index 0d6fd6950..d027c27bf 100644 --- a/packages/tui/test/component-render.test.ts +++ b/packages/tui/test/component-render.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { type Component, Container, + CURSOR_MARKER, Editor, type Focusable, type NativeScrollbackCommittedRows, @@ -71,6 +72,10 @@ class AnchoredStatusContainer extends Container implements NativeScrollbackLiveR const hasAnchoredRows = this.children.length > 0; return hasAnchoredRows ? 0 : undefined; } + + isNativeScrollbackLiveRegionPinned(): boolean { + return true; + } } function strip(rows: string[]): string[] { @@ -81,6 +86,16 @@ function visible(term: VirtualTerminal): string[] { return strip(term.getViewport()).filter(row => row.length > 0); } +function captureWrites(term: VirtualTerminal): string[] { + const writes: string[] = []; + const write = term.write.bind(term); + term.write = (data: string): void => { + writes.push(data); + write(data); + }; + return writes; +} + class RenderCountingTUI extends TUI { renders = 0; @@ -351,6 +366,46 @@ describe("TUI.requestComponentRender", () => { await term.flush(); } }); + it("keeps removed pinned panels and repeated transcript copies out of scrollback", async () => { + const term = new VirtualTerminal(40, 8, 1_000); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + const markers = Array.from({ length: 5 }, (_unused, index) => `HIST-${index}`); + const transcript = new CountingLines(markers); + const status = new AnchoredStatusContainer(); + const editor = new CountingLines([`editor${CURSOR_MARKER}`]); + tui.addChild(transcript); + tui.addChild(status); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + for (let cycle = 1; cycle <= 3; cycle++) { + const panel = new CountingLines([`panel-${cycle}-0`]); + status.addChild(panel); + tui.requestRender(); + await scheduler.drain(term); + for (let tick = 1; tick <= 8; tick++) { + panel.set(Array.from({ length: tick + 1 }, (_row, index) => `panel-${cycle}-${tick}-${index}`)); + tui.requestComponentRender(panel); + await scheduler.drain(term); + } + status.clear(); + tui.requestRender(); + await scheduler.drain(term); + } + + const buffer = strip(term.getScrollBuffer()).join("\n"); + for (const marker of markers) { + expect(buffer.split(marker)).toHaveLength(2); + } + expect(buffer).not.toContain("panel-"); + } finally { + tui.stop(); + await term.flush(); + } + }); }); describe("TUI keystroke-scoped render", () => { @@ -538,6 +593,202 @@ describe("TUI.requestDirectWrite", () => { } }); + it("directly strips markers while updating ANSI and wide-grapheme cursor geometry", async () => { + const term = new VirtualTerminal(40, 5, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const head = new CountingLines(["head"]); + const editor = new CountingLines(["plain"]); + const footer = new CountingLines(["footer"]); + tui.addChild(head); + tui.addChild(editor); + tui.addChild(footer); + + try { + tui.start(); + await scheduler.drain(term); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set([`\x1b[31m好a${CURSOR_MARKER}b${CURSOR_MARKER}\x1b[0m`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["head", "好ab", "footer", "", ""]); + expect(term.getCursor()).toEqual({ row: 1, col: 3 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + + writes.length = 0; + editor.set(["done"]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["head", "done", "footer", "", ""]); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).toContain("\x1b[?25l"); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("moves a marker without repainting unchanged row bytes", async () => { + const term = new VirtualTerminal(40, 4, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const editor = new CountingLines([`ab${CURSOR_MARKER}cd`, "efgh"]); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]); + expect(term.getCursor()).toEqual({ row: 0, col: 2 }); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set(["abcd", `e${CURSOR_MARKER}fgh`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]); + expect(term.getCursor()).toEqual({ row: 1, col: 1 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain("abcd"); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("preserves later sibling precedence across marker interval growth, shrink, and recompose", async () => { + const term = new VirtualTerminal(40, 8, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const earlier = new CountingLines(["early"]); + const target = new CountingLines(["t0", "t1", "t2"]); + const later = new CountingLines([`later${CURSOR_MARKER}`]); + tui.addChild(earlier); + tui.addChild(target); + tui.addChild(later); + + try { + tui.start(); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 4, col: 5 }); + const tuiRenders = tui.renders; + writes.length = 0; + + target.set([`${CURSOR_MARKER}t0`, `t${CURSOR_MARKER}1`, `t2${CURSOR_MARKER}`]); + tui.requestDirectWrite(target); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 4, col: 5 }); + expect(tui.renders).toBe(tuiRenders); + + target.set(["t0", `t${CURSOR_MARKER}1`, "t2"]); + tui.requestDirectWrite(target); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 4, col: 5 }); + expect(tui.renders).toBe(tuiRenders); + + later.set(["later"]); + tui.requestDirectWrite(later); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 2, col: 1 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + + const targetRenders = target.renders; + writes.length = 0; + earlier.set(["early-new"]); + tui.requestComponentRender(earlier); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["early-new", "t0", "t1", "t2", "later", "", "", ""]); + expect(term.getCursor()).toEqual({ row: 2, col: 1 }); + expect(tui.renders).toBeGreaterThan(tuiRenders); + expect(target.renders).toBe(targetRenders); + expect(writes.join("")).toContain("\x1b[?25h"); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("keeps non-empty native scrollback byte-for-byte stable during marker direct writes", async () => { + const term = new VirtualTerminal(30, 4, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const transcript = new CountingLines(Array.from({ length: 8 }, (_unused, row) => `history-${row}`)); + const editor = new CountingLines(["anim-0"]); + tui.addChild(transcript); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + const beforeBuffer = term.getScrollBuffer(); + const beforeHistory = beforeBuffer.slice(0, Math.max(0, beforeBuffer.length - term.rows)); + expect(beforeHistory.length).toBeGreaterThan(0); + expect(beforeHistory.some(row => row.trimEnd().length > 0)).toBe(true); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set([`anim-1${CURSOR_MARKER}`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + const afterBuffer = term.getScrollBuffer(); + const afterHistory = afterBuffer.slice(0, Math.max(0, afterBuffer.length - term.rows)); + expect(afterHistory).toEqual(beforeHistory); + expect(strip(term.getViewport())).toEqual(["history-5", "history-6", "history-7", "anim-1"]); + expect(term.getCursor()).toEqual({ row: 3, col: 6 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("falls back safely when marker-bearing output changes row count", async () => { + const term = new VirtualTerminal(40, 5, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const head = new CountingLines(["head"]); + const editor = new CountingLines([`one${CURSOR_MARKER}`]); + tui.addChild(head); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set([`one${CURSOR_MARKER}`, `two${CURSOR_MARKER}`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["head", "one", "two", "", ""]); + expect(term.getCursor()).toEqual({ row: 2, col: 3 }); + expect(tui.renders).toBeGreaterThan(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + it("directly rewrites fully live anchored status segments", async () => { const term = new VirtualTerminal(40, 8, 1_000); const scheduler = new StressRenderScheduler(); diff --git a/packages/tui/test/loop-watchdog.test.ts b/packages/tui/test/loop-watchdog.test.ts index fad11641e..1fe07e59d 100644 --- a/packages/tui/test/loop-watchdog.test.ts +++ b/packages/tui/test/loop-watchdog.test.ts @@ -94,7 +94,7 @@ describe("LoopWatchdog", () => { const { wd, setNow, fireTick } = harness({ sleepMs: 5_000 }); wd.start(); // deadline at 250 - setNow(10_250); // blockedMs = 10_000 exceeds the sleep cutoff + setNow(10_250); // blockedMs = 10_000 exceeds the sleep cutoff, and no CPU was burned fireTick(); expect(warnSpy).not.toHaveBeenCalled(); @@ -225,3 +225,78 @@ describe("LoopWatchdog", () => { expect(cancel).toHaveBeenCalledTimes(1); }); }); + +/** + * A long overshoot is classified by CPU time, not by duration. System sleep and + * a CPU-bound wedge both produce an arbitrarily large gap, so duration alone + * cannot separate them — and suppressing on duration discards exactly the worst + * stalls. Issue #5372 reported an 82,391ms block that older builds logged and + * current builds drop silently. + */ +describe("LoopWatchdog long-block classification", () => { + function cpuHarness(options: Partial<{ intervalMs: number; thresholdMs: number; sleepMs: number }> = {}) { + let nowValue = 0; + let cpuValue = 0; + let scheduled: (() => void) | undefined; + const wd = new LoopWatchdog({ + now: () => nowValue, + cpuNow: () => cpuValue, + schedule: (cb: () => void) => { + scheduled = cb; + return {}; + }, + ...options, + }); + return { + wd, + set(now: number, cpu: number): void { + nowValue = now; + cpuValue = cpu; + }, + fireTick(): void { + const cb = scheduled; + if (!cb) throw new Error("no tick was scheduled"); + cb(); + }, + }; + } + + test("reports a CPU-bound wedge longer than sleepMs instead of discarding it", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const h = cpuHarness(); + + h.wd.start(); // deadline 250, cpu baseline 0 + // 82,391ms of wall clock, essentially all of it burned on a core. + h.set(82_641, 82_000); + h.fireTick(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const ctx = warnSpy.mock.calls[0]![1] as { blockedMs: number; cpuMs: number }; + expect(ctx.blockedMs).toBe(82_391); + expect(ctx.cpuMs).toBeGreaterThan(80_000); + }); + + test("still suppresses a suspend/resume gap of the same duration", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const h = cpuHarness(); + + h.wd.start(); + // Same wall gap, but the process was suspended: no CPU consumed. + h.set(82_641, 3); + h.fireTick(); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("reports a long CPU-bound block when the process is CPU throttled", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const h = cpuHarness(); + + h.wd.start(); + // Ten percent CPU is still real work, not suspension. + h.set(82_641, 8_200); + h.fireTick(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index edea7de8d..cf21f7f40 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -302,6 +302,27 @@ Average Latency: 1,240 ms expect(plainLines.filter(line => line.includes("+"))).toHaveLength(0); }); + it("keeps a four-space-indented tree child inside its paragraph", () => { + const markdown = new Markdown( + `Two verified cases: + +├── **case 1** — first branch +│ └── each family has its own counter +└── **case 3: unnumbered limits** + └── limits that carry **only a label** are dropped from the list`, + 0, + 0, + defaultMarkdownTheme, + ); + const plainLines = markdown.render(80).map(line => stripVTControlCharacters(line).trimEnd()); + + // The attached indented line is a lazy paragraph continuation, never an + // indented code block: no fence border rows, no literal bold markers. + expect(plainLines.filter(line => line.includes("```"))).toHaveLength(0); + expect(plainLines.some(line => line.includes("only a label"))).toBe(true); + expect(plainLines.some(line => line.includes("**"))).toBe(false); + }); + it("keeps an unfinished code block with a rule and heading literal when no table follows", () => { const markdown = new Markdown( `The process printed this diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index 793e22e2e..c191c53e4 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -7,6 +7,7 @@ import { type NativeScrollbackLiveRegion, TUI, } from "@oh-my-pi/pi-tui"; +import { VirtualRenderScheduler } from "./virtual-render-scheduler"; import { VirtualTerminal } from "./virtual-terminal"; // Law-encoding suite for native-scrollback commits. @@ -107,21 +108,18 @@ class CommittedRowsWireProbe extends CommittedRowsProbe { } } +/** One scheduler per file; `beforeEach` rewinds it so each test starts at t=0 with an empty queue. */ +const scheduler = new VirtualRenderScheduler(); + async function settle(term: VirtualTerminal): Promise { - const nextTick = Promise.withResolvers(); - process.nextTick(nextTick.resolve); - await nextTick.promise; - await Bun.sleep(40); - await term.flush(); + await scheduler.settle(term); } // The non-multiplexer resize fast path paints the viewport at once and defers // the authoritative full replay (the ED3 scrollback rebuild) until the drag has -// been quiet for the resize settle window (120 ms). This is an integration test -// against the real render scheduler, so the window is driven with a real delay. +// been quiet for the resize settle window (120 ms). Open that window explicitly. async function settleResize(term: VirtualTerminal): Promise { - await Bun.sleep(160); - await settle(term); + await scheduler.advance(term, 160); } function capture(term: VirtualTerminal): string[] { @@ -197,6 +195,7 @@ function restoreTerminalEnv(saved: Record): void { describe("streaming scrollback — visual record", () => { let savedTerminalEnv: Record = {}; beforeEach(() => { + scheduler.reset(); savedTerminalEnv = saveTerminalEnv(); }); afterEach(() => { @@ -208,7 +207,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const sealed = new LineList(rows("prior-", 12)); const live = new SeamLineList([]); @@ -254,7 +253,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const live = new SeamLineList([]); try { @@ -286,7 +285,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(60, 8, 1_000); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const wall = new PinnedSeamLineList([]); try { @@ -323,7 +322,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); // An append-only streaming reply declares every rendered row final // (Infinity clamps to the rendered length): its scrolled-off head enters // the verified zone and never needs a finalize-time repair. @@ -357,7 +356,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(24, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const sealed = new LineList(rows("prior-", 12)); const live = new SeamLineList([]); @@ -407,7 +406,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(24, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const sealed = new LineList(rows("prior-", 12)); const live = new SeamLineList([]); // Status loader below the transcript: also reports a seam. Exactness is @@ -458,7 +457,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(40, 10); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const component = new LineList([...rows("init-", 10), "prompt"]); try { @@ -501,7 +500,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(40, 10); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const component = new LineList([...rows("init-", 10), "prompt"]); try { @@ -536,7 +535,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const sealed = new LineList(rows("prior-", 12)); const live = new SeamLineList([]); @@ -571,7 +570,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(24, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const sealed = new LineList(rows("base-", 12)); try { @@ -608,7 +607,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(40, 10); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const component = new LineList([...rows("init-", 5), "prompt"]); try { @@ -649,7 +648,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const probe = new CommittedRowsProbe([]); try { @@ -677,7 +676,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(40, 8); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const probe = new CommittedRowsWireProbe([]); try { @@ -726,7 +725,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(40, 8); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); // Content taller than the viewport before the first paint: the initial // frame takes the full-paint path, whose replay commits (frame - height) // rows in one shot on a separate exit from the ordinary update emit. @@ -753,7 +752,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(40, 8); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); // A short header above a tall overflowing body: the engine's committed // boundary sails past the header's 2-row extent. Both feeds are in the // child's own coordinates and must saturate at what the child actually @@ -801,7 +800,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); // A block that rewrites an interior row every frame (a streaming table // re-aligning, a collapsing preview). Its scrolled rows are frozen // snapshots: drift never sprays re-anchors; the single strict scan at @@ -857,7 +856,7 @@ describe("streaming scrollback — visual record", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); // The block declares its whole body final, commits, then violates the // contract by rewriting TWO committed rows (alignment breaks, so the // tail-sample tolerance cannot absorb it). The audit re-anchors, the @@ -908,7 +907,7 @@ describe("scrollback commit gap — live barriers", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { @@ -946,7 +945,7 @@ describe("scrollback commit gap — live barriers", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { @@ -983,7 +982,7 @@ describe("scrollback commit gap — live barriers", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { @@ -1021,7 +1020,7 @@ describe("scrollback commit gap — live barriers", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 5); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const barrier = new SeamLineList(["[tool pending]"]); const tail = new LineList(rows("out-", 10)); @@ -1058,7 +1057,7 @@ describe("scrollback commit gap — live barriers", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 5); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { @@ -1098,7 +1097,7 @@ describe("scrollback commit gap — live barriers", () => { // — so finalize needs NO repair and the tape never duplicates a byte. const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { @@ -1137,7 +1136,7 @@ describe("scrollback commit gap — live barriers", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { @@ -1177,7 +1176,7 @@ describe("scrollback commit gap — live barriers", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { @@ -1228,7 +1227,7 @@ describe("scrollback divergence — multiplexer fallback", () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); - const tui = new TUI(term); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const root = new SeamLineList([]); try { diff --git a/packages/tui/test/virtual-render-scheduler.ts b/packages/tui/test/virtual-render-scheduler.ts new file mode 100644 index 000000000..80ee85027 --- /dev/null +++ b/packages/tui/test/virtual-render-scheduler.ts @@ -0,0 +1,128 @@ +import type { RenderScheduler, RenderTimer } from "@oh-my-pi/pi-tui/tui"; +import type { VirtualTerminal } from "./virtual-terminal"; + +/** + * Longest delay a throttled frame can ask for: the 30 Hz cadence. Adaptive + * backpressure adds nothing under a virtual clock (a frame costs 0 virtual ms), + * and every deferred window the engine arms is longer — multiplexer resize + * debounce 50 ms, resize viewport settle 120 ms, ConPTY post-paint settle + * 150 ms — so this horizon separates "frames the pipeline owes us now" from + * "windows a test must open deliberately". + */ +const FRAME_HORIZON_MS = 40; + +/** Bound on drain rounds; a render loop that keeps re-arming is a bug, not a wait. */ +const MAX_ROUNDS = 500; + +interface PendingTimer { + at: number; + run: () => void; +} + +/** + * Deterministic {@link RenderScheduler} with a virtual clock. + * + * `TUI` reads every timestamp through `RenderScheduler.now()`, so owning the + * clock removes wall-clock races from render assertions: a loaded machine can + * neither lose a throttled frame (sleep too short) nor open a deferred settle + * window early (sleep too long). + * + * ```ts + * const scheduler = new VirtualRenderScheduler(); + * const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + * tui.start(); + * await scheduler.settle(term); // every frame the engine owes, nothing more + * await scheduler.advance(term, 160); // open the resize settle window + * ``` + */ +export class VirtualRenderScheduler implements RenderScheduler { + #now = 0; + #nextTimerId = 0; + #immediates: (() => void)[] = []; + #timers = new Map(); + + now(): number { + return this.#now; + } + + scheduleImmediate(callback: () => void): void { + this.#immediates.push(callback); + } + + scheduleRender(callback: () => void, delayMs: number): RenderTimer { + const id = this.#nextTimerId; + this.#nextTimerId += 1; + this.#timers.set(id, { at: this.#now + Math.max(0, delayMs), run: callback }); + return { + cancel: () => { + this.#timers.delete(id); + }, + }; + } + + /** Drop every queued frame and rewind the clock, for reuse across tests. */ + reset(): void { + this.#now = 0; + this.#immediates = []; + this.#timers.clear(); + } + + /** + * Run the frames the engine owes — immediates plus timers landing within + * `horizonMs` of the clock, including the ones those frames schedule — until + * the pipeline is quiescent. Deferred windows stay armed. + */ + async settle(term: VirtualTerminal, horizonMs = FRAME_HORIZON_MS): Promise { + await this.#drain(term, () => this.#now + horizonMs); + } + + /** + * Open a deferred window: advance the clock by `ms`, running every frame that + * comes due on the way, then settle the frames that follow it. + */ + async advance(term: VirtualTerminal, ms: number): Promise { + const deadline = this.#now + ms; + await this.#drain(term, () => deadline); + this.#now = Math.max(this.#now, deadline); + await this.settle(term); + } + + async #drain(term: VirtualTerminal, deadline: () => number): Promise { + for (let round = 0; round < MAX_ROUNDS; round++) { + if (!this.#runImmediates() && !this.#runDue(deadline())) return; + // Terminal writes land synchronously; yielding lets promise + // continuations queue their renders before the next round. + await term.flush(); + } + throw new Error(`VirtualRenderScheduler did not settle after ${MAX_ROUNDS} rounds`); + } + + #runImmediates(): boolean { + if (this.#immediates.length === 0) return false; + const queued = this.#immediates; + this.#immediates = []; + for (const run of queued) run(); + return true; + } + + /** Fire the earliest timer batch due at or before `deadline`, advancing the clock to it. */ + #runDue(deadline: number): boolean { + let earliest: number | undefined; + for (const timer of this.#timers.values()) { + if (timer.at <= deadline && (earliest === undefined || timer.at < earliest)) earliest = timer.at; + } + if (earliest === undefined) return false; + this.#now = Math.max(this.#now, earliest); + const due: number[] = []; + for (const [id, timer] of this.#timers) { + if (timer.at === earliest) due.push(id); + } + for (const id of due) { + const timer = this.#timers.get(id); + if (!timer) continue; + this.#timers.delete(id); + timer.run(); + } + return true; + } +} diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 3bab19b75..a1ca33818 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.3.5] - 2026-08-16 + +### Fixed + +- Fixed the Markdown renderer incorrectly breaking into a raw code block when a 4-space-indented line (such as a box-drawing tree child under a └── branch) directly followed paragraph text; it now correctly stays part of the paragraph, matching standard Markdown behavior. + ## [17.3.2] - 2026-08-13 ### Fixed diff --git a/packages/utils/package.json b/packages/utils/package.json index a8ee10856..0739653db 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "17.3.4", + "version": "17.3.5", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/utils/src/marked/core.ts b/packages/utils/src/marked/core.ts index c9f30bc6d..6bf733fcc 100644 --- a/packages/utils/src/marked/core.ts +++ b/packages/utils/src/marked/core.ts @@ -1071,8 +1071,20 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] { } let raw = line; i++; - while (i < lines.length && !isBlockStart(lines, i)) { - raw += lines[i++]!; + // An indented code block cannot interrupt a paragraph (CommonMark lazy + // continuation): a line indented by at least 4 spaces directly attached to + // paragraph text stays inside the paragraph, bypassing every block-start + // probe — matching marked's paragraph rule. After a whitespace-padded + // blank line the indent is no longer attached, so it still opens an + // indented code block. + let prevBlankish = false; + while (i < lines.length) { + const next = lines[i]!; + const indented = next.trim() !== "" && /^ {4}/.test(next); + if (indented ? prevBlankish : isBlockStart(lines, i)) break; + prevBlankish = next.trim() === ""; + raw += next; + i++; } const text = stripFinalNewline(raw); const tokens: Token[] = []; diff --git a/packages/utils/src/stream.ts b/packages/utils/src/stream.ts index 36ff5380e..02dd5433b 100644 --- a/packages/utils/src/stream.ts +++ b/packages/utils/src/stream.ts @@ -446,12 +446,13 @@ export async function* readSseEvents( * Uses `Bun.JSONL.parseChunk` internally. On parse errors, the malformed * region is skipped up to the next newline and parsing continues. * + * @param options.onMalformedRecord Called once for every skipped JSONL record. * @example * ```ts * const entries = parseJsonlLenient(fileContents); * ``` */ -export function parseJsonlLenient(buffer: string): T[] { +export function parseJsonlLenient(buffer: string, options: { onMalformedRecord?: () => void } = {}): T[] { let entries: T[] | undefined; while (buffer.length > 0) { @@ -466,11 +467,16 @@ export function parseJsonlLenient(buffer: string): T[] { } if (error) { const nextNewline = buffer.indexOf("\n", read); + const malformedEnd = nextNewline === -1 ? buffer.length : nextNewline; + if (buffer.substring(read, malformedEnd).trim().length > 0) options.onMalformedRecord?.(); if (nextNewline === -1) break; buffer = buffer.substring(nextNewline + 1); continue; } - if (read === 0) break; + if (read === 0) { + if (buffer.trim().length > 0) options.onMalformedRecord?.(); + break; + } buffer = buffer.substring(read); if (done) break; } diff --git a/packages/utils/test/fixtures/marked/goldens.json b/packages/utils/test/fixtures/marked/goldens.json index 1b8a00536..68e7e7fd7 100644 --- a/packages/utils/test/fixtures/marked/goldens.json +++ b/packages/utils/test/fixtures/marked/goldens.json @@ -954,5 +954,35 @@ } ], "html": "

\nraw *html*\n

inline x end

\n" + }, + { + "name": "lazyContinuation", + "source": "tree root\n└── last branch\n └── child under last branch\n\n real indented code\n", + "tokens": [ + { + "type": "paragraph", + "raw": "tree root\n└── last branch\n └── child under last branch", + "text": "tree root\n└── last branch\n └── child under last branch", + "tokens": [ + { + "type": "text", + "raw": "tree root\n└── last branch\n └── child under last branch", + "text": "tree root\n└── last branch\n └── child under last branch", + "escaped": false + } + ] + }, + { + "type": "space", + "raw": "\n\n" + }, + { + "type": "code", + "raw": " real indented code\n", + "codeBlockStyle": "indented", + "text": "real indented code\n" + } + ], + "html": "

tree root\n└── last branch\n └── child under last branch

\n
real indented code\n
\n" } ] diff --git a/packages/utils/test/marked.test.ts b/packages/utils/test/marked.test.ts index c53ac7408..71a1ab401 100644 --- a/packages/utils/test/marked.test.ts +++ b/packages/utils/test/marked.test.ts @@ -81,6 +81,51 @@ describe("marked compatibility", () => { }); } + // Lazy-continuation boundary shapes (behavior cross-checked against marked + // v18): an indented code block cannot interrupt a paragraph, so an indented + // line directly attached to paragraph text stays in the paragraph even when + // a setext/hr lookahead matches downstream — while a whitespace-padded + // blank line detaches it, so the next indented run still opens indented + // code. Documented divergences from marked kept as-is: marked dedents the + // attached line inside `text` via its code-merge path, and it splits a + // padded blank into a `space` token where this lexer keeps the padded + // blank inside the paragraph raw. + const lazyBoundaryShapes: Array<[string, Array<[string, string]>]> = [ + [ + "lead\n \n code\n", + [ + ["paragraph", "lead\n \n"], + ["code", " code\n"], + ], + ], + [ + "lead\n attached\n---\n", + [ + ["paragraph", "lead\n attached\n"], + ["hr", "---\n"], + ], + ], + [ + "lead\n deeper attached\n---\n", + [ + ["paragraph", "lead\n deeper attached\n"], + ["hr", "---\n"], + ], + ], + [ + "lead\n \n deeper code\n", + [ + ["paragraph", "lead\n \n"], + ["code", " deeper code\n"], + ], + ], + ]; + for (const [source, shape] of lazyBoundaryShapes) { + test(`keeps the lazy-continuation boundary shape for ${JSON.stringify(source)}`, () => { + expect([...Lexer.lex(source)].map(token => [token.type, token.raw])).toEqual(shape); + }); + } + test("runs block and inline tokenizer/renderer extensions", () => { const latexBlock: TokenizerAndRendererExtension = { name: "latexBlock", diff --git a/packages/wire/package.json b/packages/wire/package.json index 01778d69f..957ee9488 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-wire", - "version": "17.3.4", + "version": "17.3.5", "description": "Shared wire protocol types for Oh My Pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/scripts/bazel-natives.ts b/scripts/bazel-natives.ts index b60fe92fd..8bb0f4c0b 100755 --- a/scripts/bazel-natives.ts +++ b/scripts/bazel-natives.ts @@ -27,6 +27,8 @@ * * Set `OMP_NATIVE_BUILD_BACKEND=cargo` to route the host target through the * same local N-API build on systems where Bazel's prebuilt host tools cannot run. + * The host target also selects that backend automatically when neither bazelisk + * nor bazel is available on PATH. * * Note: musl addons intentionally reuse the plain linux- filenames, so a * `linux-all` copy overwrites the gnu addon with the musl one (and vice versa); @@ -134,6 +136,8 @@ export interface CliOptions { dest: string | null; source: string | null; bazelArgs: string[]; + /** Force the local Cargo/N-API host build path, skipping bazel. Same semantics as `OMP_NATIVE_BUILD_BACKEND=cargo`. */ + cargo: boolean; } /** Parse target names and the mutually exclusive build or artifact source options. */ @@ -141,6 +145,7 @@ export function parseCliArgs(argv: string[]): CliOptions { const targets: string[] = []; let dest: string | null = null; let source: string | null = null; + let cargo = false; const bazelArgs: string[] = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -158,6 +163,10 @@ export function parseCliArgs(argv: string[]): CliOptions { } continue; } + if (arg === "--cargo") { + cargo = true; + continue; + } if (arg.startsWith("-")) { throw new Error(`Unknown flag ${arg} (extra bazel args go after \`--\`)`); } @@ -165,23 +174,23 @@ export function parseCliArgs(argv: string[]): CliOptions { } if (targets.length === 0) { throw new Error( - "Usage: bun scripts/bazel-natives.ts ... [--dest ] [--source ] [-- ]", + "Usage: bun scripts/bazel-natives.ts ... [--cargo] [--dest ] [--source ] [-- ]", ); } if (source && bazelArgs.length > 0) { throw new Error("--source cannot be combined with extra bazel arguments"); } - return { targets, dest, source, bazelArgs }; + if (cargo && source) { + throw new Error("--cargo cannot be combined with --source"); + } + if (cargo && bazelArgs.length > 0) { + throw new Error("--cargo cannot be combined with extra bazel arguments"); + } + return { targets, dest, source, bazelArgs, cargo }; } -function resolveBazelBinary(): string { - const bin = Bun.which("bazelisk") ?? Bun.which("bazel"); - if (!bin) { - throw new Error( - "Neither `bazelisk` nor `bazel` found on PATH. Install bazelisk: https://github.com/bazelbuild/bazelisk", - ); - } - return bin; +function resolveBazelBinary(): string | null { + return Bun.which("bazelisk") ?? Bun.which("bazel"); } const STDERR_TAIL_LINES = 40; @@ -250,7 +259,8 @@ async function main(): Promise { const host: HostInfo = { platform: process.platform, arch: process.arch, avx2: detectHostAvx2Support() }; const destDir = options.dest ? path.resolve(options.dest) : path.join(repoRoot, "packages/natives/native"); - if ((host.platform === "win32" || Bun.env.OMP_NATIVE_BUILD_BACKEND === "cargo") && !options.source) { + const cargoBackend = options.cargo || Bun.env.OMP_NATIVE_BUILD_BACKEND === "cargo"; + if ((host.platform === "win32" || cargoBackend) && !options.source) { if (options.targets.length !== 1 || options.targets[0] !== "host") { if (host.platform === "win32") { throw new Error( @@ -259,7 +269,7 @@ async function main(): Promise { "(local napi build via VS Build Tools), or run this script from WSL/linux for cross targets.", ); } - throw new Error("OMP_NATIVE_BUILD_BACKEND=cargo supports only the host target"); + throw new Error("--cargo / OMP_NATIVE_BUILD_BACKEND=cargo supports only the host target"); } await buildLocalHostAddon(host, destDir); return; @@ -274,6 +284,17 @@ async function main(): Promise { } else { const labels = resolveTargetLabels(options.targets, host); const bazel = resolveBazelBinary(); + if (!bazel) { + if (options.targets.length !== 1 || options.targets[0] !== "host") { + throw new Error( + `Neither \`bazelisk\` nor \`bazel\` found on PATH; Cargo fallback supports only the host target, not [${options.targets.join(", ")}]. ` + + "Install bazelisk or request `host` for a local Cargo/N-API build.", + ); + } + console.log("bazelisk/bazel not found; falling back to the local Cargo/N-API host build"); + await buildLocalHostAddon(host, destDir); + return; + } // CI hands cache wiring (remote or disk) through a bazelrc fragment so // endpoint composition stays in .github/actions/bazel-cache. const rcPath = Bun.env.OMP_BAZEL_RC?.trim(); diff --git a/scripts/ci-test-ts.test.ts b/scripts/ci-test-ts.test.ts new file mode 100644 index 000000000..2b73cb41a --- /dev/null +++ b/scripts/ci-test-ts.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { describeChunkFailure } from "./ci-test-ts.ts"; + +// The two ways a chunk reaches SIGKILL are indistinguishable by exit code, so +// these drive real subprocesses to produce a genuine 137 rather than asserting +// against a hand-written constant. +async function spawnExitCode(script: string): Promise { + const proc = Bun.spawn(["sh", "-c", script], { stdout: "ignore", stderr: "ignore" }); + return await proc.exited; +} + +// Re-hosts the sequential runner's failure tail: spawn, watchdog, attribute. +// `runTestCommand` itself is not injectable (it builds argv from the repo +// layout), so the decision under test is driven directly. +async function runWithWatchdog(script: string, timeoutMs: number): Promise { + const proc = Bun.spawn(["sh", "-c", script], { stdout: "ignore", stderr: "ignore" }); + let timedOut = false; + const killTimer = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + }, timeoutMs); + const exitCode = await proc.exited; + clearTimeout(killTimer); + return describeChunkFailure(exitCode, timedOut); +} + +describe("describeChunkFailure", () => { + test("a real SIGKILL that the watchdog did not cause is attributed to the OOM killer", async () => { + const exitCode = await spawnExitCode("kill -9 $$"); + expect(exitCode).toBe(137); + + const message = describeChunkFailure(exitCode, false); + expect(message).toContain("OOM killer"); + expect(message).toContain("chunkSize"); + // The old wording carried no cause at all; it must not come back. + expect(message).not.toBe("failed with exit code 137"); + }); + + test("a watchdog kill is attributed to the watchdog, not to memory", async () => { + const message = await runWithWatchdog("sleep 30", 150); + expect(message).toContain("chunk watchdog"); + expect(message).toContain("OMP_TEST_CHUNK_TIMEOUT"); + expect(message).not.toContain("OOM killer"); + }); + + test("the two SIGKILL causes produce different messages from the same exit code", async () => { + const oomKilled = describeChunkFailure(137, false); + const watchdogKilled = describeChunkFailure(137, true); + expect(oomKilled).not.toBe(watchdogKilled); + }); + + test("an ordinary test failure keeps the plain wording", async () => { + const exitCode = await spawnExitCode("exit 1"); + expect(exitCode).toBe(1); + expect(describeChunkFailure(exitCode, false)).toBe("failed with exit code 1"); + }); + + test("a bun crash exit keeps the plain wording so the retry log still reads naturally", () => { + expect(describeChunkFailure(134, false)).toBe("failed with exit code 134"); + expect(describeChunkFailure(139, false)).toBe("failed with exit code 139"); + }); + + test("the watchdog message reports the configured timeout", () => { + const previous = Bun.env.OMP_TEST_CHUNK_TIMEOUT; + Bun.env.OMP_TEST_CHUNK_TIMEOUT = "42"; + try { + expect(describeChunkFailure(137, true)).toContain("42s"); + } finally { + if (previous === undefined) delete Bun.env.OMP_TEST_CHUNK_TIMEOUT; + else Bun.env.OMP_TEST_CHUNK_TIMEOUT = previous; + } + }); +}); diff --git a/scripts/ci-test-ts.ts b/scripts/ci-test-ts.ts index 61dfb9d79..51e492c96 100755 --- a/scripts/ci-test-ts.ts +++ b/scripts/ci-test-ts.ts @@ -417,19 +417,25 @@ async function runTestCommand(testCommand: TestCommand): Promise { stdout: "inherit", stderr: "inherit", }); - const killTimer = setTimeout(() => proc.kill("SIGKILL"), chunkTimeoutMs()); + // Watchdog, mirroring the parallel path: record that *we* killed the child, + // otherwise the resulting 137 is indistinguishable from an OOM kill. + let timedOut = false; + const killTimer = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + }, chunkTimeoutMs()); const exitCode = await proc.exited; clearTimeout(killTimer); if (exitCode === 0) { return; } - if (BUN_CRASH_EXITS[exitCode] && attempt < MAX_CHUNK_ATTEMPTS) { + if (!timedOut && BUN_CRASH_EXITS[exitCode] && attempt < MAX_CHUNK_ATTEMPTS) { console.log( `==> ${testCommand.label}: bun crashed (exit ${exitCode}); retrying (attempt ${attempt + 1}/${MAX_CHUNK_ATTEMPTS})`, ); continue; } - throw new Error(`${testCommand.label} failed with exit code ${exitCode}: ${renderedCommand}`); + throw new Error(`${testCommand.label} ${describeChunkFailure(exitCode, timedOut)}: ${renderedCommand}`); } } @@ -505,6 +511,21 @@ const BUN_CRASH_EXITS: Record = { // deterministic crash still fails every attempt and is reported normally. const MAX_CHUNK_ATTEMPTS = 3; +// Why a chunk failed, in words. Exit 137 is SIGKILL, which this runner reaches +// two very different ways -- the per-chunk watchdog firing, or the kernel OOM +// killer reaping a chunk that outgrew the runner -- and the bare exit code +// cannot tell them apart. Which one it was is the difference between "raise +// OMP_TEST_CHUNK_TIMEOUT" and "lower this bucket's chunkSize", so say it. +export function describeChunkFailure(exitCode: number, timedOut: boolean): string { + if (timedOut) { + return `exceeded the ${Math.round(chunkTimeoutMs() / 1000)}s chunk watchdog and was killed (exit ${exitCode}; OMP_TEST_CHUNK_TIMEOUT to change)`; + } + if (exitCode === 137) { + return "was SIGKILLed (exit 137) without reaching the chunk watchdog, which on a CI runner means the OOM killer; lower this bucket's chunkSize"; + } + return `failed with exit code ${exitCode}`; +} + // The standard `CI` signal is authoritative. In CI each bucket is its own // memory-capped runner job (a single fat invocation gets OOM-killed at 137), so // chunks run sequentially within a job and parallelism happens across jobs. diff --git a/scripts/rewrite-changelog.ts b/scripts/rewrite-changelog.ts index dd31a84f8..952a9f8c3 100755 --- a/scripts/rewrite-changelog.ts +++ b/scripts/rewrite-changelog.ts @@ -8,7 +8,7 @@ * the final shipped behavior belongs in release notes. * * For every non-empty `[Unreleased]` section this script hands the whole section - * to a small model (default `google-vertex/gemini-3.5-flash` via `@oh-my-pi/pi-ai`) + * to a small model (default `google-antigravity/gemini-3.7-flash` via `@oh-my-pi/pi-ai`) * and asks for a complete replacement grouped by changelog category. The model * returns structured sections/items; markdown is rendered locally so only the * Unreleased section changes and formatting stays deterministic. @@ -32,18 +32,9 @@ import * as path from "node:path"; import { parseArgs } from "node:util"; import { type } from "@oh-my-pi/omptype"; -import { - type Api, - AuthStorage, - completeSimple, - Effort, - type Model, - SqliteAuthCredentialStore, - type Tool, - type ToolCall, -} from "@oh-my-pi/pi-ai"; +import { type Api, completeSimple, Effort, type Model, type Tool, type ToolCall } from "@oh-my-pi/pi-ai"; +import { discoverAuthStorage } from "@oh-my-pi/pi-ai/auth-broker"; import { type GeneratedProvider, getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { getAgentDbPath } from "@oh-my-pi/pi-utils"; import { type ChangelogDocument, changelogPaths, @@ -55,7 +46,7 @@ import { resolveRepoRoot, } from "./fix-changelogs"; -const DEFAULT_MODEL = "google-vertex/gemini-3.5-flash"; +const DEFAULT_MODEL = "google-antigravity/gemini-3.7-flash"; // -------------------------------------------------------------------------- // Prompts @@ -109,12 +100,12 @@ async function openModel(modelSpec: string): Promise { const modelId = modelSpec.slice(slash + 1); const model = getBundledModel(provider as GeneratedProvider, modelId); if (!model) throw new Error(`unknown model "${modelSpec}" (not in bundled catalog)`); - const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); - const storage = new AuthStorage(store); - await storage.reload(); + const storage = await discoverAuthStorage({ sourceLabel: "rewrite-changelog" }); const apiKey = await storage.getApiKey(provider); if (!apiKey) { - throw new Error(`no credentials for provider "${provider}" (run \`omp login\` or set the provider env var)`); + throw new Error( + `no credentials for provider "${provider}" via ${storage.sourceLabel ?? "auth storage"} (check broker or run \`omp login\`)`, + ); } return { model, apiKey, spec: modelSpec }; } @@ -379,7 +370,7 @@ function parseCli(argv: string[]): CliOptions | "help" { options: { "dry-run": { type: "boolean", default: false }, check: { type: "boolean", default: false }, - model: { type: "string", default: DEFAULT_MODEL }, + model: { type: "string", short: "m", default: DEFAULT_MODEL }, package: { type: "string" }, "repo-root": { type: "string" }, concurrency: { type: "string", default: "4" }, @@ -398,14 +389,14 @@ function parseCli(argv: string[]): CliOptions | "help" { function usage(): string { return [ - "Usage: bun scripts/rewrite-changelog.ts [--dry-run|--check] [--model ] [--package ] [--concurrency ]", + "Usage: bun scripts/rewrite-changelog.ts [--dry-run|--check] [-m|--model ] [--package ] [--concurrency ]", "", "Hands each non-empty [Unreleased] changelog section to a small model and rewrites the entries", "into user-facing release notes, dropping intermediate developer churn and implementation-only details", "while preserving public contract, exports, API, config, auth, and billing behavior.", "", "Options:", - ` --model Classifier model (default ${DEFAULT_MODEL}).`, + ` -m, --model Classifier model (default ${DEFAULT_MODEL}).`, " --package Only changelogs whose path contains this substring.", " --concurrency Max concurrent changelogs to process in parallel (default 4).", " --dry-run Report what would be dropped without writing files.", diff --git a/scripts/setup.ts b/scripts/setup.ts new file mode 100755 index 000000000..75c710585 --- /dev/null +++ b/scripts/setup.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env bun +/** + * `bun setup` entrypoint. Chains the four setup steps (install → native + * addon build → coding-agent link → omp link) and forwards a single UX flag: + * + * --cargo Build the host native addon via the local Cargo/N-API path + * instead of bazel. Equivalent to + * `OMP_NATIVE_BUILD_BACKEND=cargo` — surfaced as a flag because + * bun/npm can't forward extra args past `&&`-chained scripts. + * + * Any other flags after `--` are appended to the native build invocation. + */ +import * as path from "node:path"; + +const repoRoot = path.join(import.meta.dir, ".."); + +const argv = process.argv.slice(2); +let cargo = false; +const passthrough: string[] = []; +for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--") { + passthrough.push(...argv.slice(i + 1)); + break; + } + if (arg === "--cargo") { + cargo = true; + continue; + } + passthrough.push(arg); +} + +const nativeBackendEnv = cargo ? { OMP_NATIVE_BUILD_BACKEND: "cargo" } : {}; + +interface Step { + label: string; + cmd: string[]; + env?: Record; + cwd?: string; +} + +const steps: Step[] = [ + { label: "bun install", cmd: ["bun", "install"] }, + { label: "build:native", cmd: ["bun", "run", "build:native", ...passthrough], env: nativeBackendEnv }, + { label: "coding-agent link", cmd: ["bun", "--cwd=packages/coding-agent", "link"] }, + { label: "link omp", cmd: ["sh", "scripts/link-omp.sh"] }, +]; + +for (const step of steps) { + console.log(`\n▶ ${step.label}`); + const proc = Bun.spawn(step.cmd, { + cwd: step.cwd ?? repoRoot, + env: { ...process.env, ...(step.env ?? {}) }, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + console.error(`\nsetup step "${step.label}" failed (exit ${exitCode})`); + process.exit(exitCode || 1); + } +}