diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82e5e6a24..a82dbc0fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,7 +307,12 @@ jobs: - name: Test workspace packages and repo scripts (TS) env: OMP_TEST_CONCURRENCY: "4" - run: bun run ci:test:ts:workspace + run: | + bun run ci:test:ts:workspace + # 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 test_coding_agent_singleton: name: Test coding-agent singleton/global-state (TS) diff --git a/Cargo.lock b/Cargo.lock index 11b6a6aef..e09cef59f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,9 +514,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64-simd" @@ -1963,9 +1963,9 @@ dependencies = [ [[package]] name = "encoding_rs_io" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" dependencies = [ "encoding_rs", ] @@ -2573,9 +2573,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -2783,13 +2783,13 @@ checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" [[package]] name = "html-to-markdown-rs" -version = "3.10.2" +version = "3.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf23a50d1e4f5ca342b75308a309466882f672a324654a363e26ff723a40e791" +checksum = "1b0fdf3ba00130a03686f79af007ba066f13e2b0ea3d736ede049cfd8b55015a" dependencies = [ "ahash", "astral-tl", - "base64 0.23.0", + "base64 0.23.1", "bitflags 2.13.1", "html-escape", "html5ever", @@ -3148,9 +3148,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b17771570a2b94107741a7b033f19132c2eee21d59d21b24d2ced26500bd66e" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -3537,9 +3537,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4899,7 +4899,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "17.2.8" +version = "17.2.9" dependencies = [ "anyhow", "ast-grep-core", @@ -4968,19 +4968,19 @@ dependencies = [ [[package]] name = "pi-iso" -version = "17.2.8" +version = "17.2.9" dependencies = [ "async-trait", "libc", "parking_lot", - "similar 3.1.1", + "similar 3.1.2", "tokio", "windows-sys 0.61.2", ] [[package]] name = "pi-natives" -version = "17.2.8" +version = "17.2.9" dependencies = [ "anyhow", "arboard", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "17.2.8" +version = "17.2.9" dependencies = [ "anyhow", "brush-builtins", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "pi-voice" -version = "17.2.8" +version = "17.2.9" dependencies = [ "audiopus_sys", "bytes", @@ -5151,7 +5151,7 @@ dependencies = [ [[package]] name = "pi-walker" -version = "17.2.8" +version = "17.2.9" dependencies = [ "dashmap", "globset", @@ -5169,7 +5169,7 @@ dependencies = [ "clap", "parking_lot", "pi-uutils-ctx", - "similar 3.1.1", + "similar 3.1.2", "tempfile", ] @@ -5714,9 +5714,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6238,9 +6238,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "similar" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" +checksum = "85ee016af5d736b69fc89e19254540fa4b5f5492853fb5503920f084011c78b6" dependencies = [ "bstr", ] diff --git a/Cargo.toml b/Cargo.toml index 4a084f620..a762a6067 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/pi-*", "crates/vendor/*"] resolver = "3" [workspace.package] -version = "17.2.8" +version = "17.2.9" edition = "2024" license = "MIT" authors = ["Can Boluk"] @@ -138,6 +138,7 @@ option_if_let_else = "allow" # match/if-let-else often clearer enum_glob_use = "allow" items_after_statements = "allow" # Sometimes more readable wildcard_imports = "allow" # Cleaner for preludes and test modules +redundant_pub_crate = "allow" # ────────────────────────────────────────────────────────────────────────────── # Variables & Type Inference diff --git a/README.md b/README.md index 931eb9144..015d534e3 100644 --- a/README.md +++ b/README.md @@ -614,7 +614,6 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP | **[@oh-my-pi/hashline](packages/hashline)** | Line-anchored patch language and applier behind the `edit` tool | | **[@oh-my-pi/pi-mnemopi](packages/mnemopi)** | Local SQLite memory engine for Oh My Pi agents | | **[@oh-my-pi/snapcompact](packages/snapcompact)** | Bitmap-frame context compression package and SQuAD eval suite | -| **[@oh-my-pi/swarm-extension](packages/swarm-extension)** | Swarm orchestration extension package | | **[@oh-my-pi/browser-relay](packages/browser-relay)** | Chrome extension that lets the browser tool drive your existing tabs | | **[@oh-my-pi/pi-metaharness](packages/metaharness)** | Unified benchmark runners, Harbor run storage, REST/SSE API, live dashboard | | **[@oh-my-pi/typescript-edit-benchmark](packages/typescript-edit-benchmark)** | Edit benchmark suite built on TypeScript source mutations | diff --git a/bun.lock b/bun.lock index 298c970da..0caad27a6 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "17.2.8", + "version": "17.2.9", "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.2.8", + "version": "17.2.9", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -64,7 +64,7 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -78,7 +78,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "17.2.8", + "version": "17.2.9", "bin": { "omp": "src/cli.ts", }, @@ -152,7 +152,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "lru-cache": "catalog:", @@ -196,7 +196,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "17.2.8", + "version": "17.2.9", "bin": { "mnemopi": "src/cli.ts", }, @@ -223,7 +223,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "17.2.8", + "version": "17.2.9", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -231,7 +231,7 @@ }, "packages/omptype": { "name": "@oh-my-pi/omptype", - "version": "17.2.8", + "version": "17.2.9", "devDependencies": { "@ark/attest": "0.56.3", "@ark/schema": "0.56.2", @@ -245,7 +245,7 @@ }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", @@ -258,7 +258,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "17.2.8", + "version": "17.2.9", "bin": { "omp-stats": "./src/index.ts", }, @@ -283,25 +283,9 @@ "postcss": "catalog:", }, }, - "packages/swarm-extension": { - "name": "@oh-my-pi/swarm-extension", - "version": "17.2.8", - "bin": { - "omp-swarm": "src/cli.ts", - }, - "dependencies": { - "@oh-my-pi/pi-utils": "workspace:*", - }, - "devDependencies": { - "@types/bun": "^1.3.14", - }, - "peerDependencies": { - "@oh-my-pi/pi-coding-agent": "^16", - }, - }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -340,7 +324,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "handlebars": "catalog:", @@ -353,7 +337,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "17.2.8", + "version": "17.2.9", "devDependencies": { "@types/bun": "catalog:", }, @@ -394,19 +378,19 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.2.8", - "@oh-my-pi/omp-stats": "17.2.8", - "@oh-my-pi/omptype": "17.2.8", - "@oh-my-pi/pi-agent-core": "17.2.8", - "@oh-my-pi/pi-ai": "17.2.8", - "@oh-my-pi/pi-catalog": "17.2.8", - "@oh-my-pi/pi-coding-agent": "17.2.8", - "@oh-my-pi/pi-mnemopi": "17.2.8", - "@oh-my-pi/pi-natives": "17.2.8", - "@oh-my-pi/pi-tui": "17.2.8", - "@oh-my-pi/pi-utils": "17.2.8", - "@oh-my-pi/pi-wire": "17.2.8", - "@oh-my-pi/snapcompact": "17.2.8", + "@oh-my-pi/hashline": "17.2.9", + "@oh-my-pi/omp-stats": "17.2.9", + "@oh-my-pi/omptype": "17.2.9", + "@oh-my-pi/pi-agent-core": "17.2.9", + "@oh-my-pi/pi-ai": "17.2.9", + "@oh-my-pi/pi-catalog": "17.2.9", + "@oh-my-pi/pi-coding-agent": "17.2.9", + "@oh-my-pi/pi-mnemopi": "17.2.9", + "@oh-my-pi/pi-natives": "17.2.9", + "@oh-my-pi/pi-tui": "17.2.9", + "@oh-my-pi/pi-utils": "17.2.9", + "@oh-my-pi/pi-wire": "17.2.9", + "@oh-my-pi/snapcompact": "17.2.9", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -777,13 +761,13 @@ "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], - "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + "@octokit/core": ["@octokit/core@7.0.7", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.4", "@octokit/request": "^10.0.13", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA=="], "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], - "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + "@octokit/graphql": ["@octokit/graphql@9.0.4", "", { "dependencies": { "@octokit/request": "^10.0.13", "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg=="], - "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], @@ -791,13 +775,13 @@ "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], - "@octokit/request": ["@octokit/request@10.0.11", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q=="], + "@octokit/request": ["@octokit/request@10.0.13", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A=="], - "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="], "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], - "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], "@oh-my-pi/browser-relay": ["@oh-my-pi/browser-relay@workspace:packages/browser-relay"], @@ -831,8 +815,6 @@ "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@workspace:packages/snapcompact"], - "@oh-my-pi/swarm-extension": ["@oh-my-pi/swarm-extension@workspace:packages/swarm-extension"], - "@oh-my-pi/typescript-edit-benchmark": ["@oh-my-pi/typescript-edit-benchmark@workspace:packages/typescript-edit-benchmark"], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -1081,7 +1063,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], @@ -1563,7 +1545,7 @@ "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], - "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1667,6 +1649,12 @@ "@napi-rs/wasm-tools-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@octokit/endpoint/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], @@ -1779,6 +1767,12 @@ "@napi-rs/tar-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], "@typescript/analyze-trace/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], diff --git a/crates/pi-natives/src/desktop/ax.rs b/crates/pi-natives/src/desktop/ax.rs index 171cea1ae..1bb2bdfe8 100644 --- a/crates/pi-natives/src/desktop/ax.rs +++ b/crates/pi-natives/src/desktop/ax.rs @@ -467,7 +467,7 @@ pub fn normalize_role_macos(native: &str) -> String { .to_ascii_lowercase() } #[cfg(any(target_os = "windows", test))] -pub(crate) fn normalize_role_uia(native: &str) -> String { +pub fn normalize_role_uia(native: &str) -> String { match native { "Edit" => "textfield", "Document" => "textarea", diff --git a/crates/pi-natives/src/desktop/keys.rs b/crates/pi-natives/src/desktop/keys.rs index 69b56ea47..6ca47d828 100644 --- a/crates/pi-natives/src/desktop/keys.rs +++ b/crates/pi-natives/src/desktop/keys.rs @@ -237,14 +237,14 @@ pub fn parse_modifiers(mods: &[String]) -> CoreResult { #[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum KeyDirection { +pub enum KeyDirection { Press, Release, Click, } #[cfg(test)] -pub(crate) fn execute_chord_with( +pub fn execute_chord_with( keys: &[KeyName], mut emit: impl FnMut(KeyName, KeyDirection) -> Result<(), E>, ) -> Result<(), E> { @@ -263,10 +263,10 @@ pub(crate) fn execute_chord_with( } let mut first_error = None; for &key in pressed.iter().rev() { - if let Err(error) = emit(key, KeyDirection::Release) { - if first_error.is_none() { - first_error = Some(error); - } + if let Err(error) = emit(key, KeyDirection::Release) + && first_error.is_none() + { + first_error = Some(error); } } first_error.map_or(Ok(()), Err) diff --git a/crates/pi-natives/src/desktop/win32/delivery.rs b/crates/pi-natives/src/desktop/win32/delivery.rs index e206da5f6..75322c959 100644 --- a/crates/pi-natives/src/desktop/win32/delivery.rs +++ b/crates/pi-natives/src/desktop/win32/delivery.rs @@ -4,7 +4,7 @@ //! exercised by the host test suite on every platform. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum EventKind { +pub enum EventKind { MouseClick, MouseMove, MouseScroll, @@ -26,34 +26,34 @@ impl EventKind { } } -pub(crate) fn is_chromium_class(class: &str) -> bool { +pub fn is_chromium_class(class: &str) -> bool { class .strip_prefix("Chrome_WidgetWin_") .is_some_and(|suffix| !suffix.is_empty()) } -pub(crate) fn is_winui3_class(class: &str) -> bool { +pub fn is_winui3_class(class: &str) -> bool { class == "WinUIDesktopWin32WindowClass" } -pub(crate) fn is_wpf_class(class: &str) -> bool { +pub fn is_wpf_class(class: &str) -> bool { class .strip_prefix("HwndWrapper[") .is_some_and(|body| !body.is_empty() && body.ends_with(']')) } -pub(crate) fn is_tk_class(class: &str) -> bool { +pub fn is_tk_class(class: &str) -> bool { class == "TkTopLevel" || class .strip_prefix("TkTopLevel.") .is_some_and(|suffix| !suffix.is_empty()) } -pub(crate) fn is_gtk_class(class: &str) -> bool { +pub fn is_gtk_class(class: &str) -> bool { class == "gdkWindowToplevel" || class == "gdkSurfaceToplevel" } -pub(crate) fn is_vcl_class(class: &str) -> bool { +pub fn is_vcl_class(class: &str) -> bool { class .strip_prefix("SAL") .is_some_and(|suffix| !suffix.is_empty()) @@ -61,7 +61,7 @@ pub(crate) fn is_vcl_class(class: &str) -> bool { /// Returns the empirical reason that a posted event would be accepted by /// Win32 but silently ignored by the target toolkit. -pub(crate) fn would_be_silently_dropped(class: &str, kind: EventKind) -> Option<&'static str> { +pub fn would_be_silently_dropped(class: &str, kind: EventKind) -> Option<&'static str> { use EventKind::{KeyCombo, Keystroke, MouseClick, MouseMove, MouseScroll, TextInput}; if is_chromium_class(class) { diff --git a/crates/pi-natives/src/desktop/win32/mod.rs b/crates/pi-natives/src/desktop/win32/mod.rs index ff8cf5fc0..d88f91dee 100644 --- a/crates/pi-natives/src/desktop/win32/mod.rs +++ b/crates/pi-natives/src/desktop/win32/mod.rs @@ -2,7 +2,7 @@ mod ax; #[cfg(target_os = "windows")] mod capture; -pub(crate) mod delivery; +pub mod delivery; #[cfg(target_os = "windows")] mod input; diff --git a/crates/pi-natives/src/fd.rs b/crates/pi-natives/src/fd.rs index b4c262e41..059b38162 100644 --- a/crates/pi-natives/src/fd.rs +++ b/crates/pi-natives/src/fd.rs @@ -3,7 +3,7 @@ //! Searches for files and directories whose paths match a query string via //! subsequence scoring. Uses `pi-walker` for directory traversal and caching. -use std::path::Path; +use std::{cmp::Ordering, collections::BinaryHeap, path::Path}; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -159,6 +159,97 @@ fn path_depth(path: &str) -> usize { path.trim_end_matches('/').matches('/').count() } +/// A scored match carrying its precomputed depth, ordered worst-first. +/// +/// The ordering is the exact inverse of the final result comparator (score +/// descending, then `path_depth` ascending, then `path` ascending), so the +/// greatest element of a `BinaryHeap` is the candidate that must +/// be evicted first, and `into_sorted_vec` yields the final best-first order. +struct RankedMatch { + depth: usize, + entry: FuzzyFindMatch, +} + +impl RankedMatch { + fn new(entry: FuzzyFindMatch) -> Self { + let depth = path_depth(&entry.path); + Self { depth, entry } + } +} + +impl Ord for RankedMatch { + fn cmp(&self, other: &Self) -> Ordering { + other + .entry + .score + .cmp(&self.entry.score) + .then_with(|| self.depth.cmp(&other.depth)) + .then_with(|| self.entry.path.cmp(&other.entry.path)) + } +} + +impl PartialOrd for RankedMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for RankedMatch { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for RankedMatch {} + +/// Bounded collector retaining at most `capacity` best matches while counting +/// every hit, so `totalMatches` stays exact even when it exceeds `maxResults`. +struct TopMatches { + capacity: usize, + total: u64, + heap: BinaryHeap, +} + +impl TopMatches { + fn new(capacity: usize) -> Self { + Self { capacity, total: 0, heap: BinaryHeap::with_capacity(capacity.min(256)) } + } + + fn push(&mut self, entry: FuzzyFindMatch) { + self.total = self.total.saturating_add(1); + if self.capacity == 0 { + return; + } + let candidate = RankedMatch::new(entry); + if self.heap.len() < self.capacity { + self.heap.push(candidate); + return; + } + // The root is the worst retained candidate; replace it only when the new + // candidate outranks it under the final comparator. + if self.heap.peek().is_some_and(|worst| candidate < *worst) { + self.heap.pop(); + self.heap.push(candidate); + } + } + + /// Exact number of scoring hits, clamped to the `u32` wire type. + const fn total_matches(&self) -> u32 { + crate::utils::clamp_u32(self.total) + } + + /// Retained matches ordered by score descending, then shallower paths, then + /// path ascending. + fn into_sorted_matches(self) -> Vec { + self + .heap + .into_sorted_vec() + .into_iter() + .map(|ranked| ranked.entry) + .collect() + } +} + struct FuzzyFindConfig { query: String, path: String, @@ -168,14 +259,18 @@ struct FuzzyFindConfig { cache: Option, } -fn score_entries( - entries: &[iofs::GlobMatch], +fn score_entries( + entries: I, query_lower: &str, normalized_query: &str, query_chars: &[char], + max_results: usize, ct: &task::CancelToken, -) -> Result> { - let mut scored = Vec::with_capacity(entries.len().min(256)); +) -> Result +where + I: IntoIterator, +{ + let mut scored = TopMatches::new(max_results); for entry in entries { ct.heartbeat()?; if entry.file_type == iofs::FileType::Symlink { @@ -189,7 +284,7 @@ fn score_entries( continue; } - let mut path = entry.path.clone(); + let mut path = entry.path; if is_directory { path.push('/'); } @@ -229,21 +324,17 @@ fn fuzzy_find_sync(config: FuzzyFindConfig, ct: task::CancelToken) -> Result = outcome - .entries - .into_iter() - .map(iofs::GlobMatch::from) - .collect(); - let mut scored = score_entries(&entries, &query_lower, &normalized_query, &query_chars, &ct)?; + let scored = score_entries( + outcome.entries.into_iter().map(iofs::GlobMatch::from), + &query_lower, + &normalized_query, + &query_chars, + max_results, + &ct, + )?; - scored.sort_by(|a, b| { - b.score - .cmp(&a.score) - .then_with(|| path_depth(&a.path).cmp(&path_depth(&b.path))) - .then_with(|| a.path.cmp(&b.path)) - }); - let total_matches = crate::utils::clamp_u32(scored.len() as u64); - let matches = scored.into_iter().take(max_results).collect(); + let total_matches = scored.total_matches(); + let matches = scored.into_sorted_matches(); Ok(FuzzyFindResult { matches, total_matches }) } @@ -381,4 +472,152 @@ mod tests { "expected cwd-root scripts/ to rank first, got {paths:?}" ); } + + #[cfg(unix)] + #[test] + fn fuzzy_find_reports_exact_total_beyond_max_results() { + let root = TempDirGuard::new(); + for index in 0..12 { + fs::write(root.path().join(format!("needle-{index}.txt")), "needle\n") + .expect("write fixture file"); + } + + let result = fuzzy_find_sync( + FuzzyFindConfig { + query: "needle".to_string(), + path: root.path().to_string_lossy().into_owned(), + hidden: Some(true), + gitignore: Some(false), + max_results: Some(3), + cache: Some(false), + }, + task::CancelToken::default(), + ) + .expect("fuzzy find succeeds"); + + assert_eq!(result.matches.len(), 3, "retained matches must honor maxResults"); + assert_eq!(result.total_matches, 12, "total must count every hit, not the retained ones"); + let paths: Vec<&str> = result + .matches + .iter() + .map(|entry| entry.path.as_str()) + .collect(); + assert_eq!( + paths, + vec!["needle-0.txt", "needle-1.txt", "needle-10.txt"], + "bounded retention must keep the same order as the full sort" + ); + } + + #[test] + fn bounded_retention_matches_reference_ordering_and_total() { + use super::{FuzzyFindMatch, TopMatches, path_depth}; + + // Score ties across depths and directories are the cases where a bounded + // heap can diverge from the full sort, so cover them explicitly. + let candidates = [ + ("packages/ai/scripts/", true, 130u32), + ("scripts/", true, 130), + (".omp/skills/opt/scripts/", true, 130), + ("src/scripts.ts", false, 120), + ("src/deep/nested/scripts.ts", false, 120), + ("a/scripts.ts", false, 120), + ("notes/script-notes.md", false, 80), + ("z.txt", false, 51), + ]; + + let mut reference: Vec<(u32, usize, String)> = candidates + .iter() + .map(|(path, _, score)| (*score, path_depth(path), (*path).to_string())) + .collect(); + reference.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + + for max_results in 1..=candidates.len() + 2 { + let mut bounded = TopMatches::new(max_results); + for (path, is_directory, score) in candidates { + bounded.push(FuzzyFindMatch { path: path.to_string(), is_directory, score }); + } + let total = bounded.total_matches(); + let bounded_paths: Vec = bounded + .into_sorted_matches() + .into_iter() + .map(|entry| entry.path) + .collect(); + let expected_paths: Vec = reference + .iter() + .take(max_results) + .map(|(_, _, path)| path.clone()) + .collect(); + + assert_eq!(total, candidates.len() as u32, "total must count every pushed hit"); + assert_eq!( + bounded_paths, expected_paths, + "bounded order must match the full sort for max_results={max_results}" + ); + } + } + + #[test] + fn bounded_retention_matches_full_sort_on_large_corpus() { + use super::{FuzzyFindMatch, TopMatches, path_depth}; + + const CANDIDATE_COUNT: usize = 100_000; + const MAX_RESULTS: usize = 128; + + let mut reference = Vec::with_capacity(CANDIDATE_COUNT); + let mut bounded = TopMatches::new(MAX_RESULTS); + for index in 0..CANDIDATE_COUNT { + let depth = index % 7; + let path = format!("{}{index:06}-item.txt", "nested/".repeat(depth)); + let score = 50 + (index % 83) as u32; + reference.push((score, path_depth(&path), path.clone())); + bounded.push(FuzzyFindMatch { path, is_directory: false, score }); + assert!( + bounded.heap.len() <= MAX_RESULTS, + "retention exceeded maxResults after candidate {index}" + ); + } + assert_eq!(reference.len(), CANDIDATE_COUNT); + assert_eq!(bounded.heap.len(), MAX_RESULTS); + assert_eq!(bounded.total_matches(), CANDIDATE_COUNT as u32); + + reference.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + let expected: Vec = reference + .into_iter() + .take(MAX_RESULTS) + .map(|(_, _, path)| path) + .collect(); + let actual: Vec = bounded + .into_sorted_matches() + .into_iter() + .map(|entry| entry.path) + .collect(); + + assert_eq!(actual, expected, "bounded top-K must match the complete baseline sort"); + } + + #[test] + fn bounded_retention_counts_hits_with_zero_capacity() { + use super::{FuzzyFindMatch, TopMatches}; + + let mut bounded = TopMatches::new(0); + for index in 0..5 { + bounded.push(FuzzyFindMatch { + path: format!("file-{index}.txt"), + is_directory: false, + score: 10, + }); + } + + assert_eq!(bounded.total_matches(), 5); + assert!(bounded.into_sorted_matches().is_empty()); + } } diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 0606eaa48..ac8c049f6 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -255,7 +255,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_2_8")] +#[napi(js_name = "__piNativesV17_2_9")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/crates/pi-shell/src/process.rs b/crates/pi-shell/src/process.rs index bb299be9d..7dd97fe11 100644 --- a/crates/pi-shell/src/process.rs +++ b/crates/pi-shell/src/process.rs @@ -1937,7 +1937,7 @@ mod tests { // Treat the child's pid as protected (standing in for the harness/an // ancestor). The sweep must refuse to signal it. - let protected: HashSet = [child_pid].into_iter().collect(); + let protected: HashSet = HashSet::from([child_pid]); let signaled = root.signal_tree_excluding(KILL_SIGNAL, &protected); assert_eq!(signaled, 0, "a protected root must never be signalled"); @@ -1965,8 +1965,8 @@ mod tests { #[test] fn protected_subtree_is_pruned_not_just_the_pid() { // root(1) -> host(2, protected) -> worker(3); root(1) -> real_child(4). - let parents: HashMap = [(2, 1), (3, 2), (4, 1)].into_iter().collect(); - let protected: HashSet = [2].into_iter().collect(); + let parents: HashMap = HashMap::from([(2, 1), (3, 2), (4, 1)]); + let protected: HashSet = HashSet::from([2]); assert!( pid_in_protected_subtree(2, &protected, &parents), diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 5d0046b50..3f9a19e41 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -8583,12 +8583,12 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }] /// A segment that carries a file redirect is still segmented, and the brush /// `Display` reconstruction the runner executes must round-trip through - /// brush's own parser **without losing the redirect**. `echo hidden - /// >/dev/null` suppresses its own stdout: if the reconstruction dropped the - /// redirect, `hidden` would leak into the captured output. Proves the - /// reconstruction path is semantically sound for the redirect-bearing - /// shapes the per-stage whitelist accepts (not just syntactically - /// parseable). + /// brush's own parser **without losing the redirect**. + /// `echo hidden >/dev/null` suppresses its own stdout: if the reconstruction + /// dropped the redirect, `hidden` would leak into the captured output. + /// Proves the reconstruction path is semantically sound for the + /// redirect-bearing shapes the per-stage whitelist accepts (not just + /// syntactically parseable). #[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn segmented_chain_with_redirect_executes_correctly() { diff --git a/crates/vendor/brush-builtins/Cargo.toml b/crates/vendor/brush-builtins/Cargo.toml index 82e3056eb..c24f538c5 100644 --- a/crates/vendor/brush-builtins/Cargo.toml +++ b/crates/vendor/brush-builtins/Cargo.toml @@ -242,87 +242,9 @@ features = [ version = "0.11.0" [lints.clippy] -bool_to_int_with_if = "allow" -cognitive_complexity = "allow" -collapsible_else_if = "allow" -collapsible_if = "allow" -expect_used = "deny" -format_push_string = "deny" -if_not_else = "allow" -if_same_then_else = "allow" -match_same_arms = "allow" -missing_errors_doc = "allow" -multiple_crate_versions = "allow" -multiple_unsafe_ops_per_block = "deny" -must_use_candidate = "allow" -option_if_let_else = "allow" -panic = "deny" -panic_in_result_fn = "deny" -redundant_closure_for_method_calls = "allow" -redundant_else = "allow" -redundant_pub_crate = "allow" -result_large_err = "allow" -similar_names = "allow" -string_lit_chars_any = "deny" -string_slice = "deny" -struct_excessive_bools = "allow" -tests_outside_test_module = "deny" -todo = "deny" -undocumented_unsafe_blocks = "deny" -unwrap_in_result = "deny" -unwrap_used = "deny" - -[lints.clippy.all] -level = "deny" -priority = -1 - -[lints.clippy.cargo] -level = "deny" -priority = -1 - -[lints.clippy.nursery] -level = "deny" -priority = -1 - -[lints.clippy.pedantic] -level = "deny" -priority = -1 - -[lints.clippy.perf] -level = "deny" -priority = -1 - +all = { level = "allow", priority = -1 } +nursery = { level = "allow", priority = -1 } +pedantic = { level = "allow", priority = -1 } +cargo = { level = "allow", priority = -1 } [lints.rust] -unnameable_types = "deny" -unsafe_op_in_unsafe_fn = "deny" -unused_attributes = "deny" -unused_lifetimes = "deny" -unused_macro_rules = "deny" - -[lints.rust.future_incompatible] -level = "deny" -priority = 0 - -[lints.rust.missing_docs] -level = "deny" -priority = 0 - -[lints.rust.nonstandard_style] -level = "deny" -priority = 0 - -[lints.rust.rust_2018_idioms] -level = "deny" -priority = -1 - -[lints.rust.unknown_lints] -level = "allow" -priority = -100 - -[lints.rust.warnings] -level = "deny" -priority = 0 - -[lints.rustdoc.all] -level = "deny" -priority = -1 +unfulfilled_lint_expectations = { level = "allow", priority = -1 } diff --git a/crates/vendor/brush-core/Cargo.toml b/crates/vendor/brush-core/Cargo.toml index 14a497b40..2267a9ac8 100644 --- a/crates/vendor/brush-core/Cargo.toml +++ b/crates/vendor/brush-core/Cargo.toml @@ -212,87 +212,7 @@ version = "1.23.1" features = ["js"] [lints.clippy] -bool_to_int_with_if = "allow" -cognitive_complexity = "allow" -collapsible_else_if = "allow" -collapsible_if = "allow" -expect_used = "deny" -format_push_string = "deny" -if_not_else = "allow" -if_same_then_else = "allow" -match_same_arms = "allow" -missing_errors_doc = "allow" -multiple_crate_versions = "allow" -multiple_unsafe_ops_per_block = "deny" -must_use_candidate = "allow" -option_if_let_else = "allow" -panic = "deny" -panic_in_result_fn = "deny" -redundant_closure_for_method_calls = "allow" -redundant_else = "allow" -redundant_pub_crate = "allow" -result_large_err = "allow" -similar_names = "allow" -string_lit_chars_any = "deny" -string_slice = "deny" -struct_excessive_bools = "allow" -tests_outside_test_module = "deny" -todo = "deny" -undocumented_unsafe_blocks = "deny" -unwrap_in_result = "deny" -unwrap_used = "deny" - -[lints.clippy.all] -level = "deny" -priority = -1 - -[lints.clippy.cargo] -level = "deny" -priority = -1 - -[lints.clippy.nursery] -level = "deny" -priority = -1 - -[lints.clippy.pedantic] -level = "deny" -priority = -1 - -[lints.clippy.perf] -level = "deny" -priority = -1 - -[lints.rust] -unnameable_types = "deny" -unsafe_op_in_unsafe_fn = "deny" -unused_attributes = "deny" -unused_lifetimes = "deny" -unused_macro_rules = "deny" - -[lints.rust.future_incompatible] -level = "deny" -priority = 0 - -[lints.rust.missing_docs] -level = "deny" -priority = 0 - -[lints.rust.nonstandard_style] -level = "deny" -priority = 0 - -[lints.rust.rust_2018_idioms] -level = "deny" -priority = -1 - -[lints.rust.unknown_lints] -level = "allow" -priority = -100 - -[lints.rust.warnings] -level = "deny" -priority = 0 - -[lints.rustdoc.all] -level = "deny" -priority = -1 +all = { level = "allow", priority = -1 } +nursery = { level = "allow", priority = -1 } +pedantic = { level = "allow", priority = -1 } +cargo = { level = "allow", priority = -1 } diff --git a/crates/vendor/uu-sort/src/ext_sort/threaded.rs b/crates/vendor/uu-sort/src/ext_sort/threaded.rs index 9efce0ebf..49db811fc 100644 --- a/crates/vendor/uu-sort/src/ext_sort/threaded.rs +++ b/crates/vendor/uu-sort/src/ext_sort/threaded.rs @@ -327,9 +327,8 @@ mod tests { fn ext_sort_spills_to_files_and_sorts() { let input: String = (0..200u32).rev().map(|i| format!("{i:04}\n")).collect(); - let mut settings = GlobalSettings::default(); - settings.buffer_size = 64; - settings.buffer_size_is_explicit = true; + let settings = + GlobalSettings { buffer_size: 64, buffer_size_is_explicit: true, ..Default::default() }; let out_dir = tempfile::tempdir().expect("temp dir"); let out_path = out_dir.path().join("sorted.txt"); diff --git a/docs/bash-tool-runtime.md b/docs/bash-tool-runtime.md index a1f970e54..f10f0bc3d 100644 --- a/docs/bash-tool-runtime.md +++ b/docs/bash-tool-runtime.md @@ -37,7 +37,7 @@ The bash tool has the `exec` approval tier. `bash.patterns` rules can explicitly ## 2) Optional interception (blocked-command path) -If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. +If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `|&`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. Fragments that receive piped stdin from `|` or `|&` are excluded from the fragment candidates, including across blank/comment continuation lines, because a stdin-consuming stage cannot be replaced by a path-based dedicated tool. Interception behavior: diff --git a/docs/extensions.md b/docs/extensions.md index 0e6534844..46a087bbe 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -12,7 +12,7 @@ This document covers the current extension runtime in: For discovery paths and filesystem loading rules, see [`extension-loading.md`](./extension-loading.md). -For packaged user-facing extension CLIs/features such as `packages/swarm-extension`, see [`user-facing-packages.md`](./user-facing-packages.md). +For packaged user-facing extension CLIs/features, see [`user-facing-packages.md`](./user-facing-packages.md). ## What an extension is diff --git a/docs/mcp-config.md b/docs/mcp-config.md index c76b4badb..806d41118 100644 --- a/docs/mcp-config.md +++ b/docs/mcp-config.md @@ -39,7 +39,7 @@ OMP also translates these current tool-native sources: - VS Code: project-only `.vscode/mcp.json` using `mcp.servers` - installed Claude marketplace plugins and OMP extension packages that declare MCP servers -For translated providers with both scopes, a same-named user entry is encountered before its project entry. OMP-native config is the exception: its project entry precedes its active-profile user entry. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). +For Claude Code, Codex, Gemini CLI, Cursor, and Windsurf, the project entry is encountered before its same-named user entry — matching OMP-native config, whose project entry precedes its active-profile user entry — so a project `enabled: false` suppresses a same-named user server. OpenCode currently encounters the user entry first. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). ### Profiles diff --git a/docs/python-repl.md b/docs/python-repl.md index e5b940739..2fac77bbb 100644 --- a/docs/python-repl.md +++ b/docs/python-repl.md @@ -146,7 +146,7 @@ The backend settings `eval.py` / `eval.js` default to `true`; `eval.rb` / `eval. The tool's session-scoped schema lists only enabled runtimes. If Python preflight fails while another runtime is enabled, `eval` remains available for that runtime and a `py` call reports a Python-backend availability error with enabled alternatives. -Python prelude helpers include `agent(prompt, *, agent="task", model=None, label=None, schema=None, schema_mode=None, isolated=None, apply=None, merge=None, handle=False)`. It synchronously calls the host bridge and returns final text, or parsed data when `schema` is supplied. `schema_mode` selects permissive or strict structured-output handling; the isolation/apply/merge flags control task worktree behavior. With `handle=True`, it returns a DAG node dict (`{"text", "output", "handle", "id", "agent"}`) whose handle is the recoverable `agent://` URI; parsed output is also stored under `"data"` when available. +Python prelude helpers include `agent(prompt, *, agent="task", label=None, schema=None, schema_mode=None, isolated=None, apply=None, merge=None, handle=False)`. It synchronously calls the host bridge and returns final text, or parsed data when `schema` is supplied. `schema_mode` selects permissive or strict structured-output handling; the isolation/apply/merge flags control task worktree behavior. With `handle=True`, it returns a DAG node dict (`{"text", "output", "handle", "id", "agent"}`) whose handle is the recoverable `agent://` URI; parsed output is also stored under `"data"` when available. ## Execution flow and cancellation/timeout diff --git a/docs/tools/bash.md b/docs/tools/bash.md index 6ef043b69..fb4340381 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -111,7 +111,7 @@ git add file && git commit -m "message" GIT_AUTHOR_NAME=Dev git commit -m "message" ``` -An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. +An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. A stage that consumes another command's stdout through an unquoted `|` or `|&` (for example `grep x` in `printf 'x\n' | grep x`) is **not** treated as an interception candidate: it reads piped stdin, which the path-based dedicated tools cannot supply, so only a standalone or first-stage command is matched. Blank and comment-only continuation lines after the pipe preserve that context. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. ### Interaction and selection guide @@ -127,7 +127,7 @@ Choose the setting by the desired outcome: 1. `BashTool.execute()` in `packages/coding-agent/src/tools/bash.ts` reads `command`, validates `env`, and defaults `timeout` to `300`. 2. If `cwd` is absent, it rewrites a leading `cd && ...` into the structured `cwd` field and strips that prefix from `command`. 3. If `async: true` is requested while `async.enabled` is off, it throws `ToolError` before any execution. -4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. +4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `|&`, `&`, or newlines (excluding stages that consume piped stdin from `|` or `|&`, including across blank/comment continuations), followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. 5. `expandInternalUrls()` rewrites supported internal URLs inside `command`, each `env` value, and protocol-looking `cwd` values. Command replacements are shell-escaped; `env` and `cwd` replacements use raw filesystem/string values because they are not interpolated into shell text. 6. `resolveToCwd()` resolves `cwd` against `session.cwd`; `fs.stat()` verifies that the target exists and is a directory. 7. `timeout: 0` disables the deadline. Otherwise `clampTimeout("bash", requestedTimeoutSec, tools.maxTimeout)` applies a positive global ceiling (when configured), then `TOOL_TIMEOUTS.bash` (`min: 1`, `max: 3600`). When clamped, `#buildCompletedResult()` / `#buildBackgroundStartResult()` append a notice line. diff --git a/docs/tools/eval.md b/docs/tools/eval.md index 26347a1e5..5c4760a1f 100644 --- a/docs/tools/eval.md +++ b/docs/tools/eval.md @@ -149,9 +149,9 @@ A stateless, tool-free one-shot model call: Runs one subagent through `runStructuredSubagent(...)`: -- JS supports the preferred `await agent(prompt, { agent?, model?, label?, schema?, schemaMode?, isolated?, apply?, merge?, handle? })`; legacy positional slots are still implemented. +- JS supports the preferred `await agent(prompt, { agent?, label?, schema?, schemaMode?, isolated?, apply?, merge?, handle? })`; legacy positional slots are still implemented. - Python/Ruby/Julia use keyword arguments (`schema_mode` outside JS). -- `agent` defaults from the current spawn policy. `model` may pin a selector/fallback chain. `schema` overrides agent/session schemas; `schemaMode`/`schema_mode` chooses `permissive` or `strict`. +- `agent` defaults from the current spawn policy; the selected agent's frontmatter model and settings always apply (there is no per-call model override — `model` is not accepted). `schema` overrides agent/session schemas; `schemaMode`/`schema_mode` chooses `permissive` or `strict`. - `isolated` requests isolation. `apply` controls whether captured changes are integrated; `merge=false` selects patch mode while the normal setting controls branch mode. - `handle=true` returns `{ text, output, handle, id, agent }`, optional parsed `data`, and isolation metadata instead of only output/data. - Eval subagents are one-shot (`keepAlive=false`), are unregistered/disposed after completion, and **do not share the caller's eval executor** (`shareEvalSession=false`). Their code mutations therefore do not appear in the caller's retained VM/kernel. diff --git a/docs/user-facing-packages.md b/docs/user-facing-packages.md index 7150b09bd..8dc7e40bc 100644 --- a/docs/user-facing-packages.md +++ b/docs/user-facing-packages.md @@ -22,18 +22,6 @@ Sources: [`python/robomp/README.md`](../python/robomp/README.md), [`python/robom - Root commands: `bun run robomp:install` installs the Python package for host development; `bun run robomp:serve` runs it on the host; `bun run robomp:build`/`bun run robomp:rebuild`, `bun run robomp:up`, `bun run robomp:down`, `bun run robomp:restart`, `bun run robomp:logs`, `bun run robomp:dev`, and `bun run robomp:reset` manage the container deployment. - Prerequisites: Docker Compose v2, a host-reachable LiteLLM-style model proxy, container model configuration, a GitHub webhook endpoint, and a bot PAT with write access to every allowlisted repository. The default two-container deployment keeps the PAT in an HMAC-authenticated `gh-proxy` sidecar rather than the orchestrator. -### `packages/swarm-extension` — swarm orchestration - -Sources: [`packages/swarm-extension/README.md`](../packages/swarm-extension/README.md), [`packages/swarm-extension/package.json`](../packages/swarm-extension/package.json), [`packages/swarm-extension/src/cli.ts`](../packages/swarm-extension/src/cli.ts), [`packages/swarm-extension/src/extension.ts`](../packages/swarm-extension/src/extension.ts). - -- Package: `@oh-my-pi/swarm-extension`; bin: `omp-swarm`. -- Feature: multi-agent DAG orchestration from YAML swarms, supporting `pipeline`, `parallel`, and `sequential` modes. -- Standalone CLI: `omp-swarm path/to/swarm.yaml` runs until completion or process termination. -- TUI extension mode: add the package path to `extensions`, then use `/swarm run `, `/swarm status `, or `/swarm help`. -- Inputs: YAML under top-level `swarm` with `name`, `workspace`, `mode`, optional `target_count`/`model`, and `agents` with `role`, `task`, optional `model`, `waits_for`, and `reports_to`. -- Side effects/output: creates the workspace if needed and persists state/logs under `/.swarm_/`. -- Limits/errors: validates the YAML definition, dependency graph, and cycles before execution; standalone runs have no built-in timeout. - ### `packages/stats` — local usage dashboard Sources: [`packages/stats/README.md`](../packages/stats/README.md), [`packages/stats/package.json`](../packages/stats/package.json), [`packages/coding-agent/src/cli/stats-cli.ts`](../packages/coding-agent/src/cli/stats-cli.ts). diff --git a/package.json b/package.json index e763563f7..70c358a22 100644 --- a/package.json +++ b/package.json @@ -26,19 +26,19 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.2.8", - "@oh-my-pi/omp-stats": "17.2.8", - "@oh-my-pi/omptype": "17.2.8", - "@oh-my-pi/pi-agent-core": "17.2.8", - "@oh-my-pi/pi-ai": "17.2.8", - "@oh-my-pi/pi-catalog": "17.2.8", - "@oh-my-pi/pi-coding-agent": "17.2.8", - "@oh-my-pi/pi-mnemopi": "17.2.8", - "@oh-my-pi/pi-natives": "17.2.8", - "@oh-my-pi/pi-tui": "17.2.8", - "@oh-my-pi/pi-utils": "17.2.8", - "@oh-my-pi/pi-wire": "17.2.8", - "@oh-my-pi/snapcompact": "17.2.8", + "@oh-my-pi/hashline": "17.2.9", + "@oh-my-pi/omp-stats": "17.2.9", + "@oh-my-pi/omptype": "17.2.9", + "@oh-my-pi/pi-agent-core": "17.2.9", + "@oh-my-pi/pi-ai": "17.2.9", + "@oh-my-pi/pi-catalog": "17.2.9", + "@oh-my-pi/pi-coding-agent": "17.2.9", + "@oh-my-pi/pi-mnemopi": "17.2.9", + "@oh-my-pi/pi-natives": "17.2.9", + "@oh-my-pi/pi-tui": "17.2.9", + "@oh-my-pi/pi-utils": "17.2.9", + "@oh-my-pi/pi-wire": "17.2.9", + "@oh-my-pi/snapcompact": "17.2.9", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -117,7 +117,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", + "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: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 844d6f388..e42677ce5 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Fixed + +- Preserved queued steering and follow-up messages when a continuation is cancelled before or during pre-dequeue hooks, and propagated the caller's cancellation signal through every continuation model-call loop. + ## [17.2.6] - 2026-08-03 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index effde2654..3d88812ad 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.2.8", + "version": "17.2.9", "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/agent-loop.ts b/packages/agent/src/agent-loop.ts index fbce2f756..9cd8b1671 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1016,7 +1016,7 @@ async function runLoopBody( // Skip when the run is already externally aborted — dequeuing would strand // the messages in a run that is about to die. try { - pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; } catch (error) { stream.push({ type: "turn_start" }); emitInputMessages(stream, messagesToEmit); @@ -1075,7 +1075,7 @@ async function runLoopBody( let gateResult: AgentPreModelCallResult; try { if (config.syncContextBeforeModelCall) { - await config.syncContextBeforeModelCall(currentContext); + await config.syncContextBeforeModelCall(currentContext, signal); } if (!directiveResolvedForTurn) { @@ -1421,7 +1421,7 @@ async function runLoopBody( // instantly aborts — message lands in history, agent never responds. The // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue // still owns every message until this dequeue. - const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; if (hasMoreToolCalls) { // Mid-work: fold any non-interrupting asides into the next turn alongside steering. const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.()); @@ -1450,9 +1450,9 @@ async function runLoopBody( // Re-poll steering too: a steer can land between the stop-boundary dequeue // above and this yield point (e.g. queued while onBeforeYield ran). Without // this poll it would strand in the queue until the next manual prompt. - const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.()); - const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || []; + const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.(signal)) || []; if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) { // Set as pending so the inner loop processes them before stopping. pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages]; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 8a17735c0..c2e779a7e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -426,6 +426,8 @@ export class Agent { #asideMessageProvider?: () => AsideMessage[] | Promise; #telemetry?: AgentLoopConfig["telemetry"]; #appendOnlyContext?: AppendOnlyContextManager; + #beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise | void>(); + #beforeModelCallHooks = new Set<(signal?: AbortSignal) => Promise | void>(); /** Buffered Cursor tool results with text length at time of call (for correct ordering) */ #cursorToolResultBuffer: CursorToolResultEntry[] = []; @@ -784,6 +786,40 @@ export class Agent { return () => this.#listeners.delete(fn); } + /** Register an independently removable hook that runs before queued messages are consumed. */ + addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise | void): () => void { + const registration = (signal?: AbortSignal) => hook(signal); + this.#beforeQueuedMessageDequeueHooks.add(registration); + return () => this.#beforeQueuedMessageDequeueHooks.delete(registration); + } + + /** Register an independently removable hook that runs immediately before each model call. */ + addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise | void): () => void { + const registration = (signal?: AbortSignal) => hook(signal); + this.#beforeModelCallHooks.add(registration); + return () => this.#beforeModelCallHooks.delete(registration); + } + + async #runBeforeModelCallHooks(signal?: AbortSignal): Promise { + for (const hook of this.#beforeModelCallHooks) await hook(signal); + } + + async #runBeforeQueuedMessageDequeueHooks(signal?: AbortSignal): Promise { + for (const hook of this.#beforeQueuedMessageDequeueHooks) await hook(signal); + } + + async #dequeueSteeringMessagesAfterHooks(signal?: AbortSignal): Promise { + if (signal?.aborted || this.#steeringQueue.length === 0) return []; + await this.#runBeforeQueuedMessageDequeueHooks(signal); + return signal?.aborted ? [] : this.#dequeueSteeringMessages(); + } + + async #dequeueFollowUpMessagesAfterHooks(signal?: AbortSignal): Promise { + if (signal?.aborted || this.#followUpQueue.length === 0) return []; + await this.#runBeforeQueuedMessageDequeueHooks(signal); + return signal?.aborted ? [] : this.#dequeueFollowUpMessages(); + } + setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void { this.#onResponse = fn; } @@ -1137,48 +1173,90 @@ export class Agent { /** * Continue from current context (used for retries and resuming queued messages). */ - async continue() { + #continuationDequeueSignal(signal?: AbortSignal): AbortSignal | undefined { + const signals: AbortSignal[] = []; + if (this.#abortController) signals.push(this.#abortController.signal); + if (signal) signals.push(signal); + if (this.#deadline !== undefined) { + const delay = this.#deadline - Date.now(); + if (delay <= 0) { + const controller = new AbortController(); + controller.abort(new DOMException("Deadline exceeded", "TimeoutError")); + signals.push(controller.signal); + } else { + signals.push(AbortSignal.timeout(delay)); + } + } + if (signals.length === 0) return undefined; + return signals.length === 1 ? signals[0] : AbortSignal.any(signals); + } + + async continue(signal?: AbortSignal) { if (this.#state.isStreaming) { throw new AgentBusyError(); } - const messages = this.#state.messages; - if (messages.length === 0) { - // An empty transcript has nothing to resume, but a queued steer/follow-up - // must still be delivered as the opening turn — mirroring the assistant-tail - // branch below. Throwing here leaves the message undeliverable, and idle-drain - // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every - // microtask because hasQueuedMessages() never clears, spinning an unbounded - // allocation loop until OOM (issue #6344). - const queuedSteering = this.#dequeueSteeringMessages(); - if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }); - return; + const { promise, resolve } = Promise.withResolvers(); + this.#runningPrompt = promise; + this.#resolveRunningPrompt = resolve; + const continuationAbortController = new AbortController(); + this.#abortController = continuationAbortController; + this.#state.isStreaming = true; + this.#state.streamMessage = null; + this.#state.error = undefined; + + try { + const dequeueSignal = this.#continuationDequeueSignal(signal); + const messages = this.#state.messages; + if (messages.length === 0) { + // An empty transcript has nothing to resume, but a queued steer/follow-up + // must still be delivered as the opening turn — mirroring the assistant-tail + // branch below. Throwing here leaves the message undeliverable, and idle-drain + // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every + // microtask because hasQueuedMessages() never clears, spinning an unbounded + // allocation loop until OOM (issue #6344). + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); + if (queuedSteering.length > 0) { + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true); + return; + } + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); + if (queuedFollowUp.length > 0) { + await this.#runLoop(queuedFollowUp, undefined, signal, true); + return; + } + throw new Error("No messages to continue from"); } - const queuedFollowUp = this.#dequeueFollowUpMessages(); - if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp); - return; + if (messages[messages.length - 1].role === "assistant") { + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); + if (queuedSteering.length > 0) { + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true); + return; + } + + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); + if (queuedFollowUp.length > 0) { + await this.#runLoop(queuedFollowUp, undefined, signal, true); + return; + } + + throw new Error("Cannot continue from message role: assistant"); + } + + await this.#runLoop(undefined, undefined, signal, true); + } finally { + resolve(); + if (this.#abortController === continuationAbortController) { + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls.clear(); + this.#abortController = undefined; + if (this.#runningPrompt === promise) { + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; + } } - throw new Error("No messages to continue from"); } - if (messages[messages.length - 1].role === "assistant") { - const queuedSteering = this.#dequeueSteeringMessages(); - if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }); - return; - } - - const queuedFollowUp = this.#dequeueFollowUpMessages(); - if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp); - return; - } - - throw new Error("Cannot continue from message role: assistant"); - } - - await this.#runLoop(undefined); } /** @@ -1186,17 +1264,29 @@ export class Agent { * If messages are provided, starts a new conversation turn with those messages. * Otherwise, continues from existing context. */ - async #runLoop(messages?: AgentMessage[], options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }) { + async #runLoop( + messages?: AgentMessage[], + options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }, + continuationSignal?: AbortSignal, + runStateClaimed = false, + ) { const model = this.#state.model; if (!model) throw new Error("No model configured"); let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true; using _ = new EventLoopKeepalive(); - const { promise, resolve } = Promise.withResolvers(); - this.#runningPrompt = promise; - this.#resolveRunningPrompt = resolve; - - this.#abortController = new AbortController(); + if (!runStateClaimed) { + const { promise, resolve } = Promise.withResolvers(); + this.#runningPrompt = promise; + this.#resolveRunningPrompt = resolve; + this.#abortController = new AbortController(); + } + const resolveRun = this.#resolveRunningPrompt; + const loopAbortController = this.#abortController; + if (!loopAbortController) throw new Error("Agent run state was not initialized"); + const loopSignal = continuationSignal + ? AbortSignal.any([loopAbortController.signal, continuationSignal]) + : loopAbortController.signal; this.#state.isStreaming = true; this.#state.streamMessage = null; this.#state.error = undefined; @@ -1315,7 +1405,8 @@ export class Agent { onSseEvent: this.#onSseEvent, getApiKey: this.getApiKey, getToolContext: this.#getToolContext, - syncContextBeforeModelCall: async context => { + syncContextBeforeModelCall: async (context, signal) => { + await this.#runBeforeModelCallHooks(signal); if (this.#listeners.size > 0) { await Bun.sleep(0); } @@ -1362,12 +1453,12 @@ export class Agent { getReasoning: () => this.#state.thinkingLevel, getDisableReasoning: () => this.#state.disableReasoning, getServiceTier: this.#serviceTierResolver, - getSteeringMessages: async () => { + getSteeringMessages: async signal => { if (skipInitialSteeringPoll) { skipInitialSteeringPoll = false; return []; } - return this.#dequeueSteeringMessages(); + return this.#dequeueSteeringMessagesAfterHooks(signal); }, hasSteeringMessages: () => { if (this.#steeringQueue.length === 0) { @@ -1392,7 +1483,7 @@ export class Agent { }, waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal), hasIrcInterrupts: this.hasIrcInterrupts, - getFollowUpMessages: async () => this.#dequeueFollowUpMessages(), + getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal), getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [], onBeforeYield: () => this.#onBeforeYield?.(), telemetry: this.#telemetry, @@ -1404,8 +1495,8 @@ export class Agent { try { const stream = messages - ? agentLoop(messages, context, config, this.#abortController.signal, this.streamFn) - : agentLoopContinue(context, config, this.#abortController.signal, this.streamFn); + ? agentLoop(messages, context, config, loopSignal, this.streamFn) + : agentLoopContinue(context, config, loopSignal, this.streamFn); for await (const event of stream) { if (event.type === "turn_start") turnOpen = true; @@ -1472,15 +1563,15 @@ export class Agent { if (!onlyEmpty) { this.appendMessage(partial); } else { - if (this.#abortController?.signal.aborted) { + if (loopSignal.aborted) { throw new Error("Request was aborted"); } } } } catch (err) { - const stoppedForAbort = this.#abortController?.signal.aborted === true; + const stoppedForAbort = loopSignal.aborted; const errorMessage = stoppedForAbort - ? abortReasonText(this.#abortController?.signal) + ? abortReasonText(loopSignal) : err instanceof Error ? err.message : String(err); @@ -1582,13 +1673,15 @@ export class Agent { this.#emit({ type: "agent_end", messages: [errorMsg] }); } } finally { - this.#state.isStreaming = false; - this.#state.streamMessage = null; - this.#state.pendingToolCalls.clear(); - this.#abortController = undefined; - this.#resolveRunningPrompt?.(); - this.#runningPrompt = undefined; - this.#resolveRunningPrompt = undefined; + resolveRun?.(); + if (this.#abortController === loopAbortController) { + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls.clear(); + this.#abortController = undefined; + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; + } } } diff --git a/packages/agent/src/compaction/errors.ts b/packages/agent/src/compaction/errors.ts index ed34484dd..6a44a86f5 100644 --- a/packages/agent/src/compaction/errors.ts +++ b/packages/agent/src/compaction/errors.ts @@ -11,7 +11,7 @@ */ export class CompactionCancelledError extends Error { - readonly name = "CompactionCancelledError" as const; + override readonly name = "CompactionCancelledError" as const; constructor(message = "Compaction cancelled") { super(message); @@ -27,7 +27,7 @@ export class CompactionCancelledError extends Error { * ordinary summarization errors and must not fall through to another provider. */ export class NativeCompactionError extends Error { - readonly name = "NativeCompactionError" as const; + override readonly name = "NativeCompactionError" as const; constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause), { cause }); diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index de90a681e..198c9a4a3 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -240,7 +240,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * mid-batch interrupt poll uses {@link hasSteeringMessages} instead and * never consumes the queue. */ - getSteeringMessages?: () => Promise; + getSteeringMessages?: (signal?: AbortSignal) => Promise; /** * Peeks whether steering messages are queued, without consuming them. @@ -285,7 +285,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * If messages are returned, they're added to the context and the agent * continues with another turn. */ - getFollowUpMessages?: () => Promise; + getFollowUpMessages?: (signal?: AbortSignal) => Promise; /** * Returns non-interrupting "aside" messages to inject at a step boundary. * @@ -319,7 +319,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * Mutate the agent context here; use `beforeModelCall` to inspect the * provider-bound context. */ - syncContextBeforeModelCall?: (context: AgentContext) => void | Promise; + syncContextBeforeModelCall?: (context: AgentContext, signal?: AbortSignal) => void | Promise; /** * Asked after the complete provider context has been built, including diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index a887758c8..1f4604efe 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { Agent, type AgentEvent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Agent, AgentBusyError, type AgentEvent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type SimpleStreamOptions, type ToolResultMessage, z } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; @@ -240,6 +240,213 @@ describe("Agent", () => { } }); + it("removes duplicate queued-message hooks independently", async () => { + const mock = createMockModel({ responses: [{ content: ["first"] }, { content: ["second"] }] }); + const agent = new Agent({ streamFn: mock.stream }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + let calls = 0; + const signals: Array = []; + const hook = (signal?: AbortSignal) => { + calls++; + signals.push(signal); + }; + const removeFirst = agent.addBeforeQueuedMessageDequeueHook(hook); + const removeSecond = agent.addBeforeQueuedMessageDequeueHook(hook); + + const controller = new AbortController(); + removeFirst(); + agent.followUp({ role: "user", content: "first turn", timestamp: Date.now() }); + await agent.continue(controller.signal); + expect(calls).toBe(1); + expect(signals).toEqual([controller.signal]); + + removeSecond(); + agent.followUp({ role: "user", content: "second turn", timestamp: Date.now() }); + await agent.continue(); + expect(calls).toBe(1); + }); + + it("continue() leaves queued messages owned when its signal is already aborted", async () => { + const agent = new Agent(); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "stay queued", timestamp: Date.now() }); + const controller = new AbortController(); + controller.abort(); + + await expect(agent.continue(controller.signal)).rejects.toThrow("Cannot continue from message role: assistant"); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + it("keeps follow-up ownership when the deadline expires during a dequeue hook", async () => { + const mock = createMockModel({ responses: [{ content: ["done"] }] }); + const agent = new Agent({ streamFn: mock.stream, deadline: Date.now() + 25 }); + let hookSignal: AbortSignal | undefined; + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected the active loop signal"); + hookSignal = signal; + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + agent.followUp({ role: "user", content: "stay queued after deadline", timestamp: Date.now() }); + + await agent.prompt("start"); + + expect(hookSignal?.aborted).toBe(true); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + it("keeps queued work when continue() reaches its deadline inside a dequeue hook", async () => { + const agent = new Agent({ deadline: Date.now() + 25 }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected the deadline-aware dequeue signal"); + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + agent.followUp({ role: "user", content: "stay queued before run loop", timestamp: Date.now() }); + + await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant"); + + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + + it("claims an abortable busy state while continue() awaits dequeue hooks", async () => { + const agent = new Agent(); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "stay queued", timestamp: Date.now() }); + const hookStarted = Promise.withResolvers(); + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected continuation dequeue signal"); + hookStarted.resolve(); + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + + const continuing = agent.continue(); + await hookStarted.promise; + let idleResolved = false; + const idle = agent.waitForIdle().then(() => { + idleResolved = true; + }); + await Promise.resolve(); + + expect(agent.state.isStreaming).toBe(true); + expect(idleResolved).toBe(false); + await expect(agent.prompt("must not overlap")).rejects.toBeInstanceOf(AgentBusyError); + + agent.abort("cancel dequeue"); + await expect(continuing).rejects.toThrow("Cannot continue from message role: assistant"); + await idle; + expect(idleResolved).toBe(true); + expect(agent.state.isStreaming).toBe(false); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + + it("does not clear a successor prompt after continue() releases idle waiters", async () => { + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const releaseSecond = Promise.withResolvers(); + const mock = createMockModel({ + responses: [ + async () => { + firstStarted.resolve(); + await releaseFirst.promise; + return { content: ["continued"] }; + }, + async () => { + secondStarted.resolve(); + await releaseSecond.promise; + return { content: ["successor"] }; + }, + ], + }); + const agent = new Agent({ streamFn: mock.stream }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "continue", timestamp: Date.now() }); + + const continuing = agent.continue(); + await firstStarted.promise; + const successor = agent.waitForIdle().then(() => agent.prompt("next prompt")); + releaseFirst.resolve(); + await secondStarted.promise; + await continuing; + + expect(agent.state.isStreaming).toBe(true); + releaseSecond.resolve(); + await successor; + expect(agent.state.isStreaming).toBe(false); + }); + + it("resolves a predecessor idle waiter when agent_end starts a successor", async () => { + const secondStarted = Promise.withResolvers(); + const releaseSecond = Promise.withResolvers(); + const mock = createMockModel({ + responses: [ + { content: ["first"] }, + async () => { + secondStarted.resolve(); + await releaseSecond.promise; + return { content: ["second"] }; + }, + ], + }); + const agent = new Agent({ streamFn: mock.stream }); + let successor: Promise | undefined; + agent.subscribe(event => { + if (event.type === "agent_end" && !successor) { + successor = agent.prompt("successor"); + } + }); + + const predecessor = agent.prompt("predecessor"); + let predecessorIdleResolved = false; + void agent.waitForIdle().then(() => { + predecessorIdleResolved = true; + }); + await secondStarted.promise; + await predecessor; + expect(agent.state.isStreaming).toBe(true); + + releaseSecond.resolve(); + await successor; + await Promise.resolve(); + expect(predecessorIdleResolved).toBe(true); + expect(agent.state.isStreaming).toBe(false); + }); + + it("classifies an in-flight continuation cancellation as aborted", async () => { + const providerStarted = Promise.withResolvers(); + const agent = new Agent({ + streamFn: (_model, _context, options) => { + const signal = options?.signal; + if (!signal) throw new Error("Expected provider abort signal"); + providerStarted.resolve(signal); + const stream = new AssistantMessageEventStream(); + signal.addEventListener("abort", () => stream.fail(new Error("provider aborted")), { once: true }); + return stream; + }, + }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "cancel this continuation", timestamp: Date.now() }); + const controller = new AbortController(); + + const running = agent.continue(controller.signal); + await providerStarted.promise; + controller.abort("caller cancelled"); + await running; + + const finalMessage = agent.state.messages.at(-1); + expect(finalMessage?.role).toBe("assistant"); + if (finalMessage?.role !== "assistant") throw new Error("Expected aborted assistant message"); + expect(finalMessage.stopReason).toBe("aborted"); + expect(finalMessage.errorMessage).toBe("caller cancelled"); + }); + it("continue() should process queued follow-up messages after an assistant turn", async () => { const mock = createMockModel({ responses: [{ content: ["Processed"] }] }); const agent = new Agent({ streamFn: mock.stream }); @@ -276,6 +483,12 @@ describe("Agent", () => { responses: [{ content: ["Processed 1"] }, { content: ["Processed 2"] }], }); const agent = new Agent({ streamFn: mock.stream }); + let dequeueHooks = 0; + const dequeueSignals: Array = []; + agent.addBeforeQueuedMessageDequeueHook(signal => { + dequeueHooks++; + dequeueSignals.push(signal); + }); agent.replaceMessages([ { @@ -297,11 +510,16 @@ describe("Agent", () => { timestamp: Date.now() + 1, }); - await expect(agent.continue()).resolves.toBeUndefined(); + const controller = new AbortController(); + await expect(agent.continue(controller.signal)).resolves.toBeUndefined(); const recentMessages = agent.state.messages.slice(-4); expect(recentMessages.map(m => m.role)).toEqual(["user", "assistant", "user", "assistant"]); expect(mock.calls.length).toBe(2); + expect(dequeueHooks).toBe(2); + expect(dequeueSignals).toHaveLength(2); + controller.abort(); + expect(dequeueSignals.every(signal => signal?.aborted === true)).toBe(true); }); it("delivers a steer that lands at the yield boundary instead of stranding it", async () => { @@ -856,6 +1074,10 @@ describe("Agent", () => { }, streamFn: mock.stream, }); + let beforeModelCalls = 0; + agent.addBeforeModelCallHook(() => { + beforeModelCalls++; + }); const unsubscribe = agent.subscribe(event => { if (event.type === "message_end" && event.message.role === "toolResult") { @@ -875,6 +1097,7 @@ describe("Agent", () => { { systemPrompt: "prompt-one", toolNames: ["alpha"] }, { systemPrompt: "prompt-two", toolNames: ["alpha", "beta"] }, ]); + expect(beforeModelCalls).toBe(2); }); it("prompt() drops stale forced toolChoice after same-turn tool refresh", async () => { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3f4da9b40..df85314e0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Fixed + +- Fixed GitHub Copilot requests failing with a raw `HTTP 400 model_not_available_for_integrator` on roughly half of all turns for recently rolled-out models. Copilot's fleet is not uniform — part of it rejects models that `/models` advertises on the same host — and the transient classifier matched only the older `model_not_supported` code at a fixed envelope depth, so these rejections surfaced as terminal errors instead of entering the existing retry path. Model-availability 400s are now recognized at any envelope depth and rerolled on a flat delay with a dedicated 8-attempt budget on the OpenAI transports; every other retryable failure keeps its previous backoff and attempt count. +- Fixed Cursor reads with inline OMP range selectors reporting the returned slice length as the source file's `totalLines`, which made sequential reads of an unchanged file appear inconsistent ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). +- Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. +- Fixed OpenAI Codex usage telemetry blocking explicitly allowed ChatGPT Team credentials when a weekly `used_percent` rounded to 100, which could route multi-account sessions to an actually exhausted sibling instead ([#7617](https://github.com/can1357/oh-my-pi/issues/7617)). +- Fixed OpenAI Codex GPT-5.x requests sending optional `reasoning.summary`, `reasoning.context`, and `text.verbosity` controls by default, reducing Codex `server_error` disconnects from unsupported request shapes. ([#4949](https://github.com/can1357/oh-my-pi/issues/4949)) +- Classified concurrent-request caps separately from quota exhaustion so they use a short retry backoff without burning a credential, and rotate credentials for account-scoped 403 caps such as Devin's overall message limit. + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/ai/package.json b/packages/ai/package.json index 6d9876dad..b0199d06a 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.2.8", + "version": "17.2.9", "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-retry.ts b/packages/ai/src/auth-retry.ts index 2e4bda417..972448b2e 100644 --- a/packages/ai/src/auth-retry.ts +++ b/packages/ai/src/auth-retry.ts @@ -3,7 +3,7 @@ import type { OAuthAccess } from "./auth-storage"; import * as AIError from "./error"; import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify"; import { isUsageLimit } from "./error/flags"; -import { isUsageLimitOutcome } from "./error/rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit"; /** * Context passed to an {@link ApiKeyResolver} on each resolution attempt. @@ -93,11 +93,14 @@ export const AUTH_RETRY_MAX_ATTEMPTS = 64; function isDirectCredentialRotationError(error: unknown): boolean { if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true; const status = AIError.status(error); - // 403: the token is valid but access was denied, so refreshing the same - // credential can't help — rotate straight through the sibling pool. - if (status === 403) return true; const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; - if (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403) return true; + // A 403 normally means a valid token lacks access, so rotate through + // siblings. A concurrency-cap 403 is transient instead; do not burn a + // sibling before the caller's backoff layer can retry it. + const isForbidden = + status === 403 || + (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403); + if (isForbidden && !isConcurrencyCapExclusion(status, message)) return true; return isUsageLimitOutcome(status, message); } diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index e1c76bda2..9344aefc4 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -5,14 +5,17 @@ * This module defines: * - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …) * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh - * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation + * - re-exported `SqliteAuthCredentialStore`: concrete SQLite-backed implementation */ -import { Database, type Statement } from "bun:sqlite"; import { createHash } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; -import { $env, $envExact, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; +import { $env, $envExact, extractRetryHint, getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; +import { + isSqliteCorruptionError, + resolveCredentialIdentityKey, + SqliteAuthCredentialStore, + serializeCredential, + USAGE_REPORT_TTL_MS, +} from "./auth/sqlite-credential-store"; import type { ApiKeyResolver } from "./auth-retry"; import * as AIError from "./error"; import { isUsageLimitOutcome } from "./error/rate-limit"; @@ -28,7 +31,6 @@ import type { import { getEnvApiKey, getEnvApiKeyName } from "./stream"; import type { Provider } from "./types"; import type { - ClientProviderUsage, ClientUsageReport, ClientUsageSummary, CredentialRankingContext, @@ -70,6 +72,12 @@ import { umansUsageProvider } from "./usage/umans"; import { xaiOauthUsageProvider } from "./usage/xai-oauth"; import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai"; +export { + isSqliteBusyError, + isSqliteCorruptionError, + SqliteAuthCredentialStore, +} from "./auth/sqlite-credential-store"; + const USAGE_RANKING_METRIC_EPSILON = 1e-9; /** * Primary (short, e.g. 5h) window used-fraction at or above which a candidate @@ -675,26 +683,8 @@ const DEFAULT_USAGE_PROVIDER_MAP = new Map( ); const USAGE_CACHE_PREFIX = "usage_cache:"; -// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP -// level so we can't fetch all N credentials every cycle; with a long cache -// each credential's last-known value sticks visible while peers retry. UI -// data (5h / 7d / monthly limits) is fine being a few minutes stale. -const USAGE_REPORT_TTL_MS = 5 * 60_000; const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000; const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000; -/** - * Downsample usage history to at most one row per hour per account window: a - * snapshot landing in the same hour bucket as the series' latest row - * overwrites it in place. That bound makes further retention pruning - * unnecessary — 1 row/hour is ~9k rows per account window per year. - */ -const USAGE_HISTORY_BUCKET_MS = 60 * 60_000; -/** - * Merge client observed-usage flushes into at most one row per 5 minutes per - * (install, provider, model): ~300 rows/day per active model per client - * instead of one row per 10s flush. - */ -const CLIENT_USAGE_BUCKET_MS = 5 * 60_000; /** * Per-credential cool-down after a usage fetch fails. While this window is * active we serve the last successful value to avoid dropping the credential @@ -3708,7 +3698,7 @@ export class AuthStorage { } #isUsageLimitExhausted(limit: UsageLimit): boolean { - if (limit.status === "exhausted") return true; + if (limit.status !== undefined && limit.status !== "unknown") return limit.status === "exhausted"; const amount = limit.amount; if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true; if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true; @@ -3856,13 +3846,15 @@ export class AuthStorage { : undefined; const rankingContext: CredentialRankingContext = { modelId: options.modelId }; + const planRequirement = resolveOpenAICodexPlanRequirement(provider, options.modelId); + const planEligibilityByCredential = new Map(); const blockScope = strategy.blockScope?.(rankingContext); const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []); const reserveFraction = Number.isFinite(options.reserveFraction) ? Math.max(0, Math.min(1, options.reserveFraction)) : 0; const nowMs = Date.now(); - const accounts = await Promise.all( + let accounts = await Promise.all( pool.map(async ({ entry, index }): Promise => { const credentialType = entry.credential.type; const providerKey = this.#getProviderTypeKey(provider, credentialType); @@ -3890,6 +3882,9 @@ export class AuthStorage { if (options.signal?.aborted) throw error; report = null; } + if (planRequirement !== "none") { + planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement)); + } if (provider === "openai-codex") { blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes); @@ -3942,6 +3937,9 @@ export class AuthStorage { }; }), ); + if (planRequirement !== "none") { + accounts = accounts.filter(account => planEligibilityByCredential.get(account.credentialId) !== false); + } if (selectedCredentialId !== undefined) { const selectedAccount = accounts.find(account => account.credentialId === selectedCredentialId); if (selectedAccount) selectedAccount.selected = true; @@ -6095,8 +6093,13 @@ export class AuthStorage { const status = AIError.status(error); const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) { + // Thread the provider-specified reset window (e.g. Devin "Your limit + // will reset in 13 minutes") into the block duration so the credential + // is not reselected and hammered while the cap remains active. + const retryAfterMs = extractRetryHint(undefined, message); return ( await this.markUsageLimitReached(provider, sessionId, { + retryAfterMs, modelId: options?.modelId, apiKey: options?.apiKey, credentialId: options?.credentialId, @@ -6535,1999 +6538,3 @@ export class AuthStorage { return undefined; } } - -// ───────────────────────────────────────────────────────────────────────────── -// SqliteAuthCredentialStore -// ───────────────────────────────────────────────────────────────────────────── - -/** Row shape for auth_credentials table queries */ -type AuthRow = { - id: number; - provider: string; - credential_type: string; - data: string; - disabled_cause: string | null; - identity_key: string | null; -}; - -/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */ -type DisabledAuthRow = AuthRow & { updated_at: number | null }; - -type CredentialBlockRow = { - credential_id: number; - provider_key: string; - block_scope: string; - blocked_until_ms: number; - updated_at: number; -}; - -type SerializedCredentialRecord = { - credentialType: AuthCredential["type"]; - data: string; - identityKey: string | null; -}; - -const AUTH_SCHEMA_VERSION = 7; -const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; -const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth"; -const LEGACY_CODEX_BLOCK_SCOPE = "shared"; -const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const; - -/** - * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended - * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`, - * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment. - */ -export function isSqliteBusyError(err: unknown): boolean { - if (err === null || typeof err !== "object") return false; - const code = (err as { code?: unknown }).code; - return typeof code === "string" && code.startsWith("SQLITE_BUSY"); -} - -/** - * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family - * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`) - * and `SQLITE_NOTADB` (the file header is not a database). Unlike - * {@link isSqliteBusyError}, these never clear by retrying: the store must be - * repaired or replaced, so callers latch and stop touching it. - */ -export function isSqliteCorruptionError(err: unknown): boolean { - if (err === null || typeof err !== "object" || !("code" in err)) return false; - const code = err.code; - return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB"); -} - -function normalizeStoredAccountId(accountId: string | null | undefined): string | null { - const normalized = accountId?.trim(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeStoredEmail(email: string | null | undefined): string | null { - const normalized = email?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null { - const normalized = identityKey?.trim(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null { - if (credential.type === "api_key") { - const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key }; - return { - credentialType: "api_key", - data: JSON.stringify(data), - identityKey: null, - }; - } - if (credential.type === "oauth") { - const { type: _type, ...rest } = credential; - return { - credentialType: "oauth", - data: JSON.stringify(rest), - identityKey: resolveCredentialIdentityKey(provider, credential), - }; - } - return null; -} - -function deserializeCredential(row: AuthRow): AuthCredential | null { - let parsed: unknown; - try { - parsed = JSON.parse(row.data); - } catch { - return null; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - if (row.credential_type === "api_key") { - const data = parsed as Record; - if (typeof data.key === "string") { - const source = data.source === "login" ? "login" : undefined; - return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key }; - } - } - if (row.credential_type === "oauth") { - return { type: "oauth", ...(parsed as Record) } as AuthCredential; - } - return null; -} - -function normalizeDisabledCause(disabledCause: string): string { - const normalized = disabledCause.trim(); - return normalized.length > 0 ? normalized : "disabled"; -} - -function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential { - return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause }; -} - -function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null { - const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:")); - if (provider === "anthropic" || provider === "openai-codex") { - // One account email can hold several organizations/workspaces (e.g. a - // Team seat plus a personal plan), each with its own org-scoped token - // and limit pools. Scope identity by org so both subscriptions can be - // stored side by side. The qualifier rides on whichever base identity - // is available, so an unqualified account/project fallback would - // still collapse two subscriptions whenever the email could not be - // recovered. Org-less credentials (rows written before org capture - // existed) keep their bare key. - const base = - emailIdentifier ?? - identifiers.find(identifier => identifier.startsWith("account:")) ?? - identifiers.find(identifier => identifier.startsWith("project:")); - const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); - if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base; - // No base identity at all: the org alone still distinguishes the row. - return orgIdentifier ?? null; - } - const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); - if (accountIdentifier) return accountIdentifier; - if (emailIdentifier) return emailIdentifier; - const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:")); - if (projectIdentifier) return projectIdentifier; - return null; -} - -function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null { - if (credential.type === "api_key") return null; - return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)); -} - -function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null { - const identityKey = normalizeStoredIdentityKey(row.identity_key); - if (identityKey) return identityKey; - const credential = deserializeCredential(row); - return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null; -} - -function matchesReplacementCredential( - provider: string, - existing: AuthCredential | null, - existingIdentityKey: string | null, - incoming: AuthCredential, -): boolean { - if (!existing || existing.type !== incoming.type) return false; - if (incoming.type === "api_key") { - if (existing.type !== "api_key") return false; - if (existing.key === incoming.key) return true; - if (provider !== "alibaba-token-plan") return false; - const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token; - const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token; - return existingToken !== undefined && existingToken === incomingToken; - } - const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming); - const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers); - if (incomingIdentityKey === null) return false; - if (incomingIdentityKey === existingIdentityKey) return true; - if (existingIdentityKey === null) return false; - // One-way upgrade, applied only when the INCOMING identity key carries the - // org qualifier (only anthropic and openai-codex keys do, so other - // providers never reach the checks below). An org-scoped login `org:` - // claims (and re-keys) any existing row that denotes the same subscription: - // - `org:` — org-only row stored when identity recovery failed, claimed - // once a later same-org login recovers a base identity; - // - `` for any base identity `` (email/account/project) the incoming - // credential carries — a pre-org legacy row, mirroring the pre-org - // replace behavior; - // - `|org:` for any such base — the same subscription keyed by a - // different base, e.g. an account-keyed row stored while the email could - // not be recovered, claimed once a later login recovers the email; - // - any same-org row whose STORED credential shares a base identity with - // the incoming one — a stored credential can retain identifiers its key - // does not use (an email-keyed row also carries the account UUID), so a - // later login that loses the email but keeps the account still updates - // its row instead of duplicating the subscription. - // The reverse stays a non-match: an org-less credential only ever replaces - // via exact key equality above and must never clobber an org-scoped row. - const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:")); - if (orgIdentifier === undefined) return false; - if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false; - if (existingIdentityKey === orgIdentifier) return true; - const existingIdentifiers = - existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`) - ? extractOAuthCredentialIdentifiers(existing) - : null; - // A base identifier that merely repeats the org qualifier's id carries no - // per-user identity (openai-codex stores the ChatGPT workspace id as both - // accountId and orgId, shared by every member) — letting it act as a - // claimable base would re-key another member's same-org row. - const orgQualifierId = orgIdentifier.slice("org:".length); - for (const identifier of incomingIdentifiers) { - const isBase = - identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:"); - if (!isBase) continue; - if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue; - if (existingIdentityKey === identifier) return true; - if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true; - if (existingIdentifiers?.includes(identifier)) return true; - } - return false; -} - -function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] { - const identifiers = new Set(); - const accountId = normalizeStoredAccountId(credential.accountId); - if (accountId) identifiers.add(`account:${accountId}`); - const email = normalizeStoredEmail(credential.email); - if (email) identifiers.add(`email:${email}`); - const projectId = normalizeStoredAccountId(credential.projectId); - if (projectId) identifiers.add(`project:${projectId}`); - const orgId = normalizeStoredAccountId(credential.orgId); - if (orgId) identifiers.add(`org:${orgId}`); - const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? []; - for (const identifier of accessIdentifiers) { - identifiers.add(identifier); - } - const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? []; - for (const identifier of refreshIdentifiers) { - identifiers.add(identifier); - } - return [...identifiers]; -} - -function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined { - if (!token) return undefined; - const parts = token.split("."); - if (parts.length !== 3) return undefined; - try { - const payload = JSON.parse( - new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })), - ) as Record; - const identifiers = new Set(); - const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined); - if (directEmail) identifiers.add(`email:${directEmail}`); - const openAiProfile = payload["https://api.openai.com/profile"]; - if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) { - const claimEmail = normalizeStoredEmail( - (openAiProfile as Record).email as string | undefined, - ); - if (claimEmail) identifiers.add(`email:${claimEmail}`); - } - const openAiAuth = payload["https://api.openai.com/auth"]; - const authClaims = - typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth) - ? (openAiAuth as Record) - : undefined; - const accountId = normalizeStoredAccountId( - typeof payload.account_id === "string" - ? payload.account_id - : typeof payload.accountId === "string" - ? payload.accountId - : typeof payload.user_id === "string" - ? payload.user_id - : typeof payload.sub === "string" - ? payload.sub - : typeof authClaims?.chatgpt_account_id === "string" - ? authClaims.chatgpt_account_id - : undefined, - ); - if (accountId) identifiers.add(`account:${accountId}`); - return identifiers.size > 0 ? [...identifiers] : undefined; - } catch { - return undefined; - } -} -/** - * Default SQLite-backed implementation of {@link AuthCredentialStore}. - * - * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. - * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, - * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly - * without going through `AuthStorage`. - */ -export class SqliteAuthCredentialStore implements AuthCredentialStore { - #db: Database; - #listActiveStmt: Statement; - #listActiveByProviderStmt: Statement; - #listDisabledStmt: Statement; - #listDisabledByProviderStmt: Statement; - #insertStmt: Statement; - #updateStmt: Statement; - #deleteStmt: Statement; - #deleteIfMatchesStmt: Statement; - #updateIfMatchesStmt: Statement; - #deleteByProviderStmt: Statement; - #hardDeleteStmt: Statement; - #getCacheStmt: Statement; - #getCacheIncludingExpiredStmt: Statement; - #upsertCacheStmt: Statement; - #deleteCachePrefixStmt: Statement; - #deleteExpiredCacheStmt: Statement; - #updateIfMatchesWithLeaseStmt: Statement; - #deleteIfMatchesWithLeaseStmt: Statement; - #getCredentialBlockStmt: Statement; - #listCredentialBlocksByCredentialStmt: Statement; - #upsertCredentialBlockStmt: Statement; - #deleteCredentialBlocksStmt: Statement; - #deleteCredentialBlockStmt: Statement; - #deleteExpiredCredentialBlocksStmt: Statement; - #acquireCredentialRefreshLeaseStmt: Statement; - #getCredentialRefreshLeaseStmt: Statement; - #renewCredentialRefreshLeaseStmt: Statement; - #releaseCredentialRefreshLeaseStmt: Statement; - #credentialBlockReconcileAfter: Map = new Map(); - #insertUsageHistoryStmt: Statement; - #insertUsageCostStmt: Statement; - #listUsageCostsStmt: Statement; - #lastUsageHistoryStmt: Statement; - #listUsageHistoryStmt: Statement; - #updateUsageHistoryStmt: Statement; - #dataVersion: number; - #authRevision: number; - #localAuthRevision: number; - #closed = false; - - constructor(db: Database) { - this.#db = db; - this.#initializeSchema(); - this.#dataVersion = this.#readDataVersion(); - this.#authRevision = this.#readAuthRevision(); - this.#localAuthRevision = this.#readLocalAuthRevision(); - - this.#listActiveStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC", - ); - this.#listActiveByProviderStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC", - ); - this.#listDisabledStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC", - ); - this.#listDisabledByProviderStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC", - ); - this.#insertStmt = this.#db.prepare( - `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`, - ); - this.#updateStmt = this.#db.prepare( - `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, - ); - this.#updateIfMatchesStmt = this.#db.prepare( - `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, - ); - this.#updateIfMatchesWithLeaseStmt = this.#db.prepare( - `UPDATE auth_credentials - SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} - WHERE id = ? AND data = ? AND disabled_cause IS NULL - AND EXISTS ( - SELECT 1 FROM auth_credential_refresh_leases - WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? - )`, - ); - this.#deleteStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, - ); - this.#deleteIfMatchesStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, - ); - this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare( - `UPDATE auth_credentials - SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} - WHERE id = ? AND data = ? AND disabled_cause IS NULL - AND EXISTS ( - SELECT 1 FROM auth_credential_refresh_leases - WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? - )`, - ); - this.#deleteByProviderStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`, - ); - this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?"); - this.#getCacheStmt = this.#db.prepare( - `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`, - ); - this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?"); - this.#upsertCacheStmt = this.#db.prepare( - "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", - ); - this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?"); - this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`); - this.#getCredentialBlockStmt = this.#db.prepare( - "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?", - ); - this.#listCredentialBlocksByCredentialStmt = this.#db.prepare( - `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE credential_id = ? AND blocked_until_ms > ? - AND NOT (provider_key = ? AND block_scope = ?) - ORDER BY provider_key ASC, block_scope ASC`, - ); - this.#upsertCredentialBlockStmt = this.#db.prepare( - `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at) - VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms), - updated_at = excluded.updated_at`, - ); - this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?"); - this.#deleteCredentialBlockStmt = this.#db.prepare( - "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?", - ); - this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare( - "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?", - ); - this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare( - `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at) - VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH}) - ON CONFLICT(credential_id) DO UPDATE SET - owner = excluded.owner, - expires_at_ms = excluded.expires_at_ms, - updated_at = excluded.updated_at - WHERE auth_credential_refresh_leases.expires_at_ms <= ?`, - ); - this.#getCredentialRefreshLeaseStmt = this.#db.prepare( - "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?", - ); - this.#renewCredentialRefreshLeaseStmt = this.#db.prepare( - `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`, - ); - this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare( - "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?", - ); - this.#insertUsageHistoryStmt = this.#db.prepare( - "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ); - this.#lastUsageHistoryStmt = this.#db.prepare( - "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1", - ); - this.#updateUsageHistoryStmt = this.#db.prepare( - "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?", - ); - this.#listUsageHistoryStmt = this.#db.prepare( - "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC", - ); - this.#insertUsageCostStmt = this.#db.prepare( - "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)", - ); - this.#listUsageCostsStmt = this.#db.prepare( - "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC", - ); - } - - static async open(dbPath: string = getAgentDbPath()): Promise { - const dir = path.dirname(dbPath); - const dirExists = await fs - .stat(dir) - .then(s => s.isDirectory()) - .catch(() => false); - if (!dirExists) { - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - } - - // Concurrent omp startups can race against WAL recovery and the schema - // init's first lock-taking statement. Bun's default `busy_timeout` is 0, - // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded - // exponential backoff before surfacing the failure. See issue #2421. - const maxAttempts = 4; - const baseDelayMs = 100; - let lastBusyError: Error | undefined; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - let db: Database | undefined; - try { - db = new Database(dbPath); - // Install the busy handler BEFORE the first lock-taking statement - // on this connection. The leases DDL below and the constructor's - // schema init both acquire locks during WAL recovery; without a - // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY. - // See issue #2421. - SqliteAuthCredentialStore.#installBusyTimeout(db); - try { - await fs.chmod(dbPath, 0o600); - } catch { - // Ignore chmod failures (e.g., Windows) - } - SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db); - return new SqliteAuthCredentialStore(db); - } catch (err) { - db?.close(); - if (!isSqliteBusyError(err)) { - throw err; - } - lastBusyError = err instanceof Error ? err : new Error(String(err)); - if (attempt < maxAttempts - 1) { - await Bun.sleep(baseDelayMs * 2 ** attempt); - } - } - } - throw new AIError.ConfigurationError( - `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`, - { cause: lastBusyError }, - ); - } - - static #ensureAuthCredentialRefreshLeasesTable(db: Database): void { - db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases ( - credential_id INTEGER PRIMARY KEY, - owner TEXT NOT NULL, - expires_at_ms INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms); - `); - } - - /** - * Install the per-connection busy handler so lock-taking statements wait for - * a contended writer instead of failing immediately (Bun defaults - * `busy_timeout` to 0). MUST run before the first lock-taking statement on - * the connection: concurrent omp startups race WAL recovery and the leases - * DDL. Uses the centralized timeout so headless hosts keep their bounded - * busy wait instead of the interactive 5s value. See issues #2421, #7298. - */ - static #installBusyTimeout(db: Database): void { - db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`); - } - - #initializeSchema(): void { - // Install the busy handler BEFORE any lock-taking statement (incl. - // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL - // recovery). Without this, concurrent omp startups can crash here with - // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via - // `open()` (which already installed it) is idempotent. See issue #2421. - SqliteAuthCredentialStore.#installBusyTimeout(this.#db); - this.#db.run(` - PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - CREATE TABLE IF NOT EXISTS auth_schema_version ( - id INTEGER PRIMARY KEY CHECK (id = 1), - version INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS cache ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - expires_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at); - CREATE TABLE IF NOT EXISTS usage_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - provider TEXT NOT NULL, - account_key TEXT NOT NULL, - email TEXT, - account_id TEXT, - limit_id TEXT NOT NULL, - label TEXT NOT NULL, - window_label TEXT, - used_fraction REAL, - status TEXT, - resets_at INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at); - CREATE TABLE IF NOT EXISTS usage_cost_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - provider TEXT NOT NULL, - account_key TEXT NOT NULL, - cost_usd REAL NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at); - CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at); - CREATE TABLE IF NOT EXISTS clients ( - install_id TEXT PRIMARY KEY, - hostname TEXT, - first_seen INTEGER NOT NULL, - last_seen INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS client_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - install_id TEXT NOT NULL, - provider TEXT NOT NULL, - model TEXT NOT NULL, - requests INTEGER NOT NULL, - input_tokens INTEGER NOT NULL, - output_tokens INTEGER NOT NULL, - cache_read_tokens INTEGER NOT NULL, - cache_write_tokens INTEGER NOT NULL, - cost_usd REAL NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at); - CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at); - `); - - if (!this.#authCredentialsTableExists()) { - this.#createAuthCredentialsTable(); - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialRefreshLeasesTable(); - this.#createAuthCredentialBlockCompatibilityObjects(); - this.#createAuthChangeTrackingObjects(); - this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); - return; - } - - const recordedVersion = this.#readAuthSchemaVersion(); - const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion(); - if (schemaVersion > AUTH_SCHEMA_VERSION) { - logger.warn("SqliteAuthCredentialStore schema version mismatch", { - current: schemaVersion, - expected: AUTH_SCHEMA_VERSION, - }); - } else if (schemaVersion < AUTH_SCHEMA_VERSION) { - this.#migrateAuthSchema(schemaVersion); - } - - this.#createAuthCredentialIndexes(); - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialRefreshLeasesTable(); - if (schemaVersion <= AUTH_SCHEMA_VERSION) { - this.#createAuthCredentialBlockCompatibilityObjects(); - } - this.#createAuthChangeTrackingObjects(); - this.#backfillCredentialIdentityKeys(); - // Rewriting an already-current version row is a no-op write transaction - // on every boot; only persist when the recorded version actually changes. - if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) { - this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); - } - } - - #authCredentialsTableExists(): boolean { - const stmt = this.#db.prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'", - ); - try { - const row = stmt.get() as { present?: number } | undefined; - return row?.present === 1; - } finally { - stmt.finalize(); - } - } - - #readAuthSchemaVersion(): number | null { - const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1"); - try { - const row = stmt.get() as { version?: number } | undefined; - return typeof row?.version === "number" ? row.version : null; - } finally { - stmt.finalize(); - } - } - - #writeAuthSchemaVersion(version: number): void { - const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)"); - try { - stmt.run(version); - } finally { - stmt.finalize(); - } - } - - #inferAuthSchemaVersion(): number { - const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); - try { - const cols = stmt.all() as Array<{ name?: string }>; - return this.#inferAuthSchemaVersionFromColumns(cols); - } finally { - stmt.finalize(); - } - } - - #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number { - const hasDisabledCause = cols.some(column => column.name === "disabled_cause"); - const hasIdentityKey = cols.some(column => column.name === "identity_key"); - const hasAccountId = cols.some(column => column.name === "account_id"); - const hasEmail = cols.some(column => column.name === "email"); - if (hasIdentityKey) return 3; - if (hasAccountId || hasEmail) return 2; - if (hasDisabledCause) return 1; - return 0; - } - - #createAuthCredentialsTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) - ); - `); - this.#createAuthCredentialIndexes(); - } - - #createAuthCredentialIndexes(): void { - this.#db.run(` - CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); - CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL; - `); - } - - #createAuthCredentialBlocksTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_blocks ( - credential_id INTEGER NOT NULL, - provider_key TEXT NOT NULL, - block_scope TEXT NOT NULL DEFAULT '', - blocked_until_ms INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (credential_id, provider_key, block_scope) - ); - CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms); - `); - } - - #createAuthChangeTrackingObjects(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_change_revision ( - id INTEGER PRIMARY KEY CHECK (id = 1), - revision INTEGER NOT NULL - ); - INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0); - CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision ( - id INTEGER PRIMARY KEY CHECK (id = 1), - revision INTEGER NOT NULL - ); - INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0); - `); - for (const table of ["auth_credentials", "auth_credential_blocks"] as const) { - for (const event of ["INSERT", "UPDATE", "DELETE"] as const) { - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()} - AFTER ${event} ON ${table} - BEGIN - UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1; - END; - `); - this.#db.run(` - CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()} - AFTER ${event} ON main.${table} - BEGIN - UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1; - END; - `); - } - } - } - - #createAuthCredentialBlockMirrorGuardTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard ( - credential_id INTEGER PRIMARY KEY - ) WITHOUT ROWID; - `); - } - - /** - * Keep a physical Codex `shared` row for pre-meter binaries that read this - * database directly. Meter rows are canonical for current code. The guard - * suppresses feedback while triggers update the compatibility projection. - */ - #createAuthCredentialBlockCompatibilityTriggers(): void { - for (const event of ["INSERT", "UPDATE"] as const) { - const eventName = event.toLowerCase(); - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters - AFTER ${event} ON auth_credential_blocks - WHEN NEW.provider_key = 'openai-codex:oauth' - AND NEW.block_scope = 'shared' - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (NEW.credential_id); - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - VALUES ( - NEW.credential_id, - NEW.provider_key, - 'chat', - NEW.blocked_until_ms, - NEW.updated_at - ) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - VALUES ( - NEW.credential_id, - NEW.provider_key, - 'spark', - NEW.blocked_until_ms, - NEW.updated_at - ) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id; - END; - - CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared - AFTER ${event} ON auth_credential_blocks - WHEN NEW.provider_key = 'openai-codex:oauth' - AND NEW.block_scope IN ('chat', 'spark') - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (NEW.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = NEW.credential_id - AND provider_key = NEW.provider_key - AND block_scope = 'shared'; - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - NEW.credential_id, - NEW.provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE credential_id = NEW.credential_id - AND provider_key = NEW.provider_key - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key; - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id; - END; - `); - } - - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters - AFTER DELETE ON auth_credential_blocks - WHEN OLD.provider_key = 'openai-codex:oauth' - AND OLD.block_scope = 'shared' - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (OLD.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope IN ('chat', 'spark'); - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id; - END; - - CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared - AFTER DELETE ON auth_credential_blocks - WHEN OLD.provider_key = 'openai-codex:oauth' - AND OLD.block_scope IN ('chat', 'spark') - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (OLD.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope = 'shared'; - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - OLD.credential_id, - OLD.provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key; - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id; - END; - `); - } - - #createAuthCredentialBlockCompatibilityObjects(): void { - this.#createAuthCredentialBlockMirrorGuardTable(); - this.#createAuthCredentialBlockCompatibilityTriggers(); - } - - #createAuthCredentialRefreshLeasesTable(): void { - SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db); - } - - #migrateAuthSchema(fromVersion: number): void { - if (fromVersion < 1) { - this.#migrateAuthSchemaV0ToV1(); - } - if (fromVersion < 3) { - this.#migrateAuthSchemaV1OrV2ToV3(); - } - if (fromVersion < 4) { - this.#migrateAuthSchemaV3ToV4(); - } - if (fromVersion < 5) { - this.#migrateAuthSchemaV4ToV5(); - } - if (fromVersion < 6) { - this.#migrateAuthSchemaV5ToV6(); - } - if (fromVersion < 7) { - this.#migrateAuthSchemaV6ToV7(); - } - } - - #migrateAuthSchemaV0ToV1(): void { - const migrate = this.#db.transaction(() => { - const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); - let hasDisabled = false; - try { - const v0Cols = stmt.all() as Array<{ name?: string }>; - hasDisabled = v0Cols.some(col => col.name === "disabled"); - } finally { - stmt.finalize(); - } - - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); - this.#db.run(` - CREATE TABLE auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) - ); - `); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"}, - created_at, - updated_at - FROM auth_credentials_v0 - `); - this.#db.run("DROP TABLE auth_credentials_v0"); - }); - migrate(); - } - - #migrateAuthSchemaV1OrV2ToV3(): void { - const migrate = this.#db.transaction(() => { - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); - this.#createAuthCredentialsTable(); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - disabled_cause, - NULL, - created_at, - updated_at - FROM auth_credentials_legacy - `); - this.#db.run("DROP TABLE auth_credentials_legacy"); - }); - migrate(); - } - - #migrateAuthSchemaV3ToV4(): void { - const migrate = this.#db.transaction(() => { - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); - this.#createAuthCredentialsTable(); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - disabled_cause, - identity_key, - created_at, - updated_at - FROM auth_credentials_v3 - `); - this.#db.run("DROP TABLE auth_credentials_v3"); - }); - migrate(); - } - - #migrateAuthSchemaV4ToV5(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialBlocksTable(); - }); - migrate(); - } - - #migrateAuthSchemaV5ToV6(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialRefreshLeasesTable(); - }); - migrate(); - } - - #migrateAuthSchemaV6ToV7(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialBlockMirrorGuardTable(); - this.#db.run(` - DELETE FROM auth_credential_block_mirror_guard; - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - SELECT DISTINCT credential_id - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope IN ('chat', 'spark', 'shared'); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope = 'shared' - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope = 'shared' - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - credential_id, - provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = excluded.blocked_until_ms, - updated_at = excluded.updated_at; - - DELETE FROM auth_credential_block_mirror_guard; - `); - this.#createAuthCredentialBlockCompatibilityTriggers(); - this.#writeAuthSchemaVersion(7); - }); - migrate.immediate(); - } - - #backfillCredentialIdentityKeys(): void { - const selectRowsStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC", - ); - let rows: AuthRow[]; - try { - rows = selectRowsStmt.all() as AuthRow[]; - } finally { - selectRowsStmt.finalize(); - } - if (rows.length === 0) return; - - let updateIdentity: Statement | null = null; - try { - for (const row of rows) { - const identityKey = resolveRowCredentialIdentityKey(row.provider, row); - // Rows whose identity cannot be derived stay NULL; writing NULL over - // NULL would just burn a write transaction on every boot. - if (identityKey === null) continue; - updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?"); - updateIdentity.run(identityKey, row.id); - } - } finally { - updateIdentity?.finalize(); - } - } - - // ─── AuthCredentialStore interface ────────────────────────────────────── - - listAuthCredentials(provider?: string): StoredAuthCredential[] { - const rows = - (provider - ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[]) - : (this.#listActiveStmt.all() as AuthRow[])) ?? []; - - const results: StoredAuthCredential[] = []; - for (const row of rows) { - const credential = deserializeCredential(row); - if (!credential) continue; - results.push(toStoredAuthCredential(row, credential)); - } - return results; - } - - async listDisabledCredentials(provider?: string): Promise { - const rows = - (provider - ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[]) - : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? []; - const results: DisabledCredentialSummary[] = []; - for (const row of rows) { - const credential = deserializeCredential(row); - const summary: DisabledCredentialSummary = { - id: row.id, - provider: row.provider, - type: row.credential_type === "api_key" ? "api_key" : "oauth", - cause: row.disabled_cause ?? "disabled", - }; - if (credential?.type === "oauth") { - if (credential.email) summary.email = credential.email; - if (credential.accountId) summary.accountId = credential.accountId; - if (credential.orgId) summary.orgId = credential.orgId; - if (credential.orgName) summary.orgName = credential.orgName; - } - if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) { - summary.disabledAtMs = row.updated_at * 1000; - } - results.push(summary); - } - return results; - } - - replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] { - const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => { - const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const existing = existingRows.map(row => ({ - id: row.id, - credential: deserializeCredential(row), - identityKey: resolveRowCredentialIdentityKey(providerName, row), - })); - - const result: StoredAuthCredential[] = []; - const matchedExistingIds = new Set(); - - for (const credential of items) { - const serialized = serializeCredential(providerName, credential); - if (!serialized) continue; - const match = existing.find( - entry => - !matchedExistingIds.has(entry.id) && - matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential), - ); - if (match) { - matchedExistingIds.add(match.id); - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id); - result.push({ id: match.id, provider: providerName, credential, disabledCause: null }); - } else { - const row = this.#insertStmt.get( - providerName, - serialized.credentialType, - serialized.data, - serialized.identityKey, - ) as { id?: number } | undefined; - if (row?.id) { - result.push({ id: row.id, provider: providerName, credential, disabledCause: null }); - } - } - } - - for (const row of existing) { - if (!matchedExistingIds.has(row.id)) { - this.#deleteStmt.run("replaced by newer credential", row.id); - } - } - - return result; - }); - - const result = replace(provider, credentials); - this.#purgeSupersededDisabledRows(provider, result); - return result; - } - - upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] { - const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => { - const serialized = serializeCredential(providerName, item); - if (!serialized) return this.listAuthCredentials(providerName); - const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const existing = existingRows.map(row => ({ - id: row.id, - credential: deserializeCredential(row), - identityKey: resolveRowCredentialIdentityKey(providerName, row), - })); - - if (item.type === "oauth") { - for (const row of existing) { - if (row.credential && row.credential.type === "api_key") { - this.#deleteStmt.run("replaced by oauth login", row.id); - } - } - } - - let targetId: number | null = null; - for (const row of existing) { - if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue; - if (targetId === null) { - targetId = row.id; - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id); - continue; - } - this.#deleteStmt.run("replaced by newer credential", row.id); - } - - if (targetId === null) { - const row = this.#insertStmt.get( - providerName, - serialized.credentialType, - serialized.data, - serialized.identityKey, - ) as { id?: number } | undefined; - targetId = row?.id ?? null; - } - - const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const result: StoredAuthCredential[] = []; - for (const row of activeRows) { - const activeCredential = deserializeCredential(row); - if (!activeCredential) continue; - result.push(toStoredAuthCredential(row, activeCredential)); - } - return result; - }); - - const result = upsert(provider, credential); - this.#purgeSupersededDisabledRows(provider, result); - return result; - } - - /** - * Hard-deletes disabled rows for a provider when an active replacement exists. - * OAuth credentials match by identity key; API keys match by provider and type. - * Disabled rows without an active same-type replacement remain recoverable. - */ - #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void { - try { - let hasActiveApiKey = false; - const activeIdentityKeys = new Set(); - for (const row of activeRows) { - if (row.credential.type === "api_key") { - hasActiveApiKey = true; - continue; - } - const identityKey = resolveCredentialIdentityKey(provider, row.credential); - if (identityKey) activeIdentityKeys.add(identityKey); - } - if (!hasActiveApiKey && activeIdentityKeys.size === 0) return; - - const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[]; - for (const row of disabledRows) { - if (hasActiveApiKey && row.credential_type === "api_key") { - this.#hardDeleteStmt.run(row.id); - continue; - } - const identityKey = resolveRowCredentialIdentityKey(provider, row); - if (identityKey && activeIdentityKeys.has(identityKey)) { - this.#hardDeleteStmt.run(row.id); - } - } - } catch { - // Best-effort cleanup; don't let it break the main operation - } - } - - updateAuthCredential(id: number, credential: AuthCredential): void { - try { - const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); - let providerRow: { provider?: string } | undefined; - try { - providerRow = providerStmt.get(id) as { provider?: string } | undefined; - } finally { - providerStmt.finalize(); - } - const provider = providerRow?.provider ?? ""; - const serialized = serializeCredential(provider, credential); - if (!serialized) return; - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id); - if (provider) { - this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); - } - } catch { - // Ignore update failures - } - } - - tryUpdateAuthCredentialIfMatches( - id: number, - expectedData: string, - credential: AuthCredential, - lease?: CredentialRefreshLeaseFence, - ): boolean { - const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); - let providerRow: { provider?: string } | undefined; - try { - providerRow = providerStmt.get(id) as { provider?: string } | undefined; - } finally { - providerStmt.finalize(); - } - const provider = providerRow?.provider ?? ""; - const serialized = serializeCredential(provider, credential); - if (!serialized) return false; - const result = lease - ? (this.#updateIfMatchesWithLeaseStmt.run( - serialized.credentialType, - serialized.data, - serialized.identityKey, - id, - expectedData, - id, - lease.owner, - lease.nowMs, - ) as { changes: number }) - : (this.#updateIfMatchesStmt.run( - serialized.credentialType, - serialized.data, - serialized.identityKey, - id, - expectedData, - ) as { changes: number }); - if (result.changes === 0) return false; - if (provider) { - this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); - } - return true; - } - - deleteAuthCredential(id: number, disabledCause: string): void { - try { - this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id); - } catch { - // Ignore delete failures - } - } - - /** - * CAS-style disable: only soft-deletes the row when its `data` column still - * matches `expectedData` and the row has not already been disabled. Used by - * the OAuth refresh-failure path to avoid clobbering a peer that rotated the - * row between our pre-check and the disable. - */ - tryDisableAuthCredentialIfMatches( - id: number, - expectedData: string, - disabledCause: string, - lease?: CredentialRefreshLeaseFence, - ): boolean { - const result = lease - ? (this.#deleteIfMatchesWithLeaseStmt.run( - normalizeDisabledCause(disabledCause), - id, - expectedData, - id, - lease.owner, - lease.nowMs, - ) as { changes: number }) - : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as { - changes: number; - }); - return result.changes > 0; - } - deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void { - try { - this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider); - } catch { - // Ignore delete failures - } - } - - getCache(key: string, options?: { includeExpired?: boolean }): string | null { - try { - const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt; - const row = stmt.get(key) as { value?: string } | undefined; - return row?.value ?? null; - } catch { - return null; - } - } - - setCache(key: string, value: string, expiresAtSec: number): void { - try { - this.#upsertCacheStmt.run(key, value, expiresAtSec); - } catch { - // Ignore cache set failures - } - } - - /** Drop all cache rows whose keys start with the supplied prefix. */ - deleteCachePrefix(prefix: string): void { - try { - this.#deleteCachePrefixStmt.run(prefix.length, prefix); - } catch { - // Ignore cache delete failures - } - } - - cleanExpiredCache(): void { - try { - this.#deleteExpiredCacheStmt.run(); - } catch { - // Ignore cleanup errors - } - } - - getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined { - const nowMs = Date.now(); - const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; - // Current callers use meter scopes. The physical shared row exists only - // for direct SQLite readers from pre-meter releases. - if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { - return undefined; - } - if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as - | { blocked_until_ms?: number; updated_at?: number } - | undefined; - return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined; - } - - getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined { - const nowMs = Date.now(); - const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; - if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { - return undefined; - } - if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as - | { blocked_until_ms?: number; updated_at?: number } - | undefined; - if (typeof row?.blocked_until_ms !== "number") return undefined; - const memoryReconcileAfter = - this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0; - const persistedReconcileAfter = - typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0; - const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter); - return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined; - } - - upsertCredentialBlock(block: StoredCredentialBlock): void { - const isLegacyCodexBlock = - block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE; - const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope]; - const upsert = this.#db.transaction(() => { - for (const blockScope of blockScopes) { - this.#upsertCredentialBlockStmt.run( - block.credentialId, - block.providerKey, - blockScope, - block.blockedUntilMs, - ); - } - }); - upsert.immediate(); - - const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS); - for (const blockScope of blockScopes) { - this.#credentialBlockReconcileAfter.set( - `${block.credentialId}\0${block.providerKey}\0${blockScope}`, - reconcileAfterMs, - ); - } - if (isLegacyCodexBlock) { - this.#credentialBlockReconcileAfter.delete( - `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`, - ); - } - } - - deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void { - this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope); - this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`); - } - - deleteCredentialBlocks(credentialId: number): void { - this.#deleteCredentialBlocksStmt.run(credentialId); - for (const key of this.#credentialBlockReconcileAfter.keys()) { - if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key); - } - } - - cleanExpiredCredentialBlocks(nowMs: number): void { - this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) { - if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key); - } - } - - listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] { - if (credentialIds.length === 0) return []; - const nowMs = Date.now(); - this.cleanExpiredCredentialBlocks(nowMs); - const seenCredentialIds = new Set(); - const blocks: StoredCredentialBlock[] = []; - for (const credentialId of credentialIds) { - if (seenCredentialIds.has(credentialId)) continue; - seenCredentialIds.add(credentialId); - const rows = this.#listCredentialBlocksByCredentialStmt.all( - credentialId, - nowMs, - LEGACY_CODEX_BLOCK_PROVIDER_KEY, - LEGACY_CODEX_BLOCK_SCOPE, - ) as CredentialBlockRow[]; - for (const row of rows) { - blocks.push({ - credentialId: row.credential_id, - providerKey: row.provider_key, - blockScope: row.block_scope, - blockedUntilMs: row.blocked_until_ms, - updatedAtMs: row.updated_at * 1000, - }); - } - } - return blocks; - } - - tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { - const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as { - changes: number; - }; - return result.changes === 1; - } - - getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined { - const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined; - if (typeof row?.expires_at_ms !== "number") return undefined; - if (row.expires_at_ms <= Date.now()) return undefined; - return row.expires_at_ms; - } - - renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { - const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as { - changes: number; - }; - return result.changes === 1; - } - - releaseCredentialRefreshLease(credentialId: number, owner: string): void { - try { - this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner); - } catch { - // Ignore lease release failures; expired leases are stealable. - } - } - - recordUsageSnapshots(entries: UsageHistoryEntry[]): void { - try { - for (const entry of entries) { - const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS); - const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as - | { id: number; recorded_at: number } - | undefined; - if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) { - this.#updateUsageHistoryStmt.run( - entry.recordedAt, - entry.email ?? null, - entry.accountId ?? null, - entry.label, - entry.windowLabel ?? null, - entry.usedFraction ?? null, - entry.status ?? null, - entry.resetsAt ?? null, - last.id, - ); - continue; - } - this.#insertUsageHistoryStmt.run( - entry.recordedAt, - entry.provider, - entry.accountKey, - entry.email ?? null, - entry.accountId ?? null, - entry.limitId, - entry.label, - entry.windowLabel ?? null, - entry.usedFraction ?? null, - entry.status ?? null, - entry.resetsAt ?? null, - ); - } - } catch { - // History is best-effort; never break the usage fetch path. - } - } - - listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] { - try { - const provider = query?.provider ?? null; - const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{ - recorded_at: number; - provider: string; - account_key: string; - email: string | null; - account_id: string | null; - limit_id: string; - label: string; - window_label: string | null; - used_fraction: number | null; - status: string | null; - resets_at: number | null; - }>; - return rows.map(row => ({ - recordedAt: row.recorded_at, - provider: row.provider as Provider, - accountKey: row.account_key, - email: row.email ?? undefined, - accountId: row.account_id ?? undefined, - limitId: row.limit_id, - label: row.label, - windowLabel: row.window_label ?? undefined, - usedFraction: row.used_fraction ?? undefined, - status: (row.status ?? undefined) as UsageHistoryEntry["status"], - resetsAt: row.resets_at ?? undefined, - })); - } catch { - return []; - } - } - recordUsageCosts(entries: UsageCostHistoryEntry[]): void { - try { - for (const entry of entries) { - this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd); - } - } catch { - // Cost history is best-effort; never break request persistence. - } - } - - listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] { - try { - const provider = query?.provider ?? null; - const accountKey = query?.accountKey ?? null; - const rows = this.#listUsageCostsStmt.all( - query?.sinceMs ?? 0, - provider, - provider, - accountKey, - accountKey, - ) as Array<{ - recorded_at: number; - provider: string; - account_key: string; - cost_usd: number; - }>; - return rows.map(row => ({ - recordedAt: row.recorded_at, - provider: row.provider as Provider, - accountKey: row.account_key, - costUsd: row.cost_usd, - })); - } catch { - return []; - } - } - - recordClientUsage(report: ClientUsageReport): void { - const now = Date.now(); - this.#db - .query( - `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?) - ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`, - ) - .run(report.installId, report.hostname ?? null, now, now); - const findBucket = this.#db.query( - `SELECT id FROM client_usage - WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ? - ORDER BY recorded_at DESC LIMIT 1`, - ); - const merge = this.#db.query( - `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?, - output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, - cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`, - ); - const insert = this.#db.query( - `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (const entry of report.entries) { - // Merge into the newest row of the same (install, provider, model) - // bucket so 10s client flushes don't accrete one row apiece forever. - const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS; - const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as { - id: number; - } | null; - if (existing) { - merge.run( - entry.at, - entry.requests, - entry.inputTokens, - entry.outputTokens, - entry.cacheReadTokens, - entry.cacheWriteTokens, - entry.costUsd, - existing.id, - ); - continue; - } - insert.run( - entry.at, - report.installId, - entry.provider, - entry.model, - entry.requests, - entry.inputTokens, - entry.outputTokens, - entry.cacheReadTokens, - entry.cacheWriteTokens, - entry.costUsd, - ); - } - } - - getClientUsageSummary(sinceMs: number): ClientUsageSummary { - const clients = this.#db - .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC") - .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>; - const aggregates = this.#db - .query( - `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens, - SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens, - SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd - FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider - ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`, - ) - .all(sinceMs) as Array<{ - install_id: string; - provider: string; - requests: number; - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - cost_usd: number; - }>; - const providersByInstall = new Map(); - for (const row of aggregates) { - let list = providersByInstall.get(row.install_id); - if (!list) { - list = []; - providersByInstall.set(row.install_id, list); - } - list.push({ - provider: row.provider, - requests: row.requests, - inputTokens: row.input_tokens, - outputTokens: row.output_tokens, - cacheReadTokens: row.cache_read_tokens, - cacheWriteTokens: row.cache_write_tokens, - costUsd: row.cost_usd, - }); - } - return { - clients: clients.map(client => ({ - installId: client.install_id, - hostname: client.hostname ?? undefined, - firstSeen: client.first_seen, - lastSeen: client.last_seen, - providers: providersByInstall.get(client.install_id) ?? [], - })), - }; - } - - // ─── Convenience methods for CLI ──────────────────────────────────────── - - /** - * Save OAuth credentials for a provider. - * Preserves unrelated identities and replaces only the matching credential. - */ - saveOAuth(provider: string, credentials: OAuthCredentials): void { - const credential: AuthCredential = { type: "oauth", ...credentials }; - this.upsertAuthCredentialForProvider(provider, credential); - } - - /** - * Get OAuth credentials for a provider. - */ - getOAuth(provider: string): OAuthCredentials | null { - const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; - for (const row of rows) { - const credential = deserializeCredential(row); - if (credential && credential.type === "oauth") { - const { type: _type, ...oauth } = credential; - return oauth as OAuthCredentials; - } - } - return null; - } - - /** - * Save API key for a provider (replaces existing). - */ - saveApiKey(provider: string, apiKey: string): void { - const credential: AuthCredential = { type: "api_key", key: apiKey }; - this.replaceAuthCredentialsForProvider(provider, [credential]); - } - - /** - * Get API key for a provider. - */ - getApiKey(provider: string): string | null { - const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; - for (const row of rows) { - const credential = deserializeCredential(row); - if (credential && credential.type === "api_key") { - return credential.key; - } - } - return null; - } - - /** - * List all providers with credentials. - */ - listProviders(): string[] { - const rows = this.#listActiveStmt.all() as AuthRow[]; - const providers = new Set(); - for (const row of rows) { - providers.add(row.provider); - } - return Array.from(providers); - } - - /** - * Delete all credentials for a provider. - */ - deleteProvider(provider: string): void { - this.deleteAuthCredentialsForProvider(provider, "deleted by user"); - } - - /** - * SQLite increments `data_version` when another connection commits. Own - * writes leave it unchanged and already notify AuthStorage directly. - */ - pollExternalChanges(): boolean { - this.#acknowledgeLocalAuthChanges(); - const dataVersion = this.#readDataVersion(); - if (dataVersion === this.#dataVersion) return false; - this.#dataVersion = dataVersion; - const authRevision = this.#readAuthRevision(); - if (authRevision === this.#authRevision) return false; - this.#authRevision = authRevision; - return true; - } - - acknowledgeLocalChanges(): void { - this.#acknowledgeLocalAuthChanges(); - } - - #acknowledgeLocalAuthChanges(): void { - const localAuthRevision = this.#readLocalAuthRevision(); - this.#authRevision += localAuthRevision - this.#localAuthRevision; - this.#localAuthRevision = localAuthRevision; - } - - #readDataVersion(): number { - const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null; - return row?.data_version ?? 0; - } - - #readAuthRevision(): number { - const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as { - revision?: number; - } | null; - return row?.revision ?? 0; - } - - #readLocalAuthRevision(): number { - const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as { - revision?: number; - } | null; - return row?.revision ?? 0; - } - - close(): void { - if (this.#closed) return; - this.#closed = true; - this.#listActiveStmt.finalize(); - this.#listActiveByProviderStmt.finalize(); - this.#listDisabledStmt.finalize(); - this.#listDisabledByProviderStmt.finalize(); - this.#insertStmt.finalize(); - this.#updateStmt.finalize(); - this.#deleteStmt.finalize(); - this.#deleteIfMatchesStmt.finalize(); - this.#deleteByProviderStmt.finalize(); - this.#hardDeleteStmt.finalize(); - this.#getCacheStmt.finalize(); - this.#getCacheIncludingExpiredStmt.finalize(); - this.#upsertCacheStmt.finalize(); - this.#deleteExpiredCacheStmt.finalize(); - this.#getCredentialBlockStmt.finalize(); - this.#listCredentialBlocksByCredentialStmt.finalize(); - this.#upsertCredentialBlockStmt.finalize(); - this.#deleteCredentialBlocksStmt.finalize(); - this.#deleteCredentialBlockStmt.finalize(); - this.#deleteExpiredCredentialBlocksStmt.finalize(); - this.#insertUsageHistoryStmt.finalize(); - this.#lastUsageHistoryStmt.finalize(); - this.#listUsageHistoryStmt.finalize(); - this.#updateUsageHistoryStmt.finalize(); - this.#insertUsageCostStmt.finalize(); - this.#listUsageCostsStmt.finalize(); - this.#updateIfMatchesStmt.finalize(); - this.#updateIfMatchesWithLeaseStmt.finalize(); - this.#deleteIfMatchesWithLeaseStmt.finalize(); - this.#deleteCachePrefixStmt.finalize(); - this.#acquireCredentialRefreshLeaseStmt.finalize(); - this.#getCredentialRefreshLeaseStmt.finalize(); - this.#renewCredentialRefreshLeaseStmt.finalize(); - this.#releaseCredentialRefreshLeaseStmt.finalize(); - this.#db.close(); - } -} diff --git a/packages/ai/src/auth/sqlite-credential-store.ts b/packages/ai/src/auth/sqlite-credential-store.ts new file mode 100644 index 000000000..0ae14f851 --- /dev/null +++ b/packages/ai/src/auth/sqlite-credential-store.ts @@ -0,0 +1,2049 @@ +/** + * SQLite-backed credential persistence for AuthStorage. + * + * The public AuthCredentialStore interface remains in ../auth-storage so local + * and remote stores share the same contract. + */ +import { Database, type Statement } from "bun:sqlite"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; +import { getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; +import type { + AuthCredential, + AuthCredentialStore, + CredentialRefreshLeaseFence, + DisabledCredentialSummary, + OAuthCredential, + StoredAuthCredential, + StoredCredentialBlock, +} from "../auth-storage"; +import * as AIError from "../error"; +import type { OAuthCredentials } from "../registry/oauth/types"; +import type { Provider } from "../types"; +import type { + ClientProviderUsage, + ClientUsageReport, + ClientUsageSummary, + UsageCostHistoryEntry, + UsageCostHistoryQuery, + UsageHistoryEntry, + UsageHistoryQuery, +} from "../usage"; + +// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP +// level so we can't fetch all N credentials every cycle; with a long cache +// each credential's last-known value sticks visible while peers retry. UI +// data (5h / 7d / monthly limits) is fine being a few minutes stale. +export const USAGE_REPORT_TTL_MS = 5 * 60_000; + +/** + * Downsample usage history to at most one row per hour per account window: a + * snapshot landing in the same hour bucket as the series' latest row + * overwrites it in place. That bound makes further retention pruning + * unnecessary — 1 row/hour is ~9k rows per account window per year. + */ +const USAGE_HISTORY_BUCKET_MS = 60 * 60_000; + +/** + * Merge client observed-usage flushes into at most one row per 5 minutes per + * (install, provider, model): ~300 rows/day per active model per client + * instead of one row per 10s flush. + */ +const CLIENT_USAGE_BUCKET_MS = 5 * 60_000; + +// ───────────────────────────────────────────────────────────────────────────── +// SqliteAuthCredentialStore +// ───────────────────────────────────────────────────────────────────────────── + +/** Row shape for auth_credentials table queries */ +type AuthRow = { + id: number; + provider: string; + credential_type: string; + data: string; + disabled_cause: string | null; + identity_key: string | null; +}; + +/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */ +type DisabledAuthRow = AuthRow & { updated_at: number | null }; + +type CredentialBlockRow = { + credential_id: number; + provider_key: string; + block_scope: string; + blocked_until_ms: number; + updated_at: number; +}; + +type SerializedCredentialRecord = { + credentialType: AuthCredential["type"]; + data: string; + identityKey: string | null; +}; + +const AUTH_SCHEMA_VERSION = 7; +const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; +const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth"; +const LEGACY_CODEX_BLOCK_SCOPE = "shared"; +const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const; + +/** + * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended + * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`, + * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment. + */ +export function isSqliteBusyError(err: unknown): boolean { + if (err === null || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + return typeof code === "string" && code.startsWith("SQLITE_BUSY"); +} + +/** + * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family + * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`) + * and `SQLITE_NOTADB` (the file header is not a database). Unlike + * {@link isSqliteBusyError}, these never clear by retrying: the store must be + * repaired or replaced, so callers latch and stop touching it. + */ +export function isSqliteCorruptionError(err: unknown): boolean { + if (err === null || typeof err !== "object" || !("code" in err)) return false; + const code = err.code; + return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB"); +} + +function normalizeStoredAccountId(accountId: string | null | undefined): string | null { + const normalized = accountId?.trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeStoredEmail(email: string | null | undefined): string | null { + const normalized = email?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null { + const normalized = identityKey?.trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +export function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null { + if (credential.type === "api_key") { + const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key }; + return { + credentialType: "api_key", + data: JSON.stringify(data), + identityKey: null, + }; + } + if (credential.type === "oauth") { + const { type: _type, ...rest } = credential; + return { + credentialType: "oauth", + data: JSON.stringify(rest), + identityKey: resolveCredentialIdentityKey(provider, credential), + }; + } + return null; +} + +function deserializeCredential(row: AuthRow): AuthCredential | null { + let parsed: unknown; + try { + parsed = JSON.parse(row.data); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + if (row.credential_type === "api_key") { + const data = parsed as Record; + if (typeof data.key === "string") { + const source = data.source === "login" ? "login" : undefined; + return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key }; + } + } + if (row.credential_type === "oauth") { + return { type: "oauth", ...(parsed as Record) } as AuthCredential; + } + return null; +} + +function normalizeDisabledCause(disabledCause: string): string { + const normalized = disabledCause.trim(); + return normalized.length > 0 ? normalized : "disabled"; +} + +function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential { + return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause }; +} + +function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null { + const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:")); + if (provider === "anthropic" || provider === "openai-codex") { + // One account email can hold several organizations/workspaces (e.g. a + // Team seat plus a personal plan), each with its own org-scoped token + // and limit pools. Scope identity by org so both subscriptions can be + // stored side by side. The qualifier rides on whichever base identity + // is available, so an unqualified account/project fallback would + // still collapse two subscriptions whenever the email could not be + // recovered. Org-less credentials (rows written before org capture + // existed) keep their bare key. + const base = + emailIdentifier ?? + identifiers.find(identifier => identifier.startsWith("account:")) ?? + identifiers.find(identifier => identifier.startsWith("project:")); + const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); + if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base; + // No base identity at all: the org alone still distinguishes the row. + return orgIdentifier ?? null; + } + const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); + if (accountIdentifier) return accountIdentifier; + if (emailIdentifier) return emailIdentifier; + const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:")); + if (projectIdentifier) return projectIdentifier; + return null; +} + +export function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null { + if (credential.type === "api_key") return null; + return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)); +} + +function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null { + const identityKey = normalizeStoredIdentityKey(row.identity_key); + if (identityKey) return identityKey; + const credential = deserializeCredential(row); + return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null; +} + +function matchesReplacementCredential( + provider: string, + existing: AuthCredential | null, + existingIdentityKey: string | null, + incoming: AuthCredential, +): boolean { + if (!existing || existing.type !== incoming.type) return false; + if (incoming.type === "api_key") { + if (existing.type !== "api_key") return false; + if (existing.key === incoming.key) return true; + if (provider !== "alibaba-token-plan") return false; + const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token; + const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token; + return existingToken !== undefined && existingToken === incomingToken; + } + const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming); + const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers); + if (incomingIdentityKey === null) return false; + if (incomingIdentityKey === existingIdentityKey) return true; + if (existingIdentityKey === null) return false; + // One-way upgrade, applied only when the INCOMING identity key carries the + // org qualifier (only anthropic and openai-codex keys do, so other + // providers never reach the checks below). An org-scoped login `org:` + // claims (and re-keys) any existing row that denotes the same subscription: + // - `org:` — org-only row stored when identity recovery failed, claimed + // once a later same-org login recovers a base identity; + // - `` for any base identity `` (email/account/project) the incoming + // credential carries — a pre-org legacy row, mirroring the pre-org + // replace behavior; + // - `|org:` for any such base — the same subscription keyed by a + // different base, e.g. an account-keyed row stored while the email could + // not be recovered, claimed once a later login recovers the email; + // - any same-org row whose STORED credential shares a base identity with + // the incoming one — a stored credential can retain identifiers its key + // does not use (an email-keyed row also carries the account UUID), so a + // later login that loses the email but keeps the account still updates + // its row instead of duplicating the subscription. + // The reverse stays a non-match: an org-less credential only ever replaces + // via exact key equality above and must never clobber an org-scoped row. + const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:")); + if (orgIdentifier === undefined) return false; + if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false; + if (existingIdentityKey === orgIdentifier) return true; + const existingIdentifiers = + existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`) + ? extractOAuthCredentialIdentifiers(existing) + : null; + // A base identifier that merely repeats the org qualifier's id carries no + // per-user identity (openai-codex stores the ChatGPT workspace id as both + // accountId and orgId, shared by every member) — letting it act as a + // claimable base would re-key another member's same-org row. + const orgQualifierId = orgIdentifier.slice("org:".length); + for (const identifier of incomingIdentifiers) { + const isBase = + identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:"); + if (!isBase) continue; + if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue; + if (existingIdentityKey === identifier) return true; + if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true; + if (existingIdentifiers?.includes(identifier)) return true; + } + return false; +} + +function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] { + const identifiers = new Set(); + const accountId = normalizeStoredAccountId(credential.accountId); + if (accountId) identifiers.add(`account:${accountId}`); + const email = normalizeStoredEmail(credential.email); + if (email) identifiers.add(`email:${email}`); + const projectId = normalizeStoredAccountId(credential.projectId); + if (projectId) identifiers.add(`project:${projectId}`); + const orgId = normalizeStoredAccountId(credential.orgId); + if (orgId) identifiers.add(`org:${orgId}`); + const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? []; + for (const identifier of accessIdentifiers) { + identifiers.add(identifier); + } + const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? []; + for (const identifier of refreshIdentifiers) { + identifiers.add(identifier); + } + return [...identifiers]; +} + +function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined { + if (!token) return undefined; + const parts = token.split("."); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse( + new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })), + ) as Record; + const identifiers = new Set(); + const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined); + if (directEmail) identifiers.add(`email:${directEmail}`); + const openAiProfile = payload["https://api.openai.com/profile"]; + if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) { + const claimEmail = normalizeStoredEmail( + (openAiProfile as Record).email as string | undefined, + ); + if (claimEmail) identifiers.add(`email:${claimEmail}`); + } + const openAiAuth = payload["https://api.openai.com/auth"]; + const authClaims = + typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth) + ? (openAiAuth as Record) + : undefined; + const accountId = normalizeStoredAccountId( + typeof payload.account_id === "string" + ? payload.account_id + : typeof payload.accountId === "string" + ? payload.accountId + : typeof payload.user_id === "string" + ? payload.user_id + : typeof payload.sub === "string" + ? payload.sub + : typeof authClaims?.chatgpt_account_id === "string" + ? authClaims.chatgpt_account_id + : undefined, + ); + if (accountId) identifiers.add(`account:${accountId}`); + return identifiers.size > 0 ? [...identifiers] : undefined; + } catch { + return undefined; + } +} +/** + * Default SQLite-backed implementation of {@link AuthCredentialStore}. + * + * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. + * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, + * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly + * without going through `AuthStorage`. + */ +export class SqliteAuthCredentialStore implements AuthCredentialStore { + #db: Database; + #listActiveStmt: Statement; + #listActiveByProviderStmt: Statement; + #listDisabledStmt: Statement; + #listDisabledByProviderStmt: Statement; + #insertStmt: Statement; + #updateStmt: Statement; + #deleteStmt: Statement; + #deleteIfMatchesStmt: Statement; + #updateIfMatchesStmt: Statement; + #deleteByProviderStmt: Statement; + #hardDeleteStmt: Statement; + #getCacheStmt: Statement; + #getCacheIncludingExpiredStmt: Statement; + #upsertCacheStmt: Statement; + #deleteCachePrefixStmt: Statement; + #deleteExpiredCacheStmt: Statement; + #updateIfMatchesWithLeaseStmt: Statement; + #deleteIfMatchesWithLeaseStmt: Statement; + #getCredentialBlockStmt: Statement; + #listCredentialBlocksByCredentialStmt: Statement; + #upsertCredentialBlockStmt: Statement; + #deleteCredentialBlocksStmt: Statement; + #deleteCredentialBlockStmt: Statement; + #deleteExpiredCredentialBlocksStmt: Statement; + #acquireCredentialRefreshLeaseStmt: Statement; + #getCredentialRefreshLeaseStmt: Statement; + #renewCredentialRefreshLeaseStmt: Statement; + #releaseCredentialRefreshLeaseStmt: Statement; + #credentialBlockReconcileAfter: Map = new Map(); + #insertUsageHistoryStmt: Statement; + #insertUsageCostStmt: Statement; + #listUsageCostsStmt: Statement; + #lastUsageHistoryStmt: Statement; + #listUsageHistoryStmt: Statement; + #updateUsageHistoryStmt: Statement; + #dataVersion: number; + #authRevision: number; + #localAuthRevision: number; + #closed = false; + + constructor(db: Database) { + this.#db = db; + this.#initializeSchema(); + this.#dataVersion = this.#readDataVersion(); + this.#authRevision = this.#readAuthRevision(); + this.#localAuthRevision = this.#readLocalAuthRevision(); + + this.#listActiveStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC", + ); + this.#listActiveByProviderStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC", + ); + this.#listDisabledStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC", + ); + this.#listDisabledByProviderStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC", + ); + this.#insertStmt = this.#db.prepare( + `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`, + ); + this.#updateStmt = this.#db.prepare( + `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, + ); + this.#updateIfMatchesStmt = this.#db.prepare( + `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, + ); + this.#updateIfMatchesWithLeaseStmt = this.#db.prepare( + `UPDATE auth_credentials + SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} + WHERE id = ? AND data = ? AND disabled_cause IS NULL + AND EXISTS ( + SELECT 1 FROM auth_credential_refresh_leases + WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? + )`, + ); + this.#deleteStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, + ); + this.#deleteIfMatchesStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, + ); + this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare( + `UPDATE auth_credentials + SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} + WHERE id = ? AND data = ? AND disabled_cause IS NULL + AND EXISTS ( + SELECT 1 FROM auth_credential_refresh_leases + WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? + )`, + ); + this.#deleteByProviderStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`, + ); + this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?"); + this.#getCacheStmt = this.#db.prepare( + `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`, + ); + this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?"); + this.#upsertCacheStmt = this.#db.prepare( + "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", + ); + this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?"); + this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`); + this.#getCredentialBlockStmt = this.#db.prepare( + "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?", + ); + this.#listCredentialBlocksByCredentialStmt = this.#db.prepare( + `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE credential_id = ? AND blocked_until_ms > ? + AND NOT (provider_key = ? AND block_scope = ?) + ORDER BY provider_key ASC, block_scope ASC`, + ); + this.#upsertCredentialBlockStmt = this.#db.prepare( + `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at) + VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms), + updated_at = excluded.updated_at`, + ); + this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?"); + this.#deleteCredentialBlockStmt = this.#db.prepare( + "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?", + ); + this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare( + "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?", + ); + this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare( + `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at) + VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH}) + ON CONFLICT(credential_id) DO UPDATE SET + owner = excluded.owner, + expires_at_ms = excluded.expires_at_ms, + updated_at = excluded.updated_at + WHERE auth_credential_refresh_leases.expires_at_ms <= ?`, + ); + this.#getCredentialRefreshLeaseStmt = this.#db.prepare( + "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?", + ); + this.#renewCredentialRefreshLeaseStmt = this.#db.prepare( + `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`, + ); + this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare( + "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?", + ); + this.#insertUsageHistoryStmt = this.#db.prepare( + "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + this.#lastUsageHistoryStmt = this.#db.prepare( + "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1", + ); + this.#updateUsageHistoryStmt = this.#db.prepare( + "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?", + ); + this.#listUsageHistoryStmt = this.#db.prepare( + "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC", + ); + this.#insertUsageCostStmt = this.#db.prepare( + "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)", + ); + this.#listUsageCostsStmt = this.#db.prepare( + "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC", + ); + } + + static async open(dbPath: string = getAgentDbPath()): Promise { + const dir = path.dirname(dbPath); + const dirExists = await fs + .stat(dir) + .then(s => s.isDirectory()) + .catch(() => false); + if (!dirExists) { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + } + + // Concurrent omp startups can race against WAL recovery and the schema + // init's first lock-taking statement. Bun's default `busy_timeout` is 0, + // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded + // exponential backoff before surfacing the failure. See issue #2421. + const maxAttempts = 4; + const baseDelayMs = 100; + let lastBusyError: Error | undefined; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let db: Database | undefined; + try { + db = new Database(dbPath); + // Install the busy handler BEFORE the first lock-taking statement + // on this connection. The leases DDL below and the constructor's + // schema init both acquire locks during WAL recovery; without a + // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY. + // See issue #2421. + SqliteAuthCredentialStore.#installBusyTimeout(db); + try { + await fs.chmod(dbPath, 0o600); + } catch { + // Ignore chmod failures (e.g., Windows) + } + SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db); + return new SqliteAuthCredentialStore(db); + } catch (err) { + db?.close(); + if (!isSqliteBusyError(err)) { + throw err; + } + lastBusyError = err instanceof Error ? err : new Error(String(err)); + if (attempt < maxAttempts - 1) { + await Bun.sleep(baseDelayMs * 2 ** attempt); + } + } + } + throw new AIError.ConfigurationError( + `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`, + { cause: lastBusyError }, + ); + } + + static #ensureAuthCredentialRefreshLeasesTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases ( + credential_id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms); + `); + } + + /** + * Install the per-connection busy handler so lock-taking statements wait for + * a contended writer instead of failing immediately (Bun defaults + * `busy_timeout` to 0). MUST run before the first lock-taking statement on + * the connection: concurrent omp startups race WAL recovery and the leases + * DDL. Uses the centralized timeout so headless hosts keep their bounded + * busy wait instead of the interactive 5s value. See issues #2421, #7298. + */ + static #installBusyTimeout(db: Database): void { + db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`); + } + + #initializeSchema(): void { + // Install the busy handler BEFORE any lock-taking statement (incl. + // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL + // recovery). Without this, concurrent omp startups can crash here with + // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via + // `open()` (which already installed it) is idempotent. See issue #2421. + SqliteAuthCredentialStore.#installBusyTimeout(this.#db); + this.#db.run(` + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + CREATE TABLE IF NOT EXISTS auth_schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at); + CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + provider TEXT NOT NULL, + account_key TEXT NOT NULL, + email TEXT, + account_id TEXT, + limit_id TEXT NOT NULL, + label TEXT NOT NULL, + window_label TEXT, + used_fraction REAL, + status TEXT, + resets_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at); + CREATE TABLE IF NOT EXISTS usage_cost_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + provider TEXT NOT NULL, + account_key TEXT NOT NULL, + cost_usd REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at); + CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at); + CREATE TABLE IF NOT EXISTS clients ( + install_id TEXT PRIMARY KEY, + hostname TEXT, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS client_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + install_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + requests INTEGER NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + cost_usd REAL NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at); + CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at); + `); + + if (!this.#authCredentialsTableExists()) { + this.#createAuthCredentialsTable(); + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialRefreshLeasesTable(); + this.#createAuthCredentialBlockCompatibilityObjects(); + this.#createAuthChangeTrackingObjects(); + this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); + return; + } + + const recordedVersion = this.#readAuthSchemaVersion(); + const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion(); + if (schemaVersion > AUTH_SCHEMA_VERSION) { + logger.warn("SqliteAuthCredentialStore schema version mismatch", { + current: schemaVersion, + expected: AUTH_SCHEMA_VERSION, + }); + } else if (schemaVersion < AUTH_SCHEMA_VERSION) { + this.#migrateAuthSchema(schemaVersion); + } + + this.#createAuthCredentialIndexes(); + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialRefreshLeasesTable(); + if (schemaVersion <= AUTH_SCHEMA_VERSION) { + this.#createAuthCredentialBlockCompatibilityObjects(); + } + this.#createAuthChangeTrackingObjects(); + this.#backfillCredentialIdentityKeys(); + // Rewriting an already-current version row is a no-op write transaction + // on every boot; only persist when the recorded version actually changes. + if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) { + this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); + } + } + + #authCredentialsTableExists(): boolean { + const stmt = this.#db.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'", + ); + try { + const row = stmt.get() as { present?: number } | undefined; + return row?.present === 1; + } finally { + stmt.finalize(); + } + } + + #readAuthSchemaVersion(): number | null { + const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1"); + try { + const row = stmt.get() as { version?: number } | undefined; + return typeof row?.version === "number" ? row.version : null; + } finally { + stmt.finalize(); + } + } + + #writeAuthSchemaVersion(version: number): void { + const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)"); + try { + stmt.run(version); + } finally { + stmt.finalize(); + } + } + + #inferAuthSchemaVersion(): number { + const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); + try { + const cols = stmt.all() as Array<{ name?: string }>; + return this.#inferAuthSchemaVersionFromColumns(cols); + } finally { + stmt.finalize(); + } + } + + #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number { + const hasDisabledCause = cols.some(column => column.name === "disabled_cause"); + const hasIdentityKey = cols.some(column => column.name === "identity_key"); + const hasAccountId = cols.some(column => column.name === "account_id"); + const hasEmail = cols.some(column => column.name === "email"); + if (hasIdentityKey) return 3; + if (hasAccountId || hasEmail) return 2; + if (hasDisabledCause) return 1; + return 0; + } + + #createAuthCredentialsTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + identity_key TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), + updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) + ); + `); + this.#createAuthCredentialIndexes(); + } + + #createAuthCredentialIndexes(): void { + this.#db.run(` + CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); + CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL; + `); + } + + #createAuthCredentialBlocksTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_blocks ( + credential_id INTEGER NOT NULL, + provider_key TEXT NOT NULL, + block_scope TEXT NOT NULL DEFAULT '', + blocked_until_ms INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (credential_id, provider_key, block_scope) + ); + CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms); + `); + } + + #createAuthChangeTrackingObjects(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_change_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + ); + INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0); + CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + ); + INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0); + `); + for (const table of ["auth_credentials", "auth_credential_blocks"] as const) { + for (const event of ["INSERT", "UPDATE", "DELETE"] as const) { + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()} + AFTER ${event} ON ${table} + BEGIN + UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1; + END; + `); + this.#db.run(` + CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()} + AFTER ${event} ON main.${table} + BEGIN + UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1; + END; + `); + } + } + } + + #createAuthCredentialBlockMirrorGuardTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard ( + credential_id INTEGER PRIMARY KEY + ) WITHOUT ROWID; + `); + } + + /** + * Keep a physical Codex `shared` row for pre-meter binaries that read this + * database directly. Meter rows are canonical for current code. The guard + * suppresses feedback while triggers update the compatibility projection. + */ + #createAuthCredentialBlockCompatibilityTriggers(): void { + for (const event of ["INSERT", "UPDATE"] as const) { + const eventName = event.toLowerCase(); + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters + AFTER ${event} ON auth_credential_blocks + WHEN NEW.provider_key = 'openai-codex:oauth' + AND NEW.block_scope = 'shared' + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (NEW.credential_id); + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + VALUES ( + NEW.credential_id, + NEW.provider_key, + 'chat', + NEW.blocked_until_ms, + NEW.updated_at + ) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + VALUES ( + NEW.credential_id, + NEW.provider_key, + 'spark', + NEW.blocked_until_ms, + NEW.updated_at + ) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id; + END; + + CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared + AFTER ${event} ON auth_credential_blocks + WHEN NEW.provider_key = 'openai-codex:oauth' + AND NEW.block_scope IN ('chat', 'spark') + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (NEW.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = NEW.credential_id + AND provider_key = NEW.provider_key + AND block_scope = 'shared'; + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + NEW.credential_id, + NEW.provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE credential_id = NEW.credential_id + AND provider_key = NEW.provider_key + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key; + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id; + END; + `); + } + + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters + AFTER DELETE ON auth_credential_blocks + WHEN OLD.provider_key = 'openai-codex:oauth' + AND OLD.block_scope = 'shared' + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (OLD.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope IN ('chat', 'spark'); + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id; + END; + + CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared + AFTER DELETE ON auth_credential_blocks + WHEN OLD.provider_key = 'openai-codex:oauth' + AND OLD.block_scope IN ('chat', 'spark') + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (OLD.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope = 'shared'; + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + OLD.credential_id, + OLD.provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key; + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id; + END; + `); + } + + #createAuthCredentialBlockCompatibilityObjects(): void { + this.#createAuthCredentialBlockMirrorGuardTable(); + this.#createAuthCredentialBlockCompatibilityTriggers(); + } + + #createAuthCredentialRefreshLeasesTable(): void { + SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db); + } + + #migrateAuthSchema(fromVersion: number): void { + if (fromVersion < 1) { + this.#migrateAuthSchemaV0ToV1(); + } + if (fromVersion < 3) { + this.#migrateAuthSchemaV1OrV2ToV3(); + } + if (fromVersion < 4) { + this.#migrateAuthSchemaV3ToV4(); + } + if (fromVersion < 5) { + this.#migrateAuthSchemaV4ToV5(); + } + if (fromVersion < 6) { + this.#migrateAuthSchemaV5ToV6(); + } + if (fromVersion < 7) { + this.#migrateAuthSchemaV6ToV7(); + } + } + + #migrateAuthSchemaV0ToV1(): void { + const migrate = this.#db.transaction(() => { + const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); + let hasDisabled = false; + try { + const v0Cols = stmt.all() as Array<{ name?: string }>; + hasDisabled = v0Cols.some(col => col.name === "disabled"); + } finally { + stmt.finalize(); + } + + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); + this.#db.run(` + CREATE TABLE auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), + updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) + ); + `); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"}, + created_at, + updated_at + FROM auth_credentials_v0 + `); + this.#db.run("DROP TABLE auth_credentials_v0"); + }); + migrate(); + } + + #migrateAuthSchemaV1OrV2ToV3(): void { + const migrate = this.#db.transaction(() => { + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); + this.#createAuthCredentialsTable(); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + disabled_cause, + NULL, + created_at, + updated_at + FROM auth_credentials_legacy + `); + this.#db.run("DROP TABLE auth_credentials_legacy"); + }); + migrate(); + } + + #migrateAuthSchemaV3ToV4(): void { + const migrate = this.#db.transaction(() => { + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); + this.#createAuthCredentialsTable(); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + disabled_cause, + identity_key, + created_at, + updated_at + FROM auth_credentials_v3 + `); + this.#db.run("DROP TABLE auth_credentials_v3"); + }); + migrate(); + } + + #migrateAuthSchemaV4ToV5(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialBlocksTable(); + }); + migrate(); + } + + #migrateAuthSchemaV5ToV6(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialRefreshLeasesTable(); + }); + migrate(); + } + + #migrateAuthSchemaV6ToV7(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialBlockMirrorGuardTable(); + this.#db.run(` + DELETE FROM auth_credential_block_mirror_guard; + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + SELECT DISTINCT credential_id + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope IN ('chat', 'spark', 'shared'); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope = 'shared' + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope = 'shared' + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + credential_id, + provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = excluded.blocked_until_ms, + updated_at = excluded.updated_at; + + DELETE FROM auth_credential_block_mirror_guard; + `); + this.#createAuthCredentialBlockCompatibilityTriggers(); + this.#writeAuthSchemaVersion(7); + }); + migrate.immediate(); + } + + #backfillCredentialIdentityKeys(): void { + const selectRowsStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC", + ); + let rows: AuthRow[]; + try { + rows = selectRowsStmt.all() as AuthRow[]; + } finally { + selectRowsStmt.finalize(); + } + if (rows.length === 0) return; + + let updateIdentity: Statement | null = null; + try { + for (const row of rows) { + const identityKey = resolveRowCredentialIdentityKey(row.provider, row); + // Rows whose identity cannot be derived stay NULL; writing NULL over + // NULL would just burn a write transaction on every boot. + if (identityKey === null) continue; + updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?"); + updateIdentity.run(identityKey, row.id); + } + } finally { + updateIdentity?.finalize(); + } + } + + // ─── AuthCredentialStore interface ────────────────────────────────────── + + listAuthCredentials(provider?: string): StoredAuthCredential[] { + const rows = + (provider + ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[]) + : (this.#listActiveStmt.all() as AuthRow[])) ?? []; + + const results: StoredAuthCredential[] = []; + for (const row of rows) { + const credential = deserializeCredential(row); + if (!credential) continue; + results.push(toStoredAuthCredential(row, credential)); + } + return results; + } + + async listDisabledCredentials(provider?: string): Promise { + const rows = + (provider + ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[]) + : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? []; + const results: DisabledCredentialSummary[] = []; + for (const row of rows) { + const credential = deserializeCredential(row); + const summary: DisabledCredentialSummary = { + id: row.id, + provider: row.provider, + type: row.credential_type === "api_key" ? "api_key" : "oauth", + cause: row.disabled_cause ?? "disabled", + }; + if (credential?.type === "oauth") { + if (credential.email) summary.email = credential.email; + if (credential.accountId) summary.accountId = credential.accountId; + if (credential.orgId) summary.orgId = credential.orgId; + if (credential.orgName) summary.orgName = credential.orgName; + } + if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) { + summary.disabledAtMs = row.updated_at * 1000; + } + results.push(summary); + } + return results; + } + + replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] { + const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => { + const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const existing = existingRows.map(row => ({ + id: row.id, + credential: deserializeCredential(row), + identityKey: resolveRowCredentialIdentityKey(providerName, row), + })); + + const result: StoredAuthCredential[] = []; + const matchedExistingIds = new Set(); + + for (const credential of items) { + const serialized = serializeCredential(providerName, credential); + if (!serialized) continue; + const match = existing.find( + entry => + !matchedExistingIds.has(entry.id) && + matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential), + ); + if (match) { + matchedExistingIds.add(match.id); + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id); + result.push({ id: match.id, provider: providerName, credential, disabledCause: null }); + } else { + const row = this.#insertStmt.get( + providerName, + serialized.credentialType, + serialized.data, + serialized.identityKey, + ) as { id?: number } | undefined; + if (row?.id) { + result.push({ id: row.id, provider: providerName, credential, disabledCause: null }); + } + } + } + + for (const row of existing) { + if (!matchedExistingIds.has(row.id)) { + this.#deleteStmt.run("replaced by newer credential", row.id); + } + } + + return result; + }); + + const result = replace(provider, credentials); + this.#purgeSupersededDisabledRows(provider, result); + return result; + } + + upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] { + const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => { + const serialized = serializeCredential(providerName, item); + if (!serialized) return this.listAuthCredentials(providerName); + const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const existing = existingRows.map(row => ({ + id: row.id, + credential: deserializeCredential(row), + identityKey: resolveRowCredentialIdentityKey(providerName, row), + })); + + if (item.type === "oauth") { + for (const row of existing) { + if (row.credential && row.credential.type === "api_key") { + this.#deleteStmt.run("replaced by oauth login", row.id); + } + } + } + + let targetId: number | null = null; + for (const row of existing) { + if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue; + if (targetId === null) { + targetId = row.id; + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id); + continue; + } + this.#deleteStmt.run("replaced by newer credential", row.id); + } + + if (targetId === null) { + const row = this.#insertStmt.get( + providerName, + serialized.credentialType, + serialized.data, + serialized.identityKey, + ) as { id?: number } | undefined; + targetId = row?.id ?? null; + } + + const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const result: StoredAuthCredential[] = []; + for (const row of activeRows) { + const activeCredential = deserializeCredential(row); + if (!activeCredential) continue; + result.push(toStoredAuthCredential(row, activeCredential)); + } + return result; + }); + + const result = upsert(provider, credential); + this.#purgeSupersededDisabledRows(provider, result); + return result; + } + + /** + * Hard-deletes disabled rows for a provider when an active replacement exists. + * OAuth credentials match by identity key; API keys match by provider and type. + * Disabled rows without an active same-type replacement remain recoverable. + */ + #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void { + try { + let hasActiveApiKey = false; + const activeIdentityKeys = new Set(); + for (const row of activeRows) { + if (row.credential.type === "api_key") { + hasActiveApiKey = true; + continue; + } + const identityKey = resolveCredentialIdentityKey(provider, row.credential); + if (identityKey) activeIdentityKeys.add(identityKey); + } + if (!hasActiveApiKey && activeIdentityKeys.size === 0) return; + + const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[]; + for (const row of disabledRows) { + if (hasActiveApiKey && row.credential_type === "api_key") { + this.#hardDeleteStmt.run(row.id); + continue; + } + const identityKey = resolveRowCredentialIdentityKey(provider, row); + if (identityKey && activeIdentityKeys.has(identityKey)) { + this.#hardDeleteStmt.run(row.id); + } + } + } catch { + // Best-effort cleanup; don't let it break the main operation + } + } + + updateAuthCredential(id: number, credential: AuthCredential): void { + try { + const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); + let providerRow: { provider?: string } | undefined; + try { + providerRow = providerStmt.get(id) as { provider?: string } | undefined; + } finally { + providerStmt.finalize(); + } + const provider = providerRow?.provider ?? ""; + const serialized = serializeCredential(provider, credential); + if (!serialized) return; + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id); + if (provider) { + this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); + } + } catch { + // Ignore update failures + } + } + + tryUpdateAuthCredentialIfMatches( + id: number, + expectedData: string, + credential: AuthCredential, + lease?: CredentialRefreshLeaseFence, + ): boolean { + const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); + let providerRow: { provider?: string } | undefined; + try { + providerRow = providerStmt.get(id) as { provider?: string } | undefined; + } finally { + providerStmt.finalize(); + } + const provider = providerRow?.provider ?? ""; + const serialized = serializeCredential(provider, credential); + if (!serialized) return false; + const result = lease + ? (this.#updateIfMatchesWithLeaseStmt.run( + serialized.credentialType, + serialized.data, + serialized.identityKey, + id, + expectedData, + id, + lease.owner, + lease.nowMs, + ) as { changes: number }) + : (this.#updateIfMatchesStmt.run( + serialized.credentialType, + serialized.data, + serialized.identityKey, + id, + expectedData, + ) as { changes: number }); + if (result.changes === 0) return false; + if (provider) { + this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); + } + return true; + } + + deleteAuthCredential(id: number, disabledCause: string): void { + try { + this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id); + } catch { + // Ignore delete failures + } + } + + /** + * CAS-style disable: only soft-deletes the row when its `data` column still + * matches `expectedData` and the row has not already been disabled. Used by + * the OAuth refresh-failure path to avoid clobbering a peer that rotated the + * row between our pre-check and the disable. + */ + tryDisableAuthCredentialIfMatches( + id: number, + expectedData: string, + disabledCause: string, + lease?: CredentialRefreshLeaseFence, + ): boolean { + const result = lease + ? (this.#deleteIfMatchesWithLeaseStmt.run( + normalizeDisabledCause(disabledCause), + id, + expectedData, + id, + lease.owner, + lease.nowMs, + ) as { changes: number }) + : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as { + changes: number; + }); + return result.changes > 0; + } + deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void { + try { + this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider); + } catch { + // Ignore delete failures + } + } + + getCache(key: string, options?: { includeExpired?: boolean }): string | null { + try { + const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt; + const row = stmt.get(key) as { value?: string } | undefined; + return row?.value ?? null; + } catch { + return null; + } + } + + setCache(key: string, value: string, expiresAtSec: number): void { + try { + this.#upsertCacheStmt.run(key, value, expiresAtSec); + } catch { + // Ignore cache set failures + } + } + + /** Drop all cache rows whose keys start with the supplied prefix. */ + deleteCachePrefix(prefix: string): void { + try { + this.#deleteCachePrefixStmt.run(prefix.length, prefix); + } catch { + // Ignore cache delete failures + } + } + + cleanExpiredCache(): void { + try { + this.#deleteExpiredCacheStmt.run(); + } catch { + // Ignore cleanup errors + } + } + + getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined { + const nowMs = Date.now(); + const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; + // Current callers use meter scopes. The physical shared row exists only + // for direct SQLite readers from pre-meter releases. + if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { + return undefined; + } + if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as + | { blocked_until_ms?: number; updated_at?: number } + | undefined; + return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined; + } + + getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined { + const nowMs = Date.now(); + const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; + if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { + return undefined; + } + if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as + | { blocked_until_ms?: number; updated_at?: number } + | undefined; + if (typeof row?.blocked_until_ms !== "number") return undefined; + const memoryReconcileAfter = + this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0; + const persistedReconcileAfter = + typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0; + const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter); + return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined; + } + + upsertCredentialBlock(block: StoredCredentialBlock): void { + const isLegacyCodexBlock = + block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE; + const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope]; + const upsert = this.#db.transaction(() => { + for (const blockScope of blockScopes) { + this.#upsertCredentialBlockStmt.run( + block.credentialId, + block.providerKey, + blockScope, + block.blockedUntilMs, + ); + } + }); + upsert.immediate(); + + const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS); + for (const blockScope of blockScopes) { + this.#credentialBlockReconcileAfter.set( + `${block.credentialId}\0${block.providerKey}\0${blockScope}`, + reconcileAfterMs, + ); + } + if (isLegacyCodexBlock) { + this.#credentialBlockReconcileAfter.delete( + `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`, + ); + } + } + + deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void { + this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope); + this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`); + } + + deleteCredentialBlocks(credentialId: number): void { + this.#deleteCredentialBlocksStmt.run(credentialId); + for (const key of this.#credentialBlockReconcileAfter.keys()) { + if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key); + } + } + + cleanExpiredCredentialBlocks(nowMs: number): void { + this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) { + if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key); + } + } + + listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] { + if (credentialIds.length === 0) return []; + const nowMs = Date.now(); + this.cleanExpiredCredentialBlocks(nowMs); + const seenCredentialIds = new Set(); + const blocks: StoredCredentialBlock[] = []; + for (const credentialId of credentialIds) { + if (seenCredentialIds.has(credentialId)) continue; + seenCredentialIds.add(credentialId); + const rows = this.#listCredentialBlocksByCredentialStmt.all( + credentialId, + nowMs, + LEGACY_CODEX_BLOCK_PROVIDER_KEY, + LEGACY_CODEX_BLOCK_SCOPE, + ) as CredentialBlockRow[]; + for (const row of rows) { + blocks.push({ + credentialId: row.credential_id, + providerKey: row.provider_key, + blockScope: row.block_scope, + blockedUntilMs: row.blocked_until_ms, + updatedAtMs: row.updated_at * 1000, + }); + } + } + return blocks; + } + + tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { + const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as { + changes: number; + }; + return result.changes === 1; + } + + getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined { + const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined; + if (typeof row?.expires_at_ms !== "number") return undefined; + if (row.expires_at_ms <= Date.now()) return undefined; + return row.expires_at_ms; + } + + renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { + const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as { + changes: number; + }; + return result.changes === 1; + } + + releaseCredentialRefreshLease(credentialId: number, owner: string): void { + try { + this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner); + } catch { + // Ignore lease release failures; expired leases are stealable. + } + } + + recordUsageSnapshots(entries: UsageHistoryEntry[]): void { + try { + for (const entry of entries) { + const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS); + const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as + | { id: number; recorded_at: number } + | undefined; + if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) { + this.#updateUsageHistoryStmt.run( + entry.recordedAt, + entry.email ?? null, + entry.accountId ?? null, + entry.label, + entry.windowLabel ?? null, + entry.usedFraction ?? null, + entry.status ?? null, + entry.resetsAt ?? null, + last.id, + ); + continue; + } + this.#insertUsageHistoryStmt.run( + entry.recordedAt, + entry.provider, + entry.accountKey, + entry.email ?? null, + entry.accountId ?? null, + entry.limitId, + entry.label, + entry.windowLabel ?? null, + entry.usedFraction ?? null, + entry.status ?? null, + entry.resetsAt ?? null, + ); + } + } catch { + // History is best-effort; never break the usage fetch path. + } + } + + listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] { + try { + const provider = query?.provider ?? null; + const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{ + recorded_at: number; + provider: string; + account_key: string; + email: string | null; + account_id: string | null; + limit_id: string; + label: string; + window_label: string | null; + used_fraction: number | null; + status: string | null; + resets_at: number | null; + }>; + return rows.map(row => ({ + recordedAt: row.recorded_at, + provider: row.provider as Provider, + accountKey: row.account_key, + email: row.email ?? undefined, + accountId: row.account_id ?? undefined, + limitId: row.limit_id, + label: row.label, + windowLabel: row.window_label ?? undefined, + usedFraction: row.used_fraction ?? undefined, + status: (row.status ?? undefined) as UsageHistoryEntry["status"], + resetsAt: row.resets_at ?? undefined, + })); + } catch { + return []; + } + } + recordUsageCosts(entries: UsageCostHistoryEntry[]): void { + try { + for (const entry of entries) { + this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd); + } + } catch { + // Cost history is best-effort; never break request persistence. + } + } + + listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] { + try { + const provider = query?.provider ?? null; + const accountKey = query?.accountKey ?? null; + const rows = this.#listUsageCostsStmt.all( + query?.sinceMs ?? 0, + provider, + provider, + accountKey, + accountKey, + ) as Array<{ + recorded_at: number; + provider: string; + account_key: string; + cost_usd: number; + }>; + return rows.map(row => ({ + recordedAt: row.recorded_at, + provider: row.provider as Provider, + accountKey: row.account_key, + costUsd: row.cost_usd, + })); + } catch { + return []; + } + } + + recordClientUsage(report: ClientUsageReport): void { + const now = Date.now(); + this.#db + .query( + `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?) + ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`, + ) + .run(report.installId, report.hostname ?? null, now, now); + const findBucket = this.#db.query( + `SELECT id FROM client_usage + WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ? + ORDER BY recorded_at DESC LIMIT 1`, + ); + const merge = this.#db.query( + `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?, + output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, + cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`, + ); + const insert = this.#db.query( + `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const entry of report.entries) { + // Merge into the newest row of the same (install, provider, model) + // bucket so 10s client flushes don't accrete one row apiece forever. + const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS; + const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as { + id: number; + } | null; + if (existing) { + merge.run( + entry.at, + entry.requests, + entry.inputTokens, + entry.outputTokens, + entry.cacheReadTokens, + entry.cacheWriteTokens, + entry.costUsd, + existing.id, + ); + continue; + } + insert.run( + entry.at, + report.installId, + entry.provider, + entry.model, + entry.requests, + entry.inputTokens, + entry.outputTokens, + entry.cacheReadTokens, + entry.cacheWriteTokens, + entry.costUsd, + ); + } + } + + getClientUsageSummary(sinceMs: number): ClientUsageSummary { + const clients = this.#db + .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC") + .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>; + const aggregates = this.#db + .query( + `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens, + SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens, + SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd + FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider + ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`, + ) + .all(sinceMs) as Array<{ + install_id: string; + provider: string; + requests: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + cost_usd: number; + }>; + const providersByInstall = new Map(); + for (const row of aggregates) { + let list = providersByInstall.get(row.install_id); + if (!list) { + list = []; + providersByInstall.set(row.install_id, list); + } + list.push({ + provider: row.provider, + requests: row.requests, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + cacheReadTokens: row.cache_read_tokens, + cacheWriteTokens: row.cache_write_tokens, + costUsd: row.cost_usd, + }); + } + return { + clients: clients.map(client => ({ + installId: client.install_id, + hostname: client.hostname ?? undefined, + firstSeen: client.first_seen, + lastSeen: client.last_seen, + providers: providersByInstall.get(client.install_id) ?? [], + })), + }; + } + + // ─── Convenience methods for CLI ──────────────────────────────────────── + + /** + * Save OAuth credentials for a provider. + * Preserves unrelated identities and replaces only the matching credential. + */ + saveOAuth(provider: string, credentials: OAuthCredentials): void { + const credential: AuthCredential = { type: "oauth", ...credentials }; + this.upsertAuthCredentialForProvider(provider, credential); + } + + /** + * Get OAuth credentials for a provider. + */ + getOAuth(provider: string): OAuthCredentials | null { + const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; + for (const row of rows) { + const credential = deserializeCredential(row); + if (credential && credential.type === "oauth") { + const { type: _type, ...oauth } = credential; + return oauth as OAuthCredentials; + } + } + return null; + } + + /** + * Save API key for a provider (replaces existing). + */ + saveApiKey(provider: string, apiKey: string): void { + const credential: AuthCredential = { type: "api_key", key: apiKey }; + this.replaceAuthCredentialsForProvider(provider, [credential]); + } + + /** + * Get API key for a provider. + */ + getApiKey(provider: string): string | null { + const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; + for (const row of rows) { + const credential = deserializeCredential(row); + if (credential && credential.type === "api_key") { + return credential.key; + } + } + return null; + } + + /** + * List all providers with credentials. + */ + listProviders(): string[] { + const rows = this.#listActiveStmt.all() as AuthRow[]; + const providers = new Set(); + for (const row of rows) { + providers.add(row.provider); + } + return Array.from(providers); + } + + /** + * Delete all credentials for a provider. + */ + deleteProvider(provider: string): void { + this.deleteAuthCredentialsForProvider(provider, "deleted by user"); + } + + /** + * SQLite increments `data_version` when another connection commits. Own + * writes leave it unchanged and already notify AuthStorage directly. + */ + pollExternalChanges(): boolean { + this.#acknowledgeLocalAuthChanges(); + const dataVersion = this.#readDataVersion(); + if (dataVersion === this.#dataVersion) return false; + this.#dataVersion = dataVersion; + const authRevision = this.#readAuthRevision(); + if (authRevision === this.#authRevision) return false; + this.#authRevision = authRevision; + return true; + } + + acknowledgeLocalChanges(): void { + this.#acknowledgeLocalAuthChanges(); + } + + #acknowledgeLocalAuthChanges(): void { + const localAuthRevision = this.#readLocalAuthRevision(); + this.#authRevision += localAuthRevision - this.#localAuthRevision; + this.#localAuthRevision = localAuthRevision; + } + + #readDataVersion(): number { + const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null; + return row?.data_version ?? 0; + } + + #readAuthRevision(): number { + const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as { + revision?: number; + } | null; + return row?.revision ?? 0; + } + + #readLocalAuthRevision(): number { + const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as { + revision?: number; + } | null; + return row?.revision ?? 0; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#listActiveStmt.finalize(); + this.#listActiveByProviderStmt.finalize(); + this.#listDisabledStmt.finalize(); + this.#listDisabledByProviderStmt.finalize(); + this.#insertStmt.finalize(); + this.#updateStmt.finalize(); + this.#deleteStmt.finalize(); + this.#deleteIfMatchesStmt.finalize(); + this.#deleteByProviderStmt.finalize(); + this.#hardDeleteStmt.finalize(); + this.#getCacheStmt.finalize(); + this.#getCacheIncludingExpiredStmt.finalize(); + this.#upsertCacheStmt.finalize(); + this.#deleteExpiredCacheStmt.finalize(); + this.#getCredentialBlockStmt.finalize(); + this.#listCredentialBlocksByCredentialStmt.finalize(); + this.#upsertCredentialBlockStmt.finalize(); + this.#deleteCredentialBlocksStmt.finalize(); + this.#deleteCredentialBlockStmt.finalize(); + this.#deleteExpiredCredentialBlocksStmt.finalize(); + this.#insertUsageHistoryStmt.finalize(); + this.#lastUsageHistoryStmt.finalize(); + this.#listUsageHistoryStmt.finalize(); + this.#updateUsageHistoryStmt.finalize(); + this.#insertUsageCostStmt.finalize(); + this.#listUsageCostsStmt.finalize(); + this.#updateIfMatchesStmt.finalize(); + this.#updateIfMatchesWithLeaseStmt.finalize(); + this.#deleteIfMatchesWithLeaseStmt.finalize(); + this.#deleteCachePrefixStmt.finalize(); + this.#acquireCredentialRefreshLeaseStmt.finalize(); + this.#getCredentialRefreshLeaseStmt.finalize(); + this.#renewCredentialRefreshLeaseStmt.finalize(); + this.#releaseCredentialRefreshLeaseStmt.finalize(); + this.#db.close(); + } +} diff --git a/packages/ai/src/error/auth-classify.ts b/packages/ai/src/error/auth-classify.ts index 575bcc1a1..a253b0ad8 100644 --- a/packages/ai/src/error/auth-classify.ts +++ b/packages/ai/src/error/auth-classify.ts @@ -1,6 +1,6 @@ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import { isOAuthExpiry, isUsageLimit } from "./flags"; -import { isUsageLimitOutcome } from "./rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./rate-limit"; /** * Whether an OAuth refresh failure is definitive (the credential must be @@ -38,9 +38,10 @@ export function isAuthRetryableError(error: unknown): boolean { if (isUsageLimit(error)) return true; if (isInvalidatedOAuthTokenError(error)) return true; const httpStatus = extractHttpStatusFromError(error); - if (httpStatus === 401 || httpStatus === 403) return true; const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined; - if (embeddedStatus === 401 || embeddedStatus === 403) return true; - return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message); + const status = httpStatus ?? embeddedStatus; + if (isConcurrencyCapExclusion(status, message)) return false; + if (status === 401 || status === 403) return true; + return isUsageLimitOutcome(status, message); } diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 09e54f17a..c72ba9074 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -7,7 +7,13 @@ import { ProviderHttpError, STREAM_ENVELOPE_ERROR_PREFIX, } from "./classes"; -import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit"; +import { + isAccountScopedCapText, + isOpaqueStatusBody, + isUsageLimitStatus, + matchesUsageLimitText, + parseRateLimitReason, +} from "./rate-limit"; export const Flag = { Class: 0x1000, @@ -107,10 +113,17 @@ const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale| export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN = /failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i; -// Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the -// error, also surfaced in text). Treated as transient — a retry usually lands -// on a backend that has the model. -const COPILOT_MODEL_NOT_SUPPORTED_PATTERN = /model_not_supported/i; +// Copilot fleet skew: HTTP 400 rejecting a model that `/models` advertised on +// the very same host. Two codes appear in the wild — `model_not_supported` +// (per-OAuth-client rollout gap) and `model_not_available_for_integrator` +// (replicas whose integrator allowlist predates the model). Both flap +// request-to-request, so a retry usually lands on a backend that has the model. +const COPILOT_TRANSIENT_MODEL_CODES: Record = { + model_not_supported: true, + model_not_available_for_integrator: true, +}; +const COPILOT_MODEL_UNAVAILABLE_PATTERN = + /model_not_supported|model_not_available_for_integrator|not available for integrator/i; // Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error). // Feature-gated deployments (Azure Foundry, Baseten, …) reject `strict: true` // tools outright when the hosted model lacks structured outputs, e.g. @@ -332,21 +345,40 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un const isOpaque = isOpaqueStatusBody(cleanMessage); const isLimitStatus = isUsageLimitStatus(statusClean); + const reason = parseRateLimitReason(cleanMessage); + // Concurrency caps (e.g. Vertex "Online prediction concurrent requests + // quota exceeded") are shed-and-backoff, not credential-rotatable — + // exclude them even when the quota-worded phrasing matches the generic + // usage-limit text matcher, whose `quota.?exceeded` arm would otherwise + // set Flag.UsageLimit and burn a healthy sibling credential. HTTP 402 is + // excluded from this gate: it is categorically an account-billing cap, so + // a 402 whose body merely mentions concurrency still classifies as a + // usage limit, mirroring isUsageLimitOutcome. + const isBillingCapStatus = statusClean === 402; + const concurrencyExcluded = reason === "CONCURRENT_LIMIT" && !isBillingCapStatus; if ( - matchesUsageLimitText(cleanMessage) || - (isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED")) + !concurrencyExcluded && + (matchesUsageLimitText(cleanMessage) || + ((statusClean === 403 || statusClean === undefined) && isAccountScopedCapText(cleanMessage)) || + (isLimitStatus && + (isOpaque || reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT")))) ) { kinds |= Flag.UsageLimit; } if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout; else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient; + // A concurrency cap (e.g. Vertex "Online prediction concurrent requests + // quota exceeded") is transient — shed-and-backoff. The bare wording need + // not match TRANSIENT_TRANSPORT_PATTERN, so flag it explicitly to keep + // AIError.retriable from treating the temporary cap as terminal. + if (reason === "CONCURRENT_LIMIT") kinds |= Flag.Transient; if ((api === "openai-responses" || api === "openai-codex-responses") && isStaleResponsesText(errorMessage)) { kinds |= Flag.StaleResponsesItem; } - // Copilot per-client routing flap is transient. - if (statusClean === 400 && COPILOT_MODEL_NOT_SUPPORTED_PATTERN.test(cleanMessage)) kinds |= Flag.Transient; + // Copilot fleet-skew model rejection is transient. + if (statusClean === 400 && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(cleanMessage)) kinds |= Flag.Transient; if (matchesStrictToolsRejection(cleanMessage, statusClean)) kinds |= Flag.Grammar; if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported; } @@ -398,7 +430,10 @@ export function classify(error: unknown, api?: Api): number { if (code === "overloaded_error" || code === "rate_limit_error") { linkKinds |= Flag.Transient; } - if (codeStatus === 401 || codeStatus === 403) { + if ( + (codeStatus === 401 || codeStatus === 403) && + !(codeStatus === 403 && parseRateLimitReason(link.message) === "CONCURRENT_LIMIT") + ) { linkKinds |= Flag.AuthFailed; } else if (codeStatus === 429) { if ((linkKinds & Flag.UsageLimit) === 0) { @@ -460,16 +495,37 @@ export function isFastModeUnsupported(error: unknown): boolean { } /** - * GitHub Copilot 400 `model_not_supported` routing flap — transient. Reads the - * structural `code` (and falls back to {@link Flag.Transient} text classification). + * Depth-bounded search for a provider error `code`. SDK error objects keep the + * parsed response body on `.error`, and Copilot's body is itself + * `{ error: { code } }`, so the code sits up to two envelopes below the thrown + * error depending on which SDK produced it. + */ +function providerErrorCode(error: object): string | undefined { + let node: object = error; + for (let depth = 0; depth < 3; depth++) { + if ("code" in node && typeof node.code === "string") return node.code; + if (!("error" in node)) return undefined; + const nested: unknown = node.error; + if (!nested || typeof nested !== "object") return undefined; + node = nested; + } + return undefined; +} + +/** + * GitHub Copilot 400 rejecting a model its own `/models` catalog advertises — + * transient fleet skew, not a malformed request. Reads the structural `code` + * through the SDK/body envelopes, then falls back to the stringified body both + * SDK families put in `message` (shapes drift; the wire text does not). */ export function isCopilotTransientModelError(error: unknown): boolean { - if (status(error) === 400 && error && typeof error === "object") { - const info = error as { code?: unknown; error?: { code?: unknown } | null }; - const code = typeof info.code === "string" ? info.code : info.error?.code; - if (code === "model_not_supported") return true; - } - return false; + if (!error || typeof error !== "object" || status(error) !== 400) return false; + const code = providerErrorCode(error); + // `Object.hasOwn`, not a bare index: `code` is provider-controlled, and a + // prototype key (`__proto__`, `toString`, …) would otherwise read truthy. + if (code !== undefined && Object.hasOwn(COPILOT_TRANSIENT_MODEL_CODES, code)) return true; + const message: unknown = "message" in error ? error.message : undefined; + return typeof message === "string" && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(message); } export function classifyMessage(message: { diff --git a/packages/ai/src/error/index.ts b/packages/ai/src/error/index.ts index 4cdb1c3f5..fff0e2036 100644 --- a/packages/ai/src/error/index.ts +++ b/packages/ai/src/error/index.ts @@ -9,5 +9,6 @@ export * from "./format"; export * from "./gateway"; export * from "./oauth"; export * from "./provider"; +export * from "./rate-limit"; export * from "./retryable"; export * from "./validation"; diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 7252e0dcf..972a533ec 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -6,12 +6,14 @@ export type RateLimitReason = | "QUOTA_EXHAUSTED" | "RATE_LIMIT_EXCEEDED" + | "CONCURRENT_LIMIT" | "MODEL_CAPACITY_EXHAUSTED" | "SERVER_ERROR" | "UNKNOWN"; const QUOTA_EXHAUSTED_BACKOFF_MS = 30 * 60 * 1000; // 30 min const RATE_LIMIT_EXCEEDED_BACKOFF_MS = 30 * 1000; // 30s +const CONCURRENT_LIMIT_BACKOFF_MS = 5 * 1000; // 5s const MODEL_CAPACITY_BASE_MS = 45 * 1000; // 45s base const MODEL_CAPACITY_JITTER_MS = 30 * 1000; // ±15s const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s @@ -26,12 +28,24 @@ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b // before classifying explicit details; an otherwise opaque status is transient // model capacity, while quota/rate-limit/server wording remains authoritative. const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/gi; +const CONCURRENT_LIMIT_PATTERN = + // Require an actual cap signal near "concurrent". "Too many concurrent + // requests" is itself a cap signal; bare feature rejections such as + // "concurrent invocation is not supported" remain excluded. + /\btoo many\s+concurren\w*\s+(?:requests?|invocations?)\b|\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b|\bconcurren[a-z]*[-_](?:[a-z]+[_-])*(?:limit|quota|exceed\w*|reach\w*)/i; +const ACCOUNT_SCOPED_403_PATTERN = + // The bare "limit will reset" / "will reset in" phrasing also appears on + // statusless per-minute transients ("Rate limit will reset in 30 seconds"), + // so gate the reset-window alternative on account-specific wording (Devin's + // "Your limit will reset in …"); the overall/account qualifiers arm above + // already covers the rest. + /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i; /** * Classify a rate-limit error message into a reason category. * Priority order: explicit details in a resource-exhausted error > QUOTA - * (Antigravity "quota will reset") > MODEL_CAPACITY > QUOTA (account) > - * RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN. + * (Antigravity "quota will reset") > 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. @@ -50,6 +64,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason { return "QUOTA_EXHAUSTED"; } + if (CONCURRENT_LIMIT_PATTERN.test(errorMessage)) { + return "CONCURRENT_LIMIT"; + } + if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("529") || lower.includes("503")) { return "MODEL_CAPACITY_EXHAUSTED"; } @@ -111,6 +129,8 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number { return QUOTA_EXHAUSTED_BACKOFF_MS; case "RATE_LIMIT_EXCEEDED": return RATE_LIMIT_EXCEEDED_BACKOFF_MS; + case "CONCURRENT_LIMIT": + return CONCURRENT_LIMIT_BACKOFF_MS; case "MODEL_CAPACITY_EXHAUSTED": return MODEL_CAPACITY_BASE_MS + Math.random() * MODEL_CAPACITY_JITTER_MS; case "SERVER_ERROR": @@ -148,18 +168,37 @@ export function isUsageLimitStatus(status: number | undefined): boolean { * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status, * empty JSON, HTTP framing only) → rotate conservatively: the server * gave us nothing else to go on. - * 4. Body has content → defer to {@link parseRateLimitReason}. Only - * `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`, + * 4. Body has content → defer to {@link parseRateLimitReason}. `QUOTA_EXHAUSTED` + * rotates; for the categorical 402 billing cap a `CONCURRENT_LIMIT` body + * also rotates (the cap is concurrent-worded but the status is still an + * exhausted billing cap). `RATE_LIMIT_EXCEEDED` (`Too many requests`, * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`), * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the * provider's own backoff layer so transient 429s don't burn sibling * credentials. */ export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean { + // Concurrency caps are shed-and-backoff, not credential-rotatable — but only + // for quota-worded 429 / other statuses. HTTP 402 is categorically an + // account-billing cap, so a 402 whose body happens to mention concurrency is + // still an exhausted billing cap and must rotate; gate the exclusion on the + // status not being that categorical billing cap. + const isBillingCapStatus = status === 402; + if (isConcurrencyCapExclusion(status, message)) return false; if (message && matchesUsageLimitText(message)) return true; + // A 403 is normally an auth failure, but several providers deliver an + // account-scoped cap with it (Devin/Codeium Connect `permission_denied`, + // GitHub Copilot). Devin's end-of-stream Connect trailer carries no HTTP + // status at all (it arrives as a `permission_denied` ValidationError), so + // accept an undefined status too — but only when the body names a cap that + // resets, never on a bare 403, which stays an auth failure. + if ((status === 403 || status === undefined) && message && isAccountScopedCapText(message)) return true; if (!isUsageLimitStatus(status)) return false; if (!message || isOpaqueStatusBody(message)) return true; - return parseRateLimitReason(message) === "QUOTA_EXHAUSTED"; + const reason = parseRateLimitReason(message); + // For the categorical 402 billing cap a concurrency-worded body is still an + // exhausted cap (rotate); for 429 / other only QUOTA_EXHAUSTED rotates. + return reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT"); } /** @@ -190,3 +229,23 @@ export function matchesUsageLimitText(errorMessage: string): boolean { OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage) ); } + +/** + * Account-scoped cap phrasing delivered on a 403 (or a statusless Connect + * trailer): "Reached overall message rate limit", "Your limit will reset in …". + * Kept separate from {@link matchesUsageLimitText} because the bare wording is + * ambiguous without the 403 / statusless-account context; consumed by both + * {@link isUsageLimitOutcome} (rotation decision) and `flags.ts` (Flag.UsageLimit). + */ +export function isAccountScopedCapText(message: string): boolean { + return ACCOUNT_SCOPED_403_PATTERN.test(message); +} + +/** + * A concurrency cap on a non-billing status is shed-and-backoff, not + * credential-rotatable. This mirrors the exclusion in {@link isUsageLimitOutcome} + * for the 403 auth-retry entry points. A 402 remains a categorical billing cap. + */ +export function isConcurrencyCapExclusion(status: number | undefined, message: string | undefined): boolean { + return message !== undefined && parseRateLimitReason(message) === "CONCURRENT_LIMIT" && status !== 402; +} diff --git a/packages/ai/src/error/retryable.ts b/packages/ai/src/error/retryable.ts index 90a065ee3..af2f3ae87 100644 --- a/packages/ai/src/error/retryable.ts +++ b/packages/ai/src/error/retryable.ts @@ -33,7 +33,7 @@ function isTransientTransportMessage(message: string): boolean { export interface ProviderRetryableHooks { /** Provider id of the failing request, used to gate provider-specific checks. */ provider?: string; - /** Provider-specific transient predicate (e.g. Copilot `model_not_supported`). */ + /** Provider-specific transient predicate (e.g. Copilot model-availability 400s). */ isProviderTransient?: (error: Error) => boolean; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 880a6b42e..9232364b4 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1531,6 +1531,13 @@ async function* observeDecodedAnthropicSdkEvents( const PROVIDER_MAX_RETRIES = 10; +/** + * Flat delay between attempts when Copilot 400s a model its own `/models` + * catalog advertises. Part of the fleet carries the model and part doesn't, so + * the retry is a reroll rather than a wait for capacity to free up. + */ +const COPILOT_MODEL_FLAP_RETRY_DELAY_MS = 400; + /** * How long `ping` keepalives may keep extending the idle deadline without any * semantic stream progress, as a multiple of the idle timeout. Anthropic pings @@ -1561,8 +1568,8 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean { /** * Whether an Anthropic (or Copilot-over-Anthropic) stream error should be * retried. The classification lives in {@link AIError.isProviderRetryableError}; - * this wrapper injects the Copilot-specific `model_not_supported` transient - * check, which the error module must not import directly. + * this wrapper injects the Copilot-specific model-availability transient check, + * which the error module must not import directly. */ export function isProviderRetryableError(error: unknown, provider?: string): boolean { return AIError.isProviderRetryableError(error, { @@ -2709,7 +2716,12 @@ const streamAnthropicOnce = ( throw streamFailure; } providerRetryAttempt++; - const backoffDelayMs = calculateAnthropicRetryDelayMs(providerRetryAttempt - 1); + // Copilot's model-availability 400 is a per-request replica reroll, not + // upstream backpressure — the exponential curve would just add dead + // time to a coin flip that the next attempt is as likely to win. + const backoffDelayMs = AIError.isCopilotTransientModelError(streamFailure) + ? COPILOT_MODEL_FLAP_RETRY_DELAY_MS + : calculateAnthropicRetryDelayMs(providerRetryAttempt - 1); // Honor the server's retry hint (`retry-after-ms`/`retry-after`) on // 429/529-style failures: retrying sooner than the server asked is a // guaranteed failure that just burns the retry budget. diff --git a/packages/ai/src/providers/cursor-pi-args.ts b/packages/ai/src/providers/cursor-pi-args.ts index d7b759f5a..c88275b8f 100644 --- a/packages/ai/src/providers/cursor-pi-args.ts +++ b/packages/ai/src/providers/cursor-pi-args.ts @@ -47,6 +47,36 @@ export function piReadPath(readPath: string, offset?: number, limit?: number): s return count === undefined ? `${readPath}:raw:${start}-` : `${readPath}:raw:${start}+${count}`; } +const READ_RANGE_CHUNK_RE = /^L?(\d+)(?:(\.\.|[-+])L?(\d+)?)?$/i; + +function isReadRangeList(value: string): boolean { + return value.split(",").every(chunk => { + const match = READ_RANGE_CHUNK_RE.exec(chunk); + if (!match) return false; + const start = Number.parseInt(match[1]!, 10); + if (start < 1) return false; + const separator = match[2]; + if (!separator) return true; + const end = match[3] ? Number.parseInt(match[3], 10) : undefined; + if (separator === "+") return end !== undefined && end >= 1; + return end === undefined || end >= start; + }); +} + +/** + * Whether a read path ends in an OMP line selector, including compound `raw` + * forms. Cursor uses this only to describe the operation already executed by + * the coding-agent read tool; the selector remains embedded in the path. + */ +export function piReadPathHasRange(readPath: string): boolean { + const chunks = readPath.split(":"); + const last = chunks.at(-1); + if (last && isReadRangeList(last)) return true; + if (last?.toLowerCase() !== "raw") return false; + const preceding = chunks.at(-2); + return preceding !== undefined && isReadRangeList(preceding); +} + /** * The same range as {@link piReadPath}, rendered for a transcript block rather * than for execution. diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f1c466041..d276a27b0 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -216,6 +216,7 @@ import { piLimit, piLsPath, piReadDisplayPath, + piReadPathHasRange, piTimeout, } from "./cursor/exec-modern"; @@ -1322,7 +1323,7 @@ async function handleExecServerMessage( buildReadResultFromToolResult( args.path, toolResult, - args.offset !== undefined || args.limit !== undefined, + args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path), ), reason => buildReadRejectedResult(args.path, reason), error => buildReadErrorResult(args.path, error), @@ -2439,15 +2440,21 @@ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): bo /** * The file's own line count, when the tool recorded one. * - * `details.meta.truncation.totalLines` is the whole file; the flat - * `details.truncation.totalLines` counts from the window's start line and is - * deliberately not consulted here. Absent for a read that returned the file - * whole, where the payload IS the file and counting it is exact. + * Read results expose the source-wide count directly when known. Older tool + * results carry it at `details.meta.truncation.totalLines`; the flat + * `details.truncation.totalLines` counts from a window's start and is + * deliberately not consulted here. */ function readTotalLinesFromDetails(toolResult: ToolResultMessage): number | undefined { - if (!toolResult.details || typeof toolResult.details !== "object") return undefined; - const meta = (toolResult.details as { meta?: { truncation?: { totalLines?: unknown } } }).meta; - const totalLines = meta?.truncation?.totalLines; + const details = toolResult.details; + if (!details || typeof details !== "object") return undefined; + const direct = "totalLines" in details ? details.totalLines : undefined; + if (typeof direct === "number" && Number.isFinite(direct)) return direct; + const meta = "meta" in details ? details.meta : undefined; + if (!meta || typeof meta !== "object") return undefined; + const truncation = "truncation" in meta ? meta.truncation : undefined; + if (!truncation || typeof truncation !== "object") return undefined; + const totalLines = "totalLines" in truncation ? truncation.totalLines : undefined; return typeof totalLines === "number" && Number.isFinite(totalLines) ? totalLines : undefined; } @@ -2467,7 +2474,7 @@ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessa // whole file. Under a composed window it is the window's, and answering a // 20-line page of a 100-line file with `total_lines: 20` tells a paginating // server it has reached the end. - const totalLines = readTotalLinesFromDetails(toolResult) ?? (text ? text.split("\n").length : 0); + const totalLines = readTotalLinesFromDetails(toolResult) ?? (rangeApplied ? 0 : text ? text.split("\n").length : 0); return create(ReadResultSchema, { result: { case: "success", diff --git a/packages/ai/src/providers/cursor/exec-modern.ts b/packages/ai/src/providers/cursor/exec-modern.ts index ebf1bccdf..621f00a6e 100644 --- a/packages/ai/src/providers/cursor/exec-modern.ts +++ b/packages/ai/src/providers/cursor/exec-modern.ts @@ -81,6 +81,7 @@ export { piLsPath, piReadDisplayPath, piReadPath, + piReadPathHasRange, piTimeout, } from "../cursor-pi-args"; diff --git a/packages/ai/src/providers/mock.ts b/packages/ai/src/providers/mock.ts index 1e81292d2..a77a4bf95 100644 --- a/packages/ai/src/providers/mock.ts +++ b/packages/ai/src/providers/mock.ts @@ -67,7 +67,7 @@ export type MockApi = typeof MOCK_API; export type MockContent = | string | { type: "text"; text: string } - | { type: "thinking"; thinking: string } + | { type: "thinking"; thinking: string; thinkingSignature?: string } | { type: "toolCall"; /** Optional explicit id; auto-generated when omitted. */ diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 8c27fe700..9e320b70c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -128,7 +128,7 @@ import { redactSensitiveInObject, transformMessages } from "./transform-messages export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "concise" | "detailed" | null; - /** `reasoning.context` replay scope; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */ + /** Explicit `reasoning.context` replay scope. Omitted by default so Codex applies its native request policy. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; codexMode?: boolean; @@ -1530,7 +1530,7 @@ export async function buildTransformedCodexRequestBody( } const codexOptions: CodexRequestOptions = { reasoningEffort: options?.reasoning, - reasoningSummary: options?.reasoningSummary === undefined ? "auto" : options.reasoningSummary, + reasoningSummary: options?.reasoningSummary, reasoningContext: options?.reasoningContext, textVerbosity: options?.textVerbosity, include: options?.include, diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index b0b157e9c..13a2cc7f9 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -33,7 +33,7 @@ export interface CodexRequestOptions { /** User-facing effort; maps 1:1 onto the wire tier of the same name. */ reasoningEffort?: CodexCallerEffort | "none"; reasoningSummary?: ReasoningConfig["summary"] | null; - /** Explicit `reasoning.context` override; defaults to `all_turns` when unset. Gated to gpt-5.4+ Codex models (older ids reject it, so it is suppressed and `context` omitted). Note that under Responses Lite (`responsesLite`), the server strictly requires `reasoning.context` to be `all_turns`, which overrides this option and forces `all_turns`. */ + /** Explicit `reasoning.context` override. Omitted by default; Responses Lite forces `all_turns` as required by that transport. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; include?: string[]; @@ -145,13 +145,12 @@ function getReasoningConfig( const config: ReasoningConfig = { effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort), }; - // `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids - // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with - // "Unsupported parameter: 'reasoning.summary' is not supported with this model". - // Mirrors the all_turns gate: an explicit summary is suppressed on unsupported - // ids, letting the server skip the human-readable summary stream. - if (options.reasoningSummary !== null && supportsCodexReasoningSummary(model.id)) { - config.summary = options.reasoningSummary ?? "detailed"; + if ( + options.reasoningSummary !== undefined && + options.reasoningSummary !== null && + supportsCodexReasoningSummary(model.id) + ) { + config.summary = options.reasoningSummary; } return config; } @@ -444,21 +443,14 @@ export async function transformRequestBody( ...body.reasoning, ...reasoningConfig, }; - // Default reasoning replay to `all_turns`, mirroring codex-rs; an - // explicit `reasoningContext` overrides the default. The `all_turns` - // value is only accepted from gpt-5.4 onward — earlier Codex ids - // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with - // "Unsupported value: 'all_turns' is not supported with this model". - // For those, drop `context` so the server applies its `current_turn` - // default. The version gate is authoritative: even an explicit - // `all_turns` override is suppressed on unsupported models, while - // `current_turn`/`auto` (universally supported) always pass through. - // Note: Responses Lite forces `all_turns` to satisfy the transport's server invariant. - const context = responsesLite ? "all_turns" : (options.reasoningContext ?? "all_turns"); - if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) { - delete body.reasoning.context; - } else { - body.reasoning.context = context; + // Responses Lite requires `all_turns`; the full transport leaves context to the server unless explicitly set. + const context = responsesLite ? "all_turns" : options.reasoningContext; + if (context !== undefined) { + if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) { + delete body.reasoning.context; + } else { + body.reasoning.context = context; + } } } else { delete body.reasoning; @@ -481,10 +473,12 @@ export async function transformRequestBody( delete body.stream_options; } - body.text = { - ...body.text, - verbosity: options.textVerbosity || "medium", - }; + if (options.textVerbosity !== undefined) { + body.text = { + ...body.text, + verbosity: options.textVerbosity, + }; + } const include = Array.isArray(options.include) ? [...options.include] : []; include.push("reasoning.encrypted_content"); diff --git a/packages/ai/src/providers/pi-native-client.ts b/packages/ai/src/providers/pi-native-client.ts index 71276b739..4826318a4 100644 --- a/packages/ai/src/providers/pi-native-client.ts +++ b/packages/ai/src/providers/pi-native-client.ts @@ -11,7 +11,7 @@ * * Activated when a {@link Model} has `transport: "pi-native"` set; the * dispatch hook lives in `streamSimple()` (see `../stream.ts`). Used by - * containerized omp deployments (robomp slots, the swarm extension) that + * containerized omp deployments (such as robomp slots) that * route every LLM call through a credential-holding sidecar so the slot * itself stays credential-free. */ diff --git a/packages/ai/src/providers/pi-native-server.ts b/packages/ai/src/providers/pi-native-server.ts index bcea48c35..b8eddd2c7 100644 --- a/packages/ai/src/providers/pi-native-server.ts +++ b/packages/ai/src/providers/pi-native-server.ts @@ -4,7 +4,7 @@ * Where the OpenAI / Anthropic / Responses route modules translate foreign * wire shapes through pi-ai's canonical {@link Context}, this module accepts * the canonical shape *directly* — for clients that already speak pi-ai - * (containerized omp, the swarm extension, robomp's sidecar auth-gateway). + * (containerized omp, robomp's sidecar auth-gateway). * Skipping the wire-format → Context → wire-format round-trip cuts * per-request CPU but, more importantly, avoids the quantization that those * translations impose on first-class pi-ai fields (service tier, cache diff --git a/packages/ai/src/registry/oauth/devin.ts b/packages/ai/src/registry/oauth/devin.ts index b3b8029c5..e8c8c251b 100644 --- a/packages/ai/src/registry/oauth/devin.ts +++ b/packages/ai/src/registry/oauth/devin.ts @@ -33,7 +33,7 @@ class DevinOAuthFlow extends OAuthCallbackFlow { }); } - generateState(): string { + override generateState(): string { return crypto.randomUUID(); } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index aff282f07..f89dfd0e3 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -21,7 +21,7 @@ import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } fr import * as AIError from "./error"; import { ProviderHttpError } from "./error"; import { isInvalidatedOAuthTokenError } from "./error/auth-classify"; -import { isUsageLimitOutcome } from "./error/rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; import { coworkFetch } from "./providers/cowork-fetch"; @@ -995,7 +995,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me // instead of burning siblings. if (AIError.isUsageLimit(error)) return true; if (isInvalidatedOAuthTokenError(error)) return true; - if (status === 401 || status === 403) return true; + if (status === 401 || (status === 403 && !isConcurrencyCapExclusion(status, message))) return true; return isUsageLimitOutcome(status, message); } @@ -1705,7 +1705,7 @@ function mapOptionsForApi( serviceTier: options?.serviceTier, preferWebsockets: options?.preferWebsockets, codexCompaction: options?.codexCompaction, - reasoningSummary: options?.hideThinkingSummary ? null : "detailed", + reasoningSummary: options?.hideThinkingSummary ? null : undefined, textVerbosity: options?.textVerbosity, }); diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 57b080199..48f2248ca 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -263,11 +263,10 @@ function buildUsageAmount(window: ParsedUsageWindow): UsageAmount { }; } -function buildUsageStatus(usedFraction?: number, limitReached?: boolean): UsageLimit["status"] { - if (limitReached) return "exhausted"; - if (usedFraction === undefined) return "unknown"; - if (usedFraction >= 1) return "exhausted"; - if (usedFraction >= 0.9) return "warning"; +function buildUsageStatus(args: { usedFraction?: number; explicitlyAllowed: boolean }): UsageLimit["status"] { + if (args.usedFraction === undefined) return "unknown"; + if (args.usedFraction >= 1) return args.explicitlyAllowed ? "warning" : "exhausted"; + if (args.usedFraction >= 0.9) return "warning"; return "ok"; } @@ -276,6 +275,8 @@ function buildUsageLimit(args: { window: ParsedUsageWindow; accountId?: string; planType?: string; + allowed?: boolean; + limitReached?: boolean; nowMs: number; }): UsageLimit { const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs); @@ -290,16 +291,14 @@ function buildUsageLimit(args: { }, window: usageWindow, amount, - // Each chat window's status reflects ONLY its own usage. The account-level - // `rate_limit.limit_reached` flag is intentionally not applied here: Codex - // returns a single shared flag for the whole account, so threading it into - // both the primary (5h) and secondary (weekly) windows marked a window with - // real headroom `exhausted` purely because a different window (or a separate - // metered feature) was at its limit, which over-blocked sibling accounts - // during credential selection. `usedFraction >= 1` already marks a window - // that is genuinely full; a real enforced limit not reflected in - // `used_percent` is caught when the live request returns usage_limit_reached. - status: buildUsageStatus(amount.usedFraction), + // The shared account-level rejection flag cannot identify which window + // is binding, but an explicit positive verdict applies to both windows. + // Preserve 100% as a warning when Codex still allows requests; live + // usage_limit_reached responses remain authoritative for blocking. + status: buildUsageStatus({ + usedFraction: amount.usedFraction, + explicitlyAllowed: args.allowed === true && args.limitReached === false, + }), }; } function additionalLimitSlug(args: { limitName?: string; meteredFeature?: string }): string { @@ -331,6 +330,8 @@ function buildAdditionalUsageLimit(args: { accountId?: string; limitName?: string; meteredFeature?: string; + allowed?: boolean; + limitReached?: boolean; nowMs: number; }): UsageLimit { const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs); @@ -348,10 +349,12 @@ function buildAdditionalUsageLimit(args: { }, window: usageWindow, amount, - // The additional meter exposes one account-level flag for both windows. - // Status must follow this window's own usage or a full weekly meter marks - // the shorter window exhausted and schedules a premature retry. - status: buildUsageStatus(amount.usedFraction), + // A positive meter verdict is authoritative even when the advisory + // percentage rounds to 100; negative shared verdicts remain window-local. + status: buildUsageStatus({ + usedFraction: amount.usedFraction, + explicitlyAllowed: args.allowed === true && args.limitReached === false, + }), }; } @@ -449,6 +452,8 @@ export const openaiCodexUsageProvider: UsageProvider = { window: parsed.primary, accountId, planType, + allowed: parsed.allowed, + limitReached: parsed.limitReached, nowMs, }), ); @@ -460,6 +465,8 @@ export const openaiCodexUsageProvider: UsageProvider = { window: parsed.secondary, accountId, planType, + allowed: parsed.allowed, + limitReached: parsed.limitReached, nowMs, }), ); @@ -478,6 +485,8 @@ export const openaiCodexUsageProvider: UsageProvider = { accountId, limitName: extra.limitName, meteredFeature: extra.meteredFeature, + allowed: extra.allowed, + limitReached: extra.limitReached, nowMs, }), ); @@ -492,6 +501,8 @@ export const openaiCodexUsageProvider: UsageProvider = { accountId, limitName: extra.limitName, meteredFeature: extra.meteredFeature, + allowed: extra.allowed, + limitReached: extra.limitReached, nowMs, }), ); diff --git a/packages/ai/src/utils/http-inspector.ts b/packages/ai/src/utils/http-inspector.ts index bf4f4e047..b62e0f5fd 100644 --- a/packages/ai/src/utils/http-inspector.ts +++ b/packages/ai/src/utils/http-inspector.ts @@ -95,10 +95,11 @@ export async function finalizeErrorMessage( * Rewrite error message for GitHub Copilot request failures. * Must run AFTER finalizeErrorMessage since it replaces the message entirely. * - * 400 `model_not_supported` = Copilot routing rollout gap for our OAuth client. - * A preview model (gpt-5.3-codex, gpt-5.4*, ...) flaps between 200 and - * 400 because only some of Copilot's backends have the model. After the - * in-request retry exhausts, surface guidance rather than the raw error. + * 400 model-unavailable = Copilot fleet skew. A model that `/models` advertises + * (claude-sonnet-4.6, claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...) + * flaps between 200 and 400 because only part of Copilot's fleet has it + * in the integrator allowlist. After the in-request retry exhausts, + * surface guidance rather than the raw error. * 401 = token invalid/expired → credential removal is safe, prompt re-login. * 403 = token valid but access denied (plan, model policy, org restriction) → * do NOT reuse the auth-failed string (which triggers credential removal). @@ -113,7 +114,7 @@ export function rewriteCopilotError(errorMessage: string, error: unknown, provid return `GitHub Copilot access denied (HTTP 403). Your account may not have access to this model or feature. Check your Copilot plan or model policy settings.`; } if (isCopilotTransientModelError(error)) { - return `GitHub Copilot rejected this model (HTTP 400 model_not_supported) after retries. This is a known intermittent rollout gap for preview models on OAuth clients other than VS Code. Try again in a few seconds, switch to a GA model (gpt-5-mini, gpt-5.2), or run this model from VS Code.`; + return `GitHub Copilot rejected this model (HTTP 400) after retries: only part of its fleet currently serves this model id, even though /models advertises it. Try again in a few seconds or switch to a model Copilot serves fleet-wide (claude-opus-4.7, claude-sonnet-4.5, gpt-4.1).`; } return errorMessage; } diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index d89ee81b1..fce896bce 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -7,14 +7,25 @@ import { getHeadersFromError, getRetryAfterMsFromHeaders } from "./retry-after"; // home). Re-exported here so existing `../utils/retry` importers keep working. export { isCopilotTransientModelError }; -const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 3; +// Copilot's model-availability flap is a per-request coin flip across fleet +// replicas, not backpressure. Measured per-attempt rejection rates for models +// mid-rollout reach ~70% (gpt-5.4, 2026-08-04), and a live 10-turn run needed 6 +// attempts on one turn — so a small budget just pushes the failure up to the +// agent-level retry, which restarts the whole turn. Eight attempts on a flat +// delay keep the residual near 5% at p=0.7 and under 1% at p=0.5, bounded at +// ~2.8s of dead time in the pathological case. +const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 8; +// Transport blips and status-bearing failures keep the pre-flap budget: they are +// not coin flips, so a longer ramp only delays surfacing a persistent fault. +const COPILOT_GENERIC_RETRY_MAX_ATTEMPTS = 3; const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400; /** Longest server-requested backoff we are willing to sit out before giving up. */ const COPILOT_RETRY_AFTER_MAX_WAIT_MS = 30_000; /** - * Wrap an initial Copilot request so transient `model_not_supported` 400s are - * retried a small number of times. No-op for non-Copilot providers. + * Wrap an initial Copilot request so transient model-availability 400s + * (`model_not_supported`, `model_not_available_for_integrator`) are retried a + * small number of times. No-op for non-Copilot providers. * * The callback **MUST** create a fresh in-flight request each invocation — a * once-consumed AsyncIterable cannot be re-iterated. @@ -38,8 +49,16 @@ export async function callWithCopilotModelRetry( if (options.signal?.aborted) throw error; const transientModelError = isCopilotTransientModelError(error); if (!transientModelError && !isRetryableError(error)) throw error; - if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break; - let delayMs = retryBaseDelayMs * (attempt + 1); + // Budget is per failure kind, counted over attempts already spent: the + // eight-attempt allowance only covers the cheap model-availability reroll. + const maxAttempts = transientModelError + ? COPILOT_MODEL_RETRY_MAX_ATTEMPTS + : COPILOT_GENERIC_RETRY_MAX_ATTEMPTS; + if (attempt >= maxAttempts - 1) break; + // Reroll the model flap on a flat delay: a ramp only adds dead time to a + // coin flip the next attempt is equally likely to win. Generic retryable + // failures (429/5xx/transport) keep the linear backoff below. + let delayMs = transientModelError ? retryBaseDelayMs : retryBaseDelayMs * (attempt + 1); if (!transientModelError) { const errorStatus = status(error); if (errorStatus !== undefined) { diff --git a/packages/ai/test/anthropic-retry.test.ts b/packages/ai/test/anthropic-retry.test.ts index 9f20af608..8ab9a26e0 100644 --- a/packages/ai/test/anthropic-retry.test.ts +++ b/packages/ai/test/anthropic-retry.test.ts @@ -105,4 +105,22 @@ describe("isProviderRetryableError", () => { expect(isProviderRetryableError(err, "anthropic")).toBe(false); expect(isProviderRetryableError(err)).toBe(false); }); + + it("retries Copilot's model_not_available_for_integrator 400 from the Anthropic messages proxy", () => { + // Shape thrown by @anthropic-ai/sdk against api.githubcopilot.com/v1/messages: + // the parsed body lands on `.error` and is itself `{ error: { code } }`. + const body = { + error: { + message: + 'The requested model is not available for integrator "copilot-language-server". Available models: [gpt-4.1 claude-opus-4.7]. Verify the correct Copilot-Integration-Id header is being sent.', + code: "model_not_available_for_integrator", + param: "model", + type: "invalid_request_error", + }, + }; + const err = new Error(`400 ${JSON.stringify(body)}`); + Object.assign(err, { status: 400, error: body }); + expect(isProviderRetryableError(err, "github-copilot")).toBe(true); + expect(isProviderRetryableError(err, "anthropic")).toBe(false); + }); }); diff --git a/packages/ai/test/auth-retry.test.ts b/packages/ai/test/auth-retry.test.ts index e4fbdb652..e438dcf04 100644 --- a/packages/ai/test/auth-retry.test.ts +++ b/packages/ai/test/auth-retry.test.ts @@ -275,6 +275,32 @@ describe("withAuth", () => { expect(contexts.map(ctx => ctx.lastChance)).toEqual([false, true, true, true]); }); + it("leaves a 403 concurrency cap to the transient retry layer", async () => { + const keys: string[] = []; + const contexts: ApiKeyResolveContext[] = []; + const pool = ["k0", "k1", "k2", "k3"]; + let resolveIndex = 0; + const concurrencyCap = Object.assign(new Error("concurrent requests limit reached"), { status: 403 }); + + await expect( + withAuth( + ctx => { + contexts.push(ctx); + return ctx.error === undefined ? pool[0] : pool[++resolveIndex]; + }, + async key => { + keys.push(key); + throw concurrencyCap; + }, + ), + ).rejects.toBe(concurrencyCap); + + // The outer transient retry/backoff layer owns concurrency caps. The auth + // retry layer must not refresh or select a sibling credential. + expect(keys).toEqual(["k0"]); + expect(contexts.map(ctx => ctx.lastChance)).toEqual([false]); + }); + it("surfaces the last 403 when every sibling is denied", async () => { const errors = [authError(403), authError(403)]; const resolved = ["k0", "k1", "k0"]; diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 365e7da0b..72656926e 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -418,6 +418,37 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-healthy"); }); + test("selects an explicitly allowed 100% Team account over a rejected exhausted sibling", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-exhausted", "exhausted@example.com") }, + { type: "oauth", ...createCredential("acct-team", "team@example.com") }, + ]); + + usageByAccount.set( + "acct-exhausted", + createCodexUsageReport({ + accountId: "acct-exhausted", + primary: { usedFraction: 1, resetInMs: 3 * 24 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 3 * 24 * HOUR_MS }, + metadata: { allowed: false, limitReached: true, planType: "prolite" }, + }), + ); + const teamReport = createCodexUsageReport({ + accountId: "acct-team", + primary: { usedFraction: 0.2, resetInMs: HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * HOUR_MS }, + metadata: { allowed: true, limitReached: false, planType: "team" }, + }); + const teamSecondary = teamReport.limits.find(limit => limit.id === "openai-codex:secondary"); + if (!teamSecondary) throw new Error("expected Team weekly usage limit"); + teamSecondary.status = "warning"; + usageByAccount.set("acct-team", teamReport); + + expect(await authStorage.getApiKey("openai-codex", "allowed-team-at-100-percent")).toBe("api-acct-team"); + }); + test("temporarily blocks only the exhausted Codex OAuth credential after a quota 429", async () => { if (!authStorage) throw new Error("test setup failed"); @@ -2027,6 +2058,90 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-pro"); }); + test("ignores plan-ineligible headroom when reporting Spark model health", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-pro", "pro@example.com") }, + ]); + usageByAccount.set( + "acct-free", + addSparkUsage( + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com" }, + }), + 0.05, + 0.05, + ), + ); + usageByAccount.set( + "acct-pro", + addSparkUsage( + createCodexUsageReport({ + accountId: "acct-pro", + primary: { usedFraction: 1, resetInMs: 2 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "pro", email: "pro@example.com", limitReached: true }, + }), + 1, + 1, + ), + ); + + const health = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.3-codex-spark", + reserveFraction: 0.1, + }); + + expect(health.state).toBe("depleted"); + expect(health.accounts).toHaveLength(1); + expect(health.accounts[0]?.state).toBe("depleted"); + }); + + test("reports an all-plan-ineligible Codex pool as depleted", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-plus", "plus@example.com") }, + ]); + usageByAccount.set( + "acct-free", + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com" }, + }), + ); + usageByAccount.set( + "acct-plus", + createCodexUsageReport({ + accountId: "acct-plus", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "plus", email: "plus@example.com" }, + }), + ); + + const paidHealth = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.6-sol", + reserveFraction: 0.1, + }); + const proHealth = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.3-codex-spark", + reserveFraction: 0.1, + }); + + expect(paidHealth.state).toBe("healthy"); + expect(paidHealth.accounts).toHaveLength(1); + expect(proHealth).toEqual({ state: "depleted", accounts: [] }); + }); + test("routes codex spark to a single Plus account when no Pro is connected", async () => { if (!authStorage) throw new Error("test setup failed"); diff --git a/packages/ai/test/copilot-retry.test.ts b/packages/ai/test/copilot-retry.test.ts index 79b79d85f..850d26506 100644 --- a/packages/ai/test/copilot-retry.test.ts +++ b/packages/ai/test/copilot-retry.test.ts @@ -1,14 +1,29 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { scheduler } from "node:timers/promises"; import { callWithCopilotModelRetry, isCopilotTransientModelError } from "@oh-my-pi/pi-ai/utils/retry"; import { isRetryableError } from "@oh-my-pi/pi-utils"; -type ErrorShape = { status: number; code?: string; error?: { code?: string; message?: string }; message: string }; +afterEach(() => { + vi.restoreAllMocks(); +}); -function copilotError({ status, code, error, message }: ErrorShape): Error { +type ErrorShape = { + status: number; + code?: string; + error?: { code?: string; message?: string } | { error: { code?: string; message?: string } }; + message: string; + headers?: Record; +}; + +function copilotError({ status, code, error, message, headers }: ErrorShape): Error { const err = new Error(message); - (err as unknown as ErrorShape).status = status; - if (code !== undefined) (err as unknown as ErrorShape).code = code; - if (error !== undefined) (err as unknown as ErrorShape).error = error; + // Single sanctioned assertion point: `Error` carries no provider fields, and + // every test reads them back through the real classifier. + const shaped = err as unknown as ErrorShape; + shaped.status = status; + if (code !== undefined) shaped.code = code; + if (error !== undefined) shaped.error = error; + if (headers !== undefined) shaped.headers = headers; return err; } @@ -31,6 +46,31 @@ describe("isCopilotTransientModelError", () => { expect(isCopilotTransientModelError(err)).toBe(true); }); + it("matches 400 model_not_available_for_integrator nested two envelopes deep (Anthropic SDK shape)", () => { + // api.githubcopilot.com/v1/messages: the SDK stores the parsed body on + // `.error`, and that body is itself `{ error: { code } }`. + const err = copilotError({ + status: 400, + error: { + error: { + code: "model_not_available_for_integrator", + message: 'The requested model is not available for integrator "copilot-language-server".', + }, + }, + message: + '400 {"error":{"message":"The requested model is not available for integrator \\"copilot-language-server\\". Available models: [gpt-4.1 claude-opus-4.7]","code":"model_not_available_for_integrator","param":"model","type":"invalid_request_error"}}', + }); + expect(isCopilotTransientModelError(err)).toBe(true); + }); + + it("falls back to the stringified body when no envelope exposes a code", () => { + const err = copilotError({ + status: 400, + message: '400 {"error":{"message":"The requested model is not available for integrator \\"x\\"."}}', + }); + expect(isCopilotTransientModelError(err)).toBe(true); + }); + it("does not match other 400 codes", () => { const err = copilotError({ status: 400, @@ -40,6 +80,13 @@ describe("isCopilotTransientModelError", () => { expect(isCopilotTransientModelError(err)).toBe(false); }); + it("does not match 400 codes that collide with Object.prototype keys", () => { + for (const code of ["__proto__", "constructor", "toString", "hasOwnProperty"]) { + const err = copilotError({ status: 400, code, message: "bad request" }); + expect(isCopilotTransientModelError(err)).toBe(false); + } + }); + it("does not match 401/403/500 regardless of code", () => { for (const status of [401, 403, 500]) { const err = copilotError({ @@ -74,7 +121,7 @@ describe("callWithCopilotModelRetry", () => { expect(calls).toBe(1); }); - it("retries up to 3 attempts for Copilot transient errors and eventually throws the last error", async () => { + it("retries up to 8 attempts for Copilot transient errors and eventually throws the last error", async () => { let calls = 0; const err = copilotError({ status: 400, code: "model_not_supported", message: "transient" }); await expect( @@ -86,7 +133,7 @@ describe("callWithCopilotModelRetry", () => { { provider: "github-copilot", retryBaseDelayMs: 0 }, ), ).rejects.toBe(err); - expect(calls).toBe(3); + expect(calls).toBe(8); }); it("succeeds on the second attempt when the first is transient", async () => { @@ -141,9 +188,7 @@ describe("callWithCopilotModelRetry", () => { async () => { calls += 1; if (calls === 1) { - const err = copilotError({ status: 429, message: "rate limited" }); - (err as unknown as { headers: Record }).headers = { "retry-after": "0.01" }; - throw err; + throw copilotError({ status: 429, message: "rate limited", headers: { "retry-after": "0.01" } }); } return "ok" as const; }, @@ -153,6 +198,21 @@ describe("callWithCopilotModelRetry", () => { expect(calls).toBe(2); }); + it("does not stretch a persistent Retry-After 429 across the flap budget", async () => { + let calls = 0; + const err = copilotError({ status: 429, message: "rate limited", headers: { "retry-after": "0.01" } }); + await expect( + callWithCopilotModelRetry( + async () => { + calls += 1; + throw err; + }, + { provider: "github-copilot", retryBaseDelayMs: 0 }, + ), + ).rejects.toBe(err); + expect(calls).toBe(3); + }); + it("still retries status-less transport blips with the linear backoff", async () => { let calls = 0; const result = await callWithCopilotModelRetry( @@ -171,6 +231,62 @@ describe("callWithCopilotModelRetry", () => { expect(calls).toBe(2); }); + it("caps persistent generic retryable failures at the pre-flap budget", async () => { + let calls = 0; + const err = new Error( + 'HTTP2StreamReset fetching "https://api.example.com/x". For more information, pass `verbose: true` in the second argument to fetch()', + ); + await expect( + callWithCopilotModelRetry( + async () => { + calls += 1; + throw err; + }, + { provider: "github-copilot", retryBaseDelayMs: 0 }, + ), + ).rejects.toBe(err); + expect(calls).toBe(3); + }); + + it("keeps the flat delay and the larger budget scoped to model flaps", async () => { + const flatWaits: number[] = []; + const rampWaits: number[] = []; + const record = (into: number[]) => { + const spy = vi.spyOn(scheduler, "wait"); + spy.mockImplementation(async (delay?: number) => { + into.push(delay ?? 0); + }); + return spy; + }; + + record(flatWaits); + await expect( + callWithCopilotModelRetry( + async () => { + throw copilotError({ status: 400, code: "model_not_available_for_integrator", message: "flap" }); + }, + { provider: "github-copilot", retryBaseDelayMs: 100 }, + ), + ).rejects.toBeInstanceOf(Error); + vi.restoreAllMocks(); + + record(rampWaits); + await expect( + callWithCopilotModelRetry( + async () => { + throw new Error( + 'HTTP2StreamReset fetching "https://api.example.com/x". For more information, pass `verbose: true` in the second argument to fetch()', + ); + }, + { provider: "github-copilot", retryBaseDelayMs: 100 }, + ), + ).rejects.toBeInstanceOf(Error); + vi.restoreAllMocks(); + + expect(flatWaits).toEqual([100, 100, 100, 100, 100, 100, 100]); + expect(rampWaits).toEqual([100, 200]); + }); + it("stops retrying when the caller aborts during backoff", async () => { const controller = new AbortController(); controller.abort(); diff --git a/packages/ai/test/cursor-exec-modern.test.ts b/packages/ai/test/cursor-exec-modern.test.ts index 45947f29e..1a796958e 100644 --- a/packages/ai/test/cursor-exec-modern.test.ts +++ b/packages/ai/test/cursor-exec-modern.test.ts @@ -1680,6 +1680,31 @@ describe("Cursor legacy read frame: range reporting", () => { if (wholeAnswer.value.result.case !== "success") throw new Error(`got ${wholeAnswer.value.result.case}`); expect(wholeAnswer.value.result.value.rangeApplied).toBe(false); }); + it("treats a path-embedded selector as ranged without reporting the slice as the file total", async () => { + const slice = Array.from({ length: 55 }, (_, index) => `line ${index + 301}`).join("\n"); + const { frames } = await dispatchExec( + buildExecMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { + path: "/repo/plan.md:raw:301-", + toolCallId: "c-inline", + }), + }), + { + execHandlers: { + async read() { + return toolResult(slice, { details: { fileSize: 21_015 } }); + }, + }, + }, + ); + const answer = soleResult(frames); + if (answer.case !== "readResult") throw new Error(`got ${answer.case}`); + if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`); + expect(answer.value.result.value.totalLines).toBe(0); + expect(answer.value.result.value.rangeApplied).toBe(true); + expect(answer.value.result.value.fileSize).toBe(21_015n); + }); it("carries the composed selector into the synthesized call", async () => { // A bare path beside a ranged result makes the slice look like the whole diff --git a/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts b/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts new file mode 100644 index 000000000..3146cfac5 --- /dev/null +++ b/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { OPENCODE_HEADERS } from "@oh-my-pi/pi-catalog/wire/github-copilot"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeCopilotClaudeModel(): Model<"anthropic-messages"> { + return buildModel({ + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + headers: { ...OPENCODE_HEADERS }, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_000, + }); +} + +const testContext: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +/** + * Verbatim body served by `api.githubcopilot.com/v1/messages` when the request + * lands on a fleet replica whose integrator allowlist predates the model, even + * though `/models` on the same host advertises it. + */ +const FLEET_SKEW_BODY = { + error: { + message: + 'The requested model is not available for integrator "copilot-language-server". Available models: [gpt-4.1 claude-opus-4.7 claude-sonnet-4.5]. Verify the correct Copilot-Integration-Id header is being sent.', + code: "model_not_available_for_integrator", + param: "model", + type: "invalid_request_error", + }, +}; + +const SSE_EVENTS = [ + { + type: "message_start", + message: { + id: "msg_fleet", + type: "message", + role: "assistant", + model: "claude-sonnet-4.6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 7, output_tokens: 0 }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "second try" } }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 3 }, + }, + { type: "message_stop" }, +]; + +function sseResponse(): Response { + const body = `${SSE_EVENTS.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n`).join("\n")}\n`; + return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } }); +} + +describe("GitHub Copilot Anthropic fleet skew", () => { + it("retries the model-availability 400 and completes on the next replica", async () => { + let attempts = 0; + const fetchMock = vi.fn(async () => { + attempts += 1; + if (attempts === 1) { + return new Response(JSON.stringify(FLEET_SKEW_BODY), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + return sseResponse(); + }); + + const result = await streamAnthropic(makeCopilotClaudeModel(), testContext, { + apiKey: "ghu_test_copilot_token", + fetch: fetchMock as unknown as typeof fetch, + providerRetryWait: async () => {}, + }).result(); + + expect(attempts).toBe(2); + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + expect(result.content).toMatchObject([{ type: "text", text: "second try" }]); + // Billing is per user prompt, not per wire attempt: a turn that burned an + // extra gateway-rejected attempt must still report one premium request. + expect(result.usage.premiumRequests).toBe(1); + }); + + it("surfaces fleet-skew guidance once every retry lands on a stale replica", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(FLEET_SKEW_BODY), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await streamAnthropic(makeCopilotClaudeModel(), testContext, { + apiKey: "ghu_test_copilot_token", + fetch: fetchMock as unknown as typeof fetch, + providerRetryWait: async () => {}, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("only part of its fleet"); + // Every attempt is a fresh wire request, not a replayed promise. + expect(fetchMock.mock.calls.length).toBeGreaterThan(1); + }); +}); diff --git a/packages/ai/test/github-copilot-error.test.ts b/packages/ai/test/github-copilot-error.test.ts index e7fce6edb..badf9be60 100644 --- a/packages/ai/test/github-copilot-error.test.ts +++ b/packages/ai/test/github-copilot-error.test.ts @@ -33,14 +33,16 @@ describe("rewriteCopilotError", () => { expect(result).not.toContain("/login github-copilot"); }); - it("rewrites 400 model_not_supported with rollout-gap guidance", () => { - const err = new Error("400 The requested model is not supported."); - (err as unknown as { status: number; code: string }).status = 400; - (err as unknown as { status: number; code: string }).code = "model_not_supported"; - const result = rewriteCopilotError("original", err, "github-copilot"); - expect(result).toContain("HTTP 400 model_not_supported"); - expect(result).toContain("rollout gap"); - expect(result).not.toContain("authentication failed"); + it("rewrites 400 model-unavailable codes with fleet-skew guidance", () => { + for (const code of ["model_not_supported", "model_not_available_for_integrator"]) { + const err = new Error("400 The requested model is not available."); + (err as unknown as { status: number; code: string }).status = 400; + (err as unknown as { status: number; code: string }).code = code; + const result = rewriteCopilotError("original", err, "github-copilot"); + expect(result).toContain("HTTP 400"); + expect(result).toContain("only part of its fleet"); + expect(result).not.toContain("authentication failed"); + } }); it("leaves non-copilot 400 model_not_supported untouched", () => { diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index 3f4ff7a74..222bd511b 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -128,71 +128,58 @@ function createCodexFetchMock(sse: string, onRequest: (captured: CapturedCodexRe }) as FetchImpl; } -describe("openai-codex reasoning.context", () => { - it("defaults to all_turns on gpt-5.4+ models and forwards explicit overrides", async () => { - const model = createCodexModel("gpt-5.4"); +describe("openai-codex optional response controls", () => { + it("omits optional controls on full requests and forwards explicit controls", async () => { + const model = createCodexModel("gpt-5.5"); const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning?.context).toBe("all_turns"); + expect(defaulted.reasoning).toEqual({ effort: "medium" }); + expect("summary" in (defaulted.reasoning ?? {})).toBe(false); + expect("context" in (defaulted.reasoning ?? {})).toBe(false); + expect("text" in defaulted).toBe(false); + expect("stream_options" in defaulted).toBe(false); const explicit = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", - reasoningContext: "current_turn", + reasoningSummary: "concise", + reasoningContext: "all_turns", + textVerbosity: "low", }); - expect(explicit.reasoning?.context).toBe("current_turn"); + expect(explicit.reasoning).toEqual({ + effort: "medium", + summary: "concise", + context: "all_turns", + }); + expect(explicit.text).toEqual({ verbosity: "low" }); + expect(explicit.stream_options).toEqual({ reasoning_summary_delivery: "sequential_cutoff" }); }); - it("keeps the all_turns default for the lite transport on supported models", async () => { + it("omits reasoning.summary when explicitly suppressed", async () => { const model = createCodexModel("gpt-5.5"); - - const lite = await transformRequestBody({ model: model.id }, model, { + const suppressed = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", - responsesLite: true, + reasoningSummary: null, }); - expect(lite.reasoning?.context).toBe("all_turns"); - - const overridden = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - responsesLite: true, - reasoningContext: "auto", - }); - expect(overridden.reasoning?.context).toBe("all_turns"); + expect(suppressed.reasoning).toEqual({ effort: "medium" }); + expect("summary" in (suppressed.reasoning ?? {})).toBe(false); + expect("stream_options" in suppressed).toBe(false); }); - it("enforces reasoning.context to be all_turns for the lite transport even when effort is unset or none", async () => { + it("forces reasoning.context to all_turns for Responses Lite", async () => { const model = createCodexModel("gpt-5.5"); - // Case 1: reasoningEffort is undefined (missing effort) const missingEffort = await transformRequestBody({ model: model.id }, model, { responsesLite: true, }); - expect(missingEffort.reasoning?.context).toBe("all_turns"); - expect(missingEffort.reasoning?.effort).toBeUndefined(); + expect(missingEffort.reasoning).toEqual({ context: "all_turns" }); - // Case 2: reasoningEffort is explicitly "none" (effort set to off) const noneEffort = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "none", responsesLite: true, - }); - expect(noneEffort.reasoning?.context).toBe("all_turns"); - expect(noneEffort.reasoning?.effort).toBe("none"); - - // Case 3: Conflicting explicit reasoningContext with missing effort under Lite - const conflictingUnsetEffort = await transformRequestBody({ model: model.id }, model, { - responsesLite: true, reasoningContext: "current_turn", }); - expect(conflictingUnsetEffort.reasoning?.context).toBe("all_turns"); + expect(noneEffort.reasoning).toEqual({ effort: "none", context: "all_turns" }); - // Case 4: Conflicting explicit reasoningContext with "none" effort under Lite - const conflictingNoneEffort = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "none", - responsesLite: true, - reasoningContext: "current_turn", - }); - expect(conflictingNoneEffort.reasoning?.context).toBe("all_turns"); - - // Case 5: responsesLite is false and reasoningEffort is undefined (regular request with no effort) const plainRequest = await transformRequestBody({ model: model.id }, model, { responsesLite: false, }); @@ -202,73 +189,35 @@ describe("openai-codex reasoning.context", () => { // gpt-5.1-codex / gpt-5.3-codex / gpt-5.3-codex-spark reject `all_turns` // ("Unsupported value: 'all_turns' is not supported with this model"). it.each(["gpt-5.1-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark"])( - "omits the all_turns default for pre-5.4 model %s", + "omits unsupported all_turns context for pre-5.4 model %s", async modelId => { const model = createCodexModel(modelId); + const forced = await transformRequestBody({ model: model.id }, model, { + reasoningEffort: "medium", + reasoningContext: "all_turns", + }); + expect(forced.reasoning).toEqual({ effort: "medium" }); - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning).toBeDefined(); - expect(defaulted.reasoning?.context).toBeUndefined(); - expect("context" in (defaulted.reasoning ?? {})).toBe(false); - - // A supported override (current_turn/auto) is still honored. const overridden = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", reasoningContext: "current_turn", }); - expect(overridden.reasoning?.context).toBe("current_turn"); + expect(overridden.reasoning).toEqual({ effort: "medium", context: "current_turn" }); }, ); - it("suppresses an explicit all_turns override on a pre-5.4 model", async () => { - const model = createCodexModel("gpt-5.3-codex-spark"); - - const forced = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningContext: "all_turns", - }); - expect(forced.reasoning).toBeDefined(); - expect(forced.reasoning?.context).toBeUndefined(); - }); -}); - -describe("openai-codex reasoning.summary", () => { - it("sends summary on gpt-5.4+ models and honors explicit levels", async () => { - const model = createCodexModel("gpt-5.4"); - - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning?.summary).toBe("detailed"); - - const explicit = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningSummary: "concise", - }); - expect(explicit.reasoning?.summary).toBe("concise"); - - const suppressed = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningSummary: null, - }); - expect("summary" in (suppressed.reasoning ?? {})).toBe(false); - }); - // gpt-5.1-codex / gpt-5.3-codex / gpt-5.3-codex-spark reject `reasoning.summary` // ("Unsupported parameter: 'reasoning.summary' is not supported with this model"). it.each(["gpt-5.1-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark"])( "omits reasoning.summary for pre-5.4 model %s", async modelId => { const model = createCodexModel(modelId); - - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning).toBeDefined(); - expect("summary" in (defaulted.reasoning ?? {})).toBe(false); - - // Even an explicit summary level is suppressed on unsupported ids. const forced = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", reasoningSummary: "detailed", }); - expect("summary" in (forced.reasoning ?? {})).toBe(false); + expect(forced.reasoning).toEqual({ effort: "medium" }); + expect("stream_options" in forced).toBe(false); }, ); }); @@ -816,7 +765,10 @@ describe("openai-codex concurrent reasoning summaries", () => { it("sends stream_options only when a summary is requested and supported", async () => { const terra = createCodexModel("gpt-5.6-terra"); - const withSummary = await transformRequestBody({ model: terra.id }, terra, { reasoningEffort: "medium" }); + const withSummary = await transformRequestBody({ model: terra.id }, terra, { + reasoningEffort: "medium", + reasoningSummary: "detailed", + }); expect(withSummary.stream_options).toEqual({ reasoning_summary_delivery: "sequential_cutoff" }); expect(withSummary.reasoning?.summary).toBe("detailed"); @@ -830,7 +782,10 @@ describe("openai-codex concurrent reasoning summaries", () => { expect(noReasoning.stream_options).toBeUndefined(); const legacy = createCodexModel("gpt-5.1-codex"); - const unsupported = await transformRequestBody({ model: legacy.id }, legacy, { reasoningEffort: "medium" }); + const unsupported = await transformRequestBody({ model: legacy.id }, legacy, { + reasoningEffort: "medium", + reasoningSummary: "detailed", + }); expect(unsupported.stream_options).toBeUndefined(); }); @@ -889,6 +844,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const thinkingDeltas: string[] = []; for await (const event of stream) { @@ -1060,6 +1016,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const thinkingDeltas: string[] = []; for await (const event of stream) { @@ -1215,6 +1172,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const deltasByBlock = new Map(); for await (const event of stream) { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 4e4c543dd..9b79ce4bd 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -19,6 +19,7 @@ import type { } from "@oh-my-pi/pi-ai/types"; import { __resetProxyCache } from "@oh-my-pi/pi-ai/utils/proxy"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; import * as piUtils from "@oh-my-pi/pi-utils"; import { withEnv } from "./helpers"; @@ -409,7 +410,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_opaque", responseId: "resp_opaque", text: "pong" }); } } @@ -501,6 +502,32 @@ describe("openai-codex streaming", () => { expect(capturedText).toEqual({ verbosity: "low" }); }); + it("omits optional response controls from default SimpleStreamOptions", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + const token = createCodexTestToken(); + const context = createCodexTestContext(); + const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false }; + let capturedBody: Record | undefined; + const fetchMock: FetchImpl = async (_input, init) => { + capturedBody = JSON.parse(decodeCodexRequestBody(init?.body)) as Record; + return new Response(createCompletedCodexSse("Hello"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + const result = await streamSimple(model, context, { + apiKey: token, + fetch: fetchMock, + reasoning: Effort.Medium, + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(capturedBody?.reasoning).toEqual({ effort: "medium" }); + expect(capturedBody?.text).toBeUndefined(); + }); + async function runCodexSseEvents(events: unknown[]) { const token = createCodexTestToken(); const context = createCodexTestContext(); @@ -1316,7 +1343,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { const added = encodeWebSocketMessage({ type: "response.output_item.added", item: { type: "message", id: "msg_ws", role: "assistant", status: "in_progress", content: [] }, @@ -1374,7 +1401,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_obs", responseId: "resp_obs", text: "Observed" }); } } @@ -1431,7 +1458,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.done", response: { @@ -1484,7 +1511,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.done", response: { @@ -1638,7 +1665,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); this.emitCodexResponse({ messageId: "msg_lite", responseId: "resp_lite", text: "Hi" }); } @@ -2876,7 +2903,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { websocketRequestCount += 1; const body: unknown = JSON.parse(data); if (websocketRequestCount === 1) { @@ -3064,7 +3091,7 @@ describe("openai-codex streaming", () => { }); } - send(_data: string): void { + override send(_data: string): void { websocketRequestCount += 1; this.emitCodexResponse({ messageId: `msg_pre_turn_${websocketRequestCount}`, @@ -3184,7 +3211,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); this.sendJson({ type: "response.output_item.added", @@ -3269,7 +3296,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); const responseIndex = sentRequests.length; this.emitCodexResponse({ @@ -3409,7 +3436,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); if (sentRequests.length === 1) { this.sendJson({ @@ -3508,7 +3535,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.completed", response: { @@ -3564,7 +3591,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.#sendCount += 1; if (this.#sendCount === 1) { this.emitCodexResponse({ @@ -3653,7 +3680,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); const responseIndex = sentRequests.length; this.emitCodexResponse({ @@ -3770,7 +3797,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as Record; sentRequests.push(request); const requestIndex = sentRequests.length; @@ -3878,7 +3905,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as Record; sentRequests.push(request); const requestIndex = sentRequests.length; @@ -3996,7 +4023,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_v2", responseId: "resp_v2", text: "Hello v2" }); } } @@ -4053,7 +4080,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; } } @@ -4113,7 +4140,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4143,7 +4170,7 @@ describe("openai-codex streaming", () => { }, 2); } - close(): void { + override close(): void { if (interval) clearInterval(interval); super.close(); } @@ -4190,7 +4217,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4213,7 +4240,7 @@ describe("openai-codex streaming", () => { } } - close(): void { + override close(): void { closeCount += 1; super.close(); } @@ -4257,7 +4284,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { if (this.#index === 0) { // First attempt: a function call whose arguments are only whitespace. // A completed reasoning item lands in nativeOutputItems before the @@ -4313,7 +4340,7 @@ describe("openai-codex streaming", () => { }); } - close(): void { + override close(): void { closeCount += 1; super.close(); } @@ -4384,7 +4411,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4436,7 +4463,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { // Every frame lands in the connection queue synchronously, before the // consumer microtask drains any of them; the close event used to wipe // the queued terminal event and turn success into a transport error. @@ -4479,7 +4506,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.output_item.added", item: { type: "function_call", id: "fc_limit", call_id: "call_limit", name: "todo", arguments: "" }, @@ -4534,13 +4561,13 @@ describe("openai-codex streaming", () => { this.emit("open", new Event("open")); } - close(): void { + override close(): void { const wasPending = this.readyState === MockWebSocket.CONNECTING; super.close(); if (wasPending) this.emit("close", { code: 1000 } as unknown as Event); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_join", responseId: "resp_join", text: "Joined" }); } } @@ -4597,7 +4624,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4669,7 +4696,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as { type?: string }; const requestType = typeof request.type === "string" ? request.type : ""; sentTypesByConnection[this.#connectionIndex]?.push(requestType); @@ -4796,7 +4823,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.output_item.added", item: { @@ -4886,7 +4913,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { this.#sendCount += 1; const request = JSON.parse(data) as { type?: string }; requestTypes.push(typeof request.type === "string" ? request.type : ""); @@ -4979,7 +5006,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sendCount += 1; const request = JSON.parse(data) as Record; expect(typeof request.type).toBe("string"); @@ -5272,7 +5299,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(_data: string): void { + override send(_data: string): void { sendCount += 1; if (sendCount === 1) { this.emitCodexResponse({ diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 56a83e7c4..cf3dddc3a 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -67,6 +67,24 @@ describe("openai-codex usage parser", () => { expect(main?.[0].amount.usedFraction).toBeCloseTo(0.04, 5); }); + it("keeps an explicitly allowed Team window usable at 100% reported usage", async () => { + const payload = makePayload(); + payload.plan_type = "team"; + payload.rate_limit.secondary_window.used_percent = 100; + const report = await openaiCodexUsageProvider.fetchUsage( + { + provider: "openai-codex", + credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, + }, + { fetch: fakeFetch(payload) }, + ); + + const secondary = report?.limits.find(limit => limit.id === "openai-codex:secondary"); + expect(secondary?.amount.usedFraction).toBe(1); + expect(secondary?.status).toBe("warning"); + expect(report?.metadata).toMatchObject({ planType: "team", allowed: true, limitReached: false }); + }); + it("surfaces additional_rate_limits as spark UsageLimit entries the widget can detect", async () => { const report = await openaiCodexUsageProvider.fetchUsage( { diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 6f25cad55..15e5b9bab 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; -import { isUsageLimit } from "@oh-my-pi/pi-ai/error/flags"; +import { classify, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/error/flags"; import { calculateRateLimitBackoffMs, + isConcurrencyCapExclusion, isUsageLimitOutcome, isUsageLimitStatus, parseRateLimitReason, @@ -58,6 +59,30 @@ describe("parseRateLimitReason", () => { expect(parseRateLimitReason("Requests per minute limit reached")).toBe("RATE_LIMIT_EXCEEDED"); }); + it("classifies concurrent request caps separately from rate limits and quota exhaustion", () => { + expect(parseRateLimitReason("Number of concurrent requests exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Maximum concurrent invocation limit reached")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrent_limit_exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrent_requests_limit_reached")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrency_quota_exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Too many concurrent requests")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Too many concurrent invocations")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Rate limit reached for gpt-4o")).toBe("RATE_LIMIT_EXCEEDED"); + expect(parseRateLimitReason("Your quota will reset at 07-28")).toBe("QUOTA_EXHAUSTED"); + }); + + // Deterministic 4xx feature rejections worded with bare concurrency nouns + // ("concurrent request/invocation is not supported") must not classify as a + // concurrency cap — doing so would set Flag.Transient and retry the rejection + // instead of surfacing it. A cap needs an explicit limit/quota/exceeded/reached + // signal near "concurrent". + it("does not classify bare concurrency feature rejections as CONCURRENT_LIMIT", () => { + expect(parseRateLimitReason("Concurrent invocation is not supported")).not.toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Only one concurrent request is supported")).not.toBe("CONCURRENT_LIMIT"); + // The deterministic rejection must surface as a hard error, not be retried. + expect(is(classify("Concurrent invocation is not supported"), Flag.Transient)).toBe(false); + }); + it("classifies overloaded 529 as MODEL_CAPACITY_EXHAUSTED", () => { expect(parseRateLimitReason("Service overloaded 529")).toBe("MODEL_CAPACITY_EXHAUSTED"); }); @@ -266,6 +291,30 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(429, message)).toBe(true); }); + it("rotates only account-scoped cap 403s and statusless trailers", () => { + const devinTrailer = + "Devin stream error permission_denied: Reached overall message rate limit. Please try again later. Your limit will reset in 13 minutes."; + // HTTP 403 with the account-scoped body rotates. + expect(isUsageLimitOutcome(403, devinTrailer)).toBe(true); + // Devin's Connect trailer carries no HTTP status (a permission_denied + // ValidationError), so it must rotate on an undefined status too — + // otherwise the exhausted credential is retried as a transient failure. + expect(isUsageLimitOutcome(undefined, devinTrailer)).toBe(true); + expect(isUsageLimit(devinTrailer)).toBe(true); + expect(isUsageLimitOutcome(403, "Forbidden")).toBe(false); + }); + + // A statusless per-minute reset-window transient ("Rate limit will reset in + // 30 seconds") is ordinary throttling (RATE_LIMIT_EXCEEDED), not an account + // usage cap. The reset-window alternative is gated on account scope so it stays + // in the backoff lane instead of rotating the credential. + it("does not rotate on a statusless per-minute reset-window transient", () => { + const message = "Rate limit will reset in 30 seconds"; + expect(parseRateLimitReason(message)).toBe("RATE_LIMIT_EXCEEDED"); + expect(isUsageLimitOutcome(undefined, message)).toBe(false); + expect(isUsageLimit(message)).toBe(false); + }); + it("rotates on xAI Grok Build 402 usage-balance exhaustion regardless of status", () => { const message = "402 Grok Build usage balance exhausted"; expect(isUsageLimitOutcome(402, message)).toBe(true); @@ -285,6 +334,59 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(401, "Invalid API key")).toBe(false); expect(isUsageLimitOutcome(400, "invalid_request_error: model unsupported")).toBe(false); }); + + // Vertex returns "Online prediction concurrent requests quota exceeded" for a + // concurrent-request cap. The generic USAGE_LIMIT_PATTERN matches + // `quota.?exceeded`, but this is a concurrency cap (5s backoff, no rotation), + // not account quota exhaustion. CONCURRENT_LIMIT must take precedence so the + // credential is not burned. + it("does not rotate on Vertex quota-worded concurrency caps", () => { + const message = "Online prediction concurrent requests quota exceeded"; + expect(parseRateLimitReason(message)).toBe("CONCURRENT_LIMIT"); + expect(isUsageLimitOutcome(429, message)).toBe(false); + expect(isUsageLimit(message)).toBe(false); + }); + + it("excludes non-billing concurrency caps from credential rotation", () => { + const message = "concurrent requests limit reached"; + expect(isConcurrencyCapExclusion(403, message)).toBe(true); + expect(isConcurrencyCapExclusion(undefined, message)).toBe(true); + expect(isConcurrencyCapExclusion(402, message)).toBe(false); + expect(isConcurrencyCapExclusion(403, "Forbidden")).toBe(false); + const classified = classify(new ProviderHttpError(message, 403)); + expect(is(classified, Flag.AuthFailed)).toBe(false); + expect(is(classified, Flag.Transient)).toBe(true); + }); + + // The same bare concurrency wording can reach turn recovery without a + // preserved HTTP status (Vertex/Bedrock paths that bypass API-key + // resolution). The body misses TRANSIENT_TRANSPORT_PATTERN, so without an + // explicit Flag.Transient the temporary cap classifies as terminal and is + // never retried. It must stay shed-and-backoff (transient/retriable). + it("keeps statusless concurrency caps transient and retriable", () => { + const message = "Online prediction concurrent requests quota exceeded"; + const id = classify(message); + expect(is(id, Flag.Transient)).toBe(true); + expect(retriable(id)).toBe(true); + }); + + // HTTP 402 is categorically an account-billing cap, so a 402 whose body is + // worded as a concurrency cap still rotates — the billing-cap status wins + // over the concurrency exclusion. The identical concurrency wording on a + // quota-worded 429 stays non-rotatable (5s backoff). This pins the + // 402-billing-cap > concurrency-exclusion precedence in both the rotation + // decision (isUsageLimitOutcome) and the Flag.UsageLimit classification + // (isUsageLimit). + it("rotates on 402 concurrency-worded billing caps but not 429 concurrency caps", () => { + const message = "concurrent requests limit reached"; + expect(parseRateLimitReason(message)).toBe("CONCURRENT_LIMIT"); + // 402 billing cap wins: rotate. + expect(isUsageLimitOutcome(402, message)).toBe(true); + expect(isUsageLimit(Object.assign(new Error(message), { status: 402 }))).toBe(true); + // 429 concurrency cap: shed-and-backoff, do not rotate. + expect(isUsageLimitOutcome(429, message)).toBe(false); + expect(isUsageLimit(Object.assign(new Error(message), { status: 429 }))).toBe(false); + }); }); describe("calculateRateLimitBackoffMs", () => { @@ -295,4 +397,8 @@ describe("calculateRateLimitBackoffMs", () => { expect(ms).toBeLessThanOrEqual(75_000); } }); + + it("returns a short backoff for CONCURRENT_LIMIT", () => { + expect(calculateRateLimitBackoffMs("CONCURRENT_LIMIT")).toBe(5_000); + }); }); diff --git a/packages/ai/test/stream-auth-retry.test.ts b/packages/ai/test/stream-auth-retry.test.ts index 83e06c66d..90e782f35 100644 --- a/packages/ai/test/stream-auth-retry.test.ts +++ b/packages/ai/test/stream-auth-retry.test.ts @@ -120,6 +120,41 @@ describe("streamSimple resolver auth retry", () => { expect((contexts[1]!.error as { status?: number }).status).toBe(401); }); + it("surfaces a 403 concurrency cap for transient backoff without rotating credentials", async () => { + const keys: unknown[] = []; + const contexts: ApiKeyResolveContext[] = []; + const concurrencyCap = Object.assign(new Error("concurrent requests limit reached"), { status: 403 }); + registerCustomApi( + API, + (_model: Model, _context: Context, options?: SimpleStreamOptions) => { + pushKey(keys, options); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => stream.fail(concurrencyCap)); + return stream; + }, + SOURCE_ID, + ); + + const stream = streamSimple(model(), context, { + apiKey: async ctx => { + contexts.push(ctx); + return ctx.error === undefined ? "old-key" : ctx.lastChance ? "sibling-key" : "refresh-key"; + }, + }); + await expect( + (async () => { + for await (const _event of stream) { + // drain + } + })(), + ).rejects.toBe(concurrencyCap); + + expect(keys).toEqual(["old-key"]); + expect(contexts.map(ctx => ({ lastChance: ctx.lastChance, hasError: ctx.error !== undefined }))).toEqual([ + { lastChance: false, hasError: false }, + ]); + }); + it("buffers the start event and retries on a 401 error event before content", async () => { const keys: unknown[] = []; const eventTypes: string[] = []; diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index cab9afb53..cc674c288 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Fixed + +- Fixed Amazon Bedrock catalog generation omitting AWS GovCloud `us-gov.*` Claude inference-profile IDs, so selectors like `amazon-bedrock/us-gov.anthropic.claude-sonnet-4-5-…` resolve instead of failing model lookup (or misrouting commercial `us.*` geos onto `us-east-1` with GovCloud credentials). + ## [17.2.7] - 2026-08-03 ### Fixed diff --git a/packages/catalog/package.json b/packages/catalog/package.json index 037a0131f..7af1dbb78 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.2.8", + "version": "17.2.9", "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/src/models.json b/packages/catalog/src/models.json index 07a28ec00..99910f606 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -10488,6 +10488,330 @@ "contextWindow": 262000, "maxTokens": 262000 }, + "us-gov.anthropic.claude-fable-5": { + "id": "us-gov.anthropic.claude-fable-5", + "name": "Claude Fable 5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-haiku-4-5-20251001-v1:0": { + "id": "us-gov.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "Claude Haiku 4.5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.1, + "cacheWrite": 1.25 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-opus-4-1-20250805-v1:0": { + "id": "us-gov.anthropic.claude-opus-4-1-20250805-v1:0", + "name": "Claude Opus 4.1 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 15, + "output": 75, + "cacheRead": 1.5, + "cacheWrite": 18.75 + }, + "contextWindow": 200000, + "maxTokens": 32000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-opus-4-5-20251101-v1:0": { + "id": "us-gov.anthropic.claude-opus-4-5-20251101-v1:0", + "name": "Claude Opus 4.5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "thinking": { + "mode": "anthropic-budget-effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-opus-4-6-v1": { + "id": "us-gov.anthropic.claude-opus-4-6-v1", + "name": "Claude Opus 4.6 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ] + } + }, + "us-gov.anthropic.claude-opus-4-7": { + "id": "us-gov.anthropic.claude-opus-4-7", + "name": "Claude Opus 4.7 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-opus-4-8": { + "id": "us-gov.anthropic.claude-opus-4-8", + "name": "Claude Opus 4.8 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-opus-5": { + "id": "us-gov.anthropic.claude-opus-5", + "name": "Claude Opus 5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "id": "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "Claude Sonnet 4.5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-sonnet-4-6": { + "id": "us-gov.anthropic.claude-sonnet-4-6", + "name": "Claude Sonnet 4.6 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 1000000, + "maxTokens": 64000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-sonnet-5": { + "id": "us-gov.anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 2.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, "us.amazon.nova-lite-v1:0": { "id": "us.amazon.nova-lite-v1:0", "name": "Nova Lite", @@ -105772,4 +106096,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 9153c208b..d6a948bda 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -5608,14 +5608,25 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK: readonly ModelsDevProviderDescrip id: crossRegionId, name: toModelName(m.name, crossRegionId), }; - // Also emit EU variants for Claude models + // Also emit EU and AWS GovCloud (`us-gov.`) geo inference-profile + // variants for Claude models. GovCloud accounts list system profiles + // under the `us-gov.` prefix (e.g. us-gov.anthropic.claude-sonnet-4-5-…); + // without these rows the catalog only has commercial geos (`us.`/`eu.`/…) + // and model resolution rejects the GovCloud id (or misroutes commercial + // geos onto us-east-1 with GovCloud credentials → 403). if (modelId.startsWith("anthropic.claude-")) { + const displayName = toModelName(m.name, modelId); return [ bedrockModel, { ...bedrockModel, id: `eu.${modelId}`, - name: `${toModelName(m.name, modelId)} (EU)`, + name: `${displayName} (EU)`, + }, + { + ...bedrockModel, + id: `us-gov.${modelId}`, + name: `${displayName} (GovCloud)`, }, ]; } diff --git a/packages/catalog/test/amazon-bedrock-opus-5.test.ts b/packages/catalog/test/amazon-bedrock-opus-5.test.ts index d1e54e6ca..4b4d9591e 100644 --- a/packages/catalog/test/amazon-bedrock-opus-5.test.ts +++ b/packages/catalog/test/amazon-bedrock-opus-5.test.ts @@ -4,18 +4,23 @@ import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; import { dropUnsupportedBedrockGeoIds } from "../scripts/generated-policies"; -// AWS's Bedrock model card for Claude Opus 5 lists exactly these Programmatic -// Access IDs — the bare model ID plus the us./eu./au. Geo and global. -// inference profiles. Japan is explicitly marked unsupported for Geo +// AWS's Bedrock model card for Claude Opus 5 lists these commercial/geo +// Programmatic Access IDs — the bare model ID plus the us./eu./au. Geo and +// global inference profiles. Japan is explicitly marked unsupported for Geo // inference in the same card's regional-availability table, so no `jp.` // profile exists for this model (unlike several Opus 4.x generations). // https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-5.html +// +// The catalog also synthesizes `us-gov.*` Claude geo profiles for AWS GovCloud +// (same path as the derived `eu.*` row) so GovCloud selectors resolve without +// requiring a full inference-profile ARN. const AWS_DOCUMENTED_OPUS_5_IDS = [ "anthropic.claude-opus-5", "us.anthropic.claude-opus-5", "eu.anthropic.claude-opus-5", "au.anthropic.claude-opus-5", "global.anthropic.claude-opus-5", + "us-gov.anthropic.claude-opus-5", ]; // A representative `stencil.so` "amazon-bedrock" payload for Claude Opus 5. @@ -78,10 +83,10 @@ describe("Amazon Bedrock Claude Opus 5", () => { ); const opus5Ids = dropUnsupportedBedrockGeoIds(mapped).map(model => model.id); - // Set semantics: the descriptor also derives an `eu.` variant from the - // bare `anthropic.` row, so `eu.` legitimately arrives from both that - // derivation and the standalone stencil.so row (deduped downstream by - // the generator). We assert the documented ID coverage, not row count. + // Set semantics: the descriptor also derives `eu.` and `us-gov.` variants + // from the bare `anthropic.` row, so `eu.` legitimately arrives from both + // that derivation and the standalone stencil.so row (deduped downstream + // by the generator). We assert the documented ID coverage, not row count. expect(new Set(opus5Ids)).toEqual(new Set(AWS_DOCUMENTED_OPUS_5_IDS)); // `stencil.so` lists `jp.anthropic.claude-opus-5`, but Bedrock has no such // inference profile for this model and would reject it, so the generation diff --git a/packages/catalog/test/amazon-bedrock-us-gov.test.ts b/packages/catalog/test/amazon-bedrock-us-gov.test.ts new file mode 100644 index 000000000..7904fe56d --- /dev/null +++ b/packages/catalog/test/amazon-bedrock-us-gov.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi/pi-catalog/provider-models"; + +/** + * Contract: bare Anthropic Claude foundation rows from models.dev/stencil.so + * must produce a `us-gov.` Bedrock inference-profile selector. + * GovCloud accounts expose system profiles under that geo prefix; without it, + * `omp --model amazon-bedrock/us-gov.…` fails model resolution even though + * AWS CLI and ARN-based selectors work. + */ +const CLAUDE_FOUNDATION_ID = "anthropic.claude-sonnet-4-5-20250929-v1:0"; + +const BEDROCK_CLAUDE_FIXTURE = { + "amazon-bedrock": { + models: { + [CLAUDE_FOUNDATION_ID]: { + name: "Claude Sonnet 4.5", + tool_call: true, + reasoning: true, + limit: { context: 200_000, output: 64_000 }, + cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + modalities: { input: ["text", "image"] }, + }, + // Non-Claude Bedrock model must not get a us-gov sibling from the Claude transform. + "amazon.nova-pro-v1:0": { + name: "Nova Pro", + tool_call: true, + reasoning: false, + limit: { context: 300_000, output: 10_000 }, + cost: { input: 0.8, output: 3.2, cache_read: 0, cache_write: 0 }, + modalities: { input: ["text", "image"] }, + }, + }, + }, +}; + +describe("Amazon Bedrock GovCloud (us-gov) catalog mapping", () => { + test("bare Claude foundation ids emit a us-gov geo inference-profile selector", () => { + const mapped = mapModelsDevToModels(BEDROCK_CLAUDE_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "amazon-bedrock", + ); + const ids = mapped.map(model => model.id); + + expect(ids).toContain(`us-gov.${CLAUDE_FOUNDATION_ID}`); + expect(ids).toContain(`eu.${CLAUDE_FOUNDATION_ID}`); + + const gov = mapped.find(model => model.id === `us-gov.${CLAUDE_FOUNDATION_ID}`); + expect(gov).toBeDefined(); + expect(gov?.api).toBe("bedrock-converse-stream"); + expect(gov?.name).toContain("GovCloud"); + }); + + test("non-Claude Bedrock models do not receive synthesized us-gov variants", () => { + const mapped = mapModelsDevToModels(BEDROCK_CLAUDE_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "amazon-bedrock", + ); + const ids = mapped.map(model => model.id); + + expect(ids.some(id => id.startsWith("us-gov.amazon."))).toBe(false); + expect(ids).not.toContain("us-gov.amazon.nova-pro-v1:0"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ae1e200eb..7e03985ce 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,57 @@ - Fixed an advisor refusal skipping the model fallback chain. `AdvisorRuntime` treated a classifier refusal as terminal once its one stripped-reasoning resend failed, returning before the `onTurnError` hook that owns fallback (`#recoverAdvisorTurn`), so a `Refusal (cyber)` on one model disabled the advisor even with a configured chain. The refusal path now takes the same fallback pass the primary turn-recovery path already allows, and only reports the advisor unavailable when the host declines to switch. - Bounded advisor refusal recovery to one attempt per model. The cascade walks the fallback chain to exhaustion, but a model switch re-arms `#includeThinking` via `#syncModelIdentity`, so a chain whose keys point back at each other (A→B, B→A) would strip-and-resend against the same pair forever. Each cascade now visits a model at most once; a successful turn or a reset starts a fresh walk. - Fixed `/advisor status` throwing when the roster is empty but an advisor is live. `formatAdvisorStatus` dereferenced `stats.advisors[0]` after a guard that only covered the inactive case, so a status call landing in the window where `#advisorStatuses` is cleared for a rebuild hit `undefined.contextWindow`. Reporting status now never throws. +## [17.2.9] - 2026-08-05 + +### Breaking Changes + +- Renamed `compareVersions` to `compareChangelogEntries` in `@oh-my-pi/pi-coding-agent/utils/changelog`. The function signature and behavior are unchanged; update imports to use the new name. + +### Added + +- Added automatic detection of common Ungoogled Chromium Linux installations for the browser tool. + +### Changed + +- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, restored task/timestamp metadata for historical agents, and consistent keyboard and mouse navigation. +- Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)). +- Routed Bun install-cache pruning in `update-cli` through the shared `compareVersions` utility (`@oh-my-pi/pi-utils`), removing a duplicate local comparator that rounded large numeric version identifiers via `Number`. + +### Fixed + +- Retried concurrent-request caps with a short backoff without deleting valid Copilot credentials or rotating through sibling accounts. +- Fixed the default `textVerbosity` setting being forwarded to OpenAI Codex requests unless the user explicitly configures it, preserving Codex's native response-control defaults. ([#4949](https://github.com/can1357/oh-my-pi/issues/4949)) +- Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. Subscriber dispatch is serialized so a rapid stream tail (`message_update` → `message_end` → `agent_end`) cannot overtake the coalesced flush. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) +- Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json`. These importers now also load project entries before same-named user entries (matching native/Codex) so a project `enabled: false` suppresses a same-named user server ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). +- Removed the per-call `model` override from the eval `agent()` helper (all runtimes), completing the earlier task-tool removal (`9f8aa87dbf`). Subagents always use their selected agent's frontmatter model and settings; a legacy `model` argument is silently ignored, so an explicit `model: "default"` can no longer route children onto the parent session model ([#6438](https://github.com/can1357/oh-my-pi/issues/6438)). +- Fixed legacy Pi extension validation rejecting plugins such as `remote-pi` that import the package-root `convertToPng` image helper. ([#7610](https://github.com/can1357/oh-my-pi/issues/7610)) +- Fixed the legacy session-directory migration silently deleting a live session's transcript when its filename collided with an existing entry in the destination: colliding entries are now preserved in place, the legacy directory is only removed when empty, and collisions/migration failures are logged ([#7593](https://github.com/can1357/oh-my-pi/issues/7593)). +- Fixed `PUPPETEER_EXECUTABLE_PATH` being ignored when a system Chrome installation was detected, preventing Windows users from selecting a compatible headless browser for the shared browser daemon ([#7601](https://github.com/can1357/oh-my-pi/issues/7601)). +- Fixed `openai-models-list` discovery ignoring server-advertised input modalities, so custom virtual tier IDs absent from the bundled catalog showed `images: no` even when the `/v1/models` response reported `input: ["text","image"]` ([#7583](https://github.com/can1357/oh-my-pi/issues/7583)). +- Exposed exact source line counts in read results when selector-based reads reach EOF, allowing protocol bridges to distinguish a returned slice from the complete file ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). +- Fixed `grep`/`glob` silently collapsing a semicolon-delimited `path` list to one literal path when the joined string was too long for the OS to name (`ENAMETOOLONG`) — a list of bare filenames past `NAME_MAX` or absolute paths past `PATH_MAX` failed with `Path not found: ` even though every entry existed. The multipath probe now treats `ENAMETOOLONG` as a definitively non-existent single path so the split proceeds, and `glob` surfaces a clean `Path not found` instead of leaking the raw errno ([#7597](https://github.com/can1357/oh-my-pi/issues/7597)). +- Fixed `--mode json` (and text) print mode truncating a large final record (e.g. a multi-MB `agent_end`) when the process exited before stdout drained, while still exiting 0. Per-event writes are now serialized on their own completion callbacks and shutdown blocks on the last one, so the terminal record is delivered in full ([#7635](https://github.com/can1357/oh-my-pi/issues/7635)). +- Fixed text print mode treating buffered partial responses as replay-unsafe, allowing transient mid-stream connection failures to retry without exposing duplicated output ([#7625](https://github.com/can1357/oh-my-pi/issues/7625)). +- Fixed Hindsight `autoRecall` intermittently not reaching the model: two recall paths shared the `hasRecalledForFirstTurn` flag, and the `agent_start` event path could consume it first and inject only via an unawaited background prompt rebuild that a fast turn outran. `beforeAgentStartPrompt` (awaited before the turn builds) is now the sole injection path ([#7568](https://github.com/can1357/oh-my-pi/issues/7568)). +- Fixed `read memory://` returning a confusing "Unknown memory namespace" error under `memory.backend=hindsight` (Hindsight stores memories server-side and has no `memory://` addressing); the handler now returns a corrective pointer to `recall`/`reflect` so a stray read — steered by the shared `recall` tool description — self-corrects in one turn ([#7587](https://github.com/can1357/oh-my-pi/issues/7587)). +- Fixed extension/custom/hook tool wrappers stripping schema methods off `parameters`: `applyToolProxy` bound every callable property, and binding a schema (a plain function carrying `toJsonSchema`/`assert`) dropped those properties, breaking wire-schema detection and crashing the status-line token estimator with `JSON.stringify(schema) === undefined`. Prototype methods are still bound; own data properties and schema callables now pass through untouched. +- Fixed bug where `agent()` calls in eval cells ignored turn cancellation and continued running indefinitely +- Fixed the built-in `tail` printing `tail: Broken pipe` and failing when a downstream pipeline reader exited early (e.g. `tail -c N file.jsonl | jq …` with jq aborting on a parse error); it now exits silently with 141 (128+SIGPIPE) like a real tail, in every output path including `--follow`. +- Fixed the in-process ps shell builtin rejecting common procps/BSD format specifiers (`ps -o tpgid,...` failed with `unknown output format specifier`); added `tpgid`, `pri`, `flags`, real/effective user and group columns, `wchan`, fault counters, `sz`, and the STAT `+` foreground flag. +- Fixed Herdr rejecting the macOS development launcher because its foreground process was reported as `bun` instead of `omp`. +- Completed usage-aware model fallback across startup, queued turns, same-turn tool continuations, ACP/TUI confirmation cancellation, eligible account reselection, cooldown restoration, and isolated subagent settings so low-usage handoffs remain lossless and cannot consume cancelled queued work. +- Fixed Agent Hub opening and selection becoming O(all rows) on large rosters: row rendering is now lazy around the selected viewport, and observer lookup is O(1) by id instead of copy-sorting every session per row. +- Fixed persisted Agent Hub rows dropping an explicit caller model role when a subagent used a model override, preserving role provenance after restart. +- Fixed the bash interceptor blocking `grep`/`cat`/`find` used as a downstream pipeline stage (e.g. `printf 'x\n' | grep x`); a stage consuming piped stdin cannot be replaced by a path-based dedicated tool, so it is no longer matched, while standalone and first-stage searches stay intercepted ([#7496](https://github.com/can1357/oh-my-pi/issues/7496)). +- Fixed floating rejections from cmux browser guest JavaScript terminating the main process and every active session; attributable rejections now fail the browser run as tool errors while unrelated process rejections retain the fatal path ([#7365](https://github.com/can1357/oh-my-pi/issues/7365)). +- Fixed the Windows bash tool silently taking down the whole omp process when a command blocked until its timeout: cancelling a timed-out run walked the spawned child's descendant tree from raw `th32ParentProcessID` links, and a recycled pid matching the harness's stale recorded parent pid could enumerate omp as a false descendant and `TerminateProcess` it, killing the session with no `session_exit` record. Run-cancellation sweeps now refuse to signal the harness or any process collected beneath it, while still reaping the timed-out target when it owns a recycled ancestor pid ([#7452](https://github.com/can1357/oh-my-pi/issues/7452)). +- Fixed the unexpected-stop guard (`features.unexpectedStopDetection`) never firing for thinking-only stops: `isUnexpectedStopCandidate` only counted non-whitespace `text` blocks, so a `stopReason: "stop"` turn whose sole content was a signed `thinking` block (a trapped response or a truncated reasoning fragment from reasoning models) bypassed classification and silently ended the turn mid-task. Such stops are now candidates and are classified on their thinking text ([#7499](https://github.com/can1357/oh-my-pi/issues/7499)). +- Fixed Task cancellation hanging forever when a child ignored abort or stalled during cleanup ([#7483](https://github.com/can1357/oh-my-pi/issues/7483)). +- Fixed LSP diagnostics being dropped when servers normalize file URI percent-encoding or Windows path casing. +- Fixed WSL sessions missing Agent Skills stored in the Windows host profile's `.agents/skills` directory. ([#3779](https://github.com/can1357/oh-my-pi/issues/3779)) +- Fixed `omp setup python` to validate the same configured or discovered interpreter used by the Python eval runtime. +- Fixed self-update misclassifying glibc Linux hosts with an installed musl loader as musl hosts, which could download an unusable musl binary instead of the glibc release. +- Fixed a crash where opening the Agent Hub after a resume and moving the selection triggered an unbounded `ExtensionExitError` unhandled-rejection storm and exit 129. The postmortem module bound the native hard-exit at first evaluation; when the bundler deferred that evaluation into a `withHostGuard` window it froze the guard's throwing replacement, poisoning every later signal/fatal exit. The native exit is now resolved per call, and the guard stamps its replacement with the native primitive it shadows so mid-guard signals still exit ([#7393](https://github.com/can1357/oh-my-pi/issues/7393)). ## [17.2.8] - 2026-08-04 @@ -19,6 +70,7 @@ ### Changed - Replaced arktype with @oh-my-pi/omptype for tool parameter and config schemas, significantly improving startup performance with ~100x faster schema construction. Config schema errors are now reported via OmpErrors using the same path/problem structure. +- Replaced arktype with `@oh-my-pi/omptype` across all tool parameter and config schemas: ~100x faster schema construction removes the arktype startup tax (the `scope({}, { jitless: true })` workarounds are gone). Config schema errors now report via `OmpErrors` entries with the same `path`/`problem` shape. ### Fixed @@ -98,6 +150,13 @@ - Fixed heavily branched conversation trees shifting linear continuations into disconnected columns. - Fixed plugin installation validation failures for legacy compatibility shims. - Removed hard-coded references to disabled or absent agents in system and tool prompts. +### Added + +- Added resumable session details to fatal crash output, including an `omp --resume ` command for every persisted live agent session. + +### Fixed + +- Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation, and late user continuation errors are logged instead of dropped after the run ends. ## [17.2.4] - 2026-08-01 @@ -5361,7 +5420,7 @@ - Fixed command-fixup notices to list all stripped segments instead of reporting only one - Fixed summarized `read` output stalling agents on elided regions by appending an explicit footer like `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`. The footer fires whenever the structural summarizer elided at least one span, so the model gets a concrete recovery selector instead of having to guess from a bare `...` / `{ .. }` marker. Surfaces `elidedLines` on `ReadToolDetails.summary` alongside the existing `elidedSpans`. ([#1046](https://github.com/can1357/oh-my-pi/issues/1046)) - Updated the `read` tool prompt to describe the new elision footer and instruct the model to follow `:raw` (or an explicit line range) when the elided body is actually needed, rather than guessing. -- Fixed plugin extensions failing to load when their `peerDependencies` reference internal `pi-*` packages under any scope other than `@mariozechner` (e.g. `Cannot find module '@earendil-works/pi-tui'` from `@juicesharp/rpiv-ask-user-question`, or `Cannot find module '@oh-my-pi/pi-utils'` from `@oh-my-pi/swarm-extension`). The legacy-pi specifier shim now treats `@mariozechner`, `@earendil-works`, **and** the canonical `@oh-my-pi` itself as aliases for the same set of bundled in-process packages (`pi-agent-core`, `pi-ai`, `pi-coding-agent`, `pi-natives`, `pi-tui`, `pi-utils`), and additionally rewrites the upstream-only `pi-ai/oauth` subpath onto our `pi-ai/utils/oauth` layout. Restored the `Key` runtime helper export on `@oh-my-pi/pi-tui` to match upstream — plugins using `Key.enter` / `Key.ctrl("c")` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) no longer fail with `Export named 'Key' not found`. End-to-end verified against `@juicesharp/rpiv-ask-user-question`, `@oh-my-pi/swarm-extension`, and `@plannotator/pi-extension` — each now loads cleanly with all of its tools/commands/handlers registered. Plugins importing any of those scopes are remapped to the omp binary's own copy at load time, so peer deps are no longer dragged in from npm and there is exactly one module instance per package regardless of which scope name the plugin's manifest happened to declare. +- Fixed plugin extensions failing to load when their `peerDependencies` reference internal `pi-*` packages under any scope other than `@mariozechner` (e.g. `Cannot find module '@earendil-works/pi-tui'` from `@juicesharp/rpiv-ask-user-question`). The legacy-pi specifier shim now treats `@mariozechner`, `@earendil-works`, **and** the canonical `@oh-my-pi` itself as aliases for the same set of bundled in-process packages (`pi-agent-core`, `pi-ai`, `pi-coding-agent`, `pi-natives`, `pi-tui`, `pi-utils`), and additionally rewrites the upstream-only `pi-ai/oauth` subpath onto our `pi-ai/utils/oauth` layout. Restored the `Key` runtime helper export on `@oh-my-pi/pi-tui` to match upstream … - Fixed `omp commit` hanging after a successful commit instead of returning to the shell. The command now mirrors the `runPrintMode` exit pattern and calls `postmortem.quit(0)` once the pipeline resolves so lingering HTTP/2 keep-alive sockets, the Settings autosave timer, and other AgentSession background handles don't keep the event loop pinned. ([#1041](https://github.com/can1357/oh-my-pi/issues/1041)) - Fixed hashline payload parsing to silently treat truly-blank lines as empty `~`-prefixed payload lines when more payload follows in the same run. The previous behavior broke at the blank ("payload line has no preceding +, <, or = operation.") even though the intent is obvious — the only ambiguity is between in-payload blanks and end-of-section blanks, and a one-line lookahead resolves it: blanks that precede a non-payload op still end the run cleanly as section separators. Recovers the common case of forgetting the leading separator on a blank inserted line without changing how trailing blanks between ops behave. - Rewrote the hashline edit prompt examples to use an ASCII-only `TITLE = "Mr"` → `"Mrs"` / `"Dr"` motif instead of the previous `" • "` and `"·"` separators. Some agents had been copying the middle-dot literal characters into real edits as if they were format scaffolding (e.g. emitting payload lines like `~ ·`), since the demo inserts were near-twins of the existing string. The new example keeps every original op shape (single-line replace, multiline replace, insert AFTER/BEFORE, append, delete, blank, plus both anti-patterns) but uses content that is obviously domain-specific and clearly distinct from any payload separator. Pure prompt change; no parser, schema, or runtime behavior is affected. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 03ac0fabc..e7ef42261 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.2.8", + "version": "17.2.9", "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/scripts/omp b/packages/coding-agent/scripts/omp index ef7a82b96..82721c719 100755 --- a/packages/coding-agent/scripts/omp +++ b/packages/coding-agent/scripts/omp @@ -36,7 +36,16 @@ mkdir -p "$launch_dir" OMP_LAUNCH_CWD=$PWD export OMP_LAUNCH_CWD cd "$launch_dir" +# Herdr 0.7.5 validates the foreground process name. The macOS shell can +# preserve OMP's identity while still running the development CLI through Bun. +run_bun() { + if [ "${HERDR_ENV:-}" = 1 ] && [ "$(uname -s)" = Darwin ]; then + exec -a omp bun "$@" + fi + exec bun "$@" +} + if [ -n "${PI_TIMING:-}" ]; then - exec bun --preload "$preload" --preload "$timing_preload" "$cli" "$@" + run_bun --preload "$preload" --preload "$timing_preload" "$cli" "$@" fi -exec bun --preload "$preload" "$cli" "$@" +run_bun --preload "$preload" "$cli" "$@" diff --git a/packages/coding-agent/src/async/job-manager.ts b/packages/coding-agent/src/async/job-manager.ts index 71d498c01..1fc9e6535 100644 --- a/packages/coding-agent/src/async/job-manager.ts +++ b/packages/coding-agent/src/async/job-manager.ts @@ -94,6 +94,12 @@ export interface AsyncJobDeliveryState { pendingJobIds: string[]; } +export interface AsyncJobReapResult { + settled: boolean; + pendingJobIds: string[]; + completion: Promise; +} + export interface AsyncJobRegisterOptions { id?: string; /** Registry id of the agent that owns this job; used to scope cancelAll. */ @@ -490,6 +496,26 @@ export class AsyncJobManager { } } + /** + * Cancel every job owned by `ownerId`, then wait only until `deadlineAt`. + * The returned completion keeps waiting for actual process settlement when + * the deadline expires, so callers can move that cleanup out of the + * user-visible Task wait without losing ownership of the live work. + */ + async cancelAndReapOwnerJobs(ownerId: string, deadlineAt: number): Promise { + this.cancelAll({ ownerId }); + const timeoutMs = Math.max(0, deadlineAt - Date.now()); + const settled = await this.waitForOwnerJobs(ownerId, { timeoutMs }); + if (settled) { + return { settled: true, pendingJobIds: [], completion: Promise.resolve() }; + } + const pendingJobIds = this.getAllJobs({ ownerId }) + .filter(job => job.status === "running" || job.status === "cancelled") + .map(job => job.id); + const completion = this.waitForOwnerJobs(ownerId).then(() => {}); + return { settled: false, pendingJobIds, completion }; + } + async #waitForAllUntil(deadline: number): Promise { const promises = Array.from(this.#jobs.values()).map(job => job.promise); if (promises.length === 0) return true; diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index 31177ab71..84279da3f 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -136,11 +136,11 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise { } // Start the dashboard server - const { port } = await startServer(cmd.port); - console.log(chalk.green(`Dashboard available at: http://localhost:${port}`)); + const { hostname, port } = await startServer(cmd.port); + const url = `http://${hostname}:${port}`; + console.log(chalk.green(`Dashboard available at: ${url}`)); // Open browser - const url = `http://localhost:${port}`; openPath(url); console.log("Press Ctrl+C to stop\n"); diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index f106e72d1..9cfc95413 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -10,7 +10,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { $env, $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; +import { $env, $which, APP_NAME, compareVersions, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; @@ -498,24 +498,6 @@ async function getLatestRelease(): Promise { }; } -/** - * Compare semver versions. Returns: - * - negative if a < b - * - 0 if a == b - * - positive if a > b - */ -function compareVersions(a: string, b: string): number { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const na = pa[i] || 0; - const nb = pb[i] || 0; - if (na !== nb) return na - nb; - } - return 0; -} - interface BunInstallCachePruneResult { scannedPackages: number; removedEntries: number; @@ -532,42 +514,6 @@ function stripBunCacheVersionSuffix(name: string): string { return metadataIndex === -1 ? name : name.slice(0, metadataIndex); } -function compareSemverIdentifier(a: string, b: string): number { - const aNumber = /^\d+$/.test(a); - const bNumber = /^\d+$/.test(b); - if (aNumber && bNumber) return Number(a) - Number(b); - if (aNumber) return -1; - if (bNumber) return 1; - return a.localeCompare(b); -} - -function compareSemverLikeVersions(a: string, b: string): number { - const [aCoreWithPrerelease] = a.split("+", 1); - const [bCoreWithPrerelease] = b.split("+", 1); - const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2); - const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2); - const aParts = aCore.split("."); - const bParts = bCore.split("."); - for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { - const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0); - if (diff !== 0 && Number.isFinite(diff)) return diff; - } - if (!aPrerelease && !bPrerelease) return 0; - if (!aPrerelease) return 1; - if (!bPrerelease) return -1; - const aPrereleaseParts = aPrerelease.split("."); - const bPrereleaseParts = bPrerelease.split("."); - for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) { - const aPart = aPrereleaseParts[i]; - const bPart = bPrereleaseParts[i]; - if (aPart === undefined) return -1; - if (bPart === undefined) return 1; - const diff = compareSemverIdentifier(aPart, bPart); - if (diff !== 0) return diff; - } - return 0; -} - async function readdirIfExists(dir: string): Promise { try { return await fs.promises.readdir(dir, { withFileTypes: true }); @@ -689,7 +635,7 @@ export async function pruneBunInstallCache( scannedPackages++; let latestVersion: string | undefined; for (const version of group.actualDirs.keys()) { - if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version; + if (!latestVersion || compareVersions(version, latestVersion) > 0) latestVersion = version; } if (!latestVersion) continue; for (const [version, paths] of group.actualDirs) { diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index d36916870..87a4cfe2d 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -109,11 +109,11 @@ export class ConfigError extends Error { this.#message = message; } - get message(): string { + override get message(): string { return this.#message; } - toString(): string { + override toString(): string { return this.message; } } diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 6836f1eda..6408cfad9 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -607,12 +607,12 @@ export class KeybindingsManager extends TuiKeybindingsManager { this.setUserBindings(mergeKeybindingsConfig(inheritedConfig, profileConfig)); } - setUserBindings(userBindings: KeybindingsConfig): void { + override setUserBindings(userBindings: KeybindingsConfig): void { this.#userBindings = userBindings; super.setUserBindings(userBindings); } - getKeys(keybinding: Keybinding): KeyId[] { + override getKeys(keybinding: Keybinding): KeyId[] { const keys = super.getKeys(keybinding); const fallbackKey = getFallbackKey(keybinding); if (fallbackKey === undefined || this.#userBindings[keybinding] !== undefined) return keys; @@ -620,7 +620,7 @@ export class KeybindingsManager extends TuiKeybindingsManager { return removeKey(keys, fallbackKey); } - getResolvedBindings(): KeybindingsConfig { + override getResolvedBindings(): KeybindingsConfig { const resolved = super.getResolvedBindings(); resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING)); return resolved; diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts index 7e30f0fd7..8ea4b99b0 100644 --- a/packages/coding-agent/src/config/model-discovery.ts +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -724,6 +724,31 @@ export async function discoverLlamaCppModelRuntimeMetadata( } } +/** + * Read image-input support from an OpenAI-compatible `/v1/models` row. Handles + * direct `input` arrays, Synthetic-style top-level `input_modalities`, and + * OpenRouter-style `architecture.input_modalities`; returns undefined when none + * is present so the bundled reference (or the `["text"]` default) can take over. + */ +function extractOpenAIModelsListInputCapabilities(item: { + input?: unknown; + input_modalities?: unknown; + architecture?: unknown; +}): ("text" | "image")[] | undefined { + const modalities = new Set(); + const collect = (value: unknown): void => { + if (!Array.isArray(value)) return; + for (const entry of value) { + if (typeof entry === "string") modalities.add(entry.toLowerCase()); + } + }; + collect(item.input); + collect(item.input_modalities); + if (isRecord(item.architecture)) collect(item.architecture.input_modalities); + if (modalities.size === 0) return undefined; + return modalities.has("image") ? ["text", "image"] : ["text"]; +} + export async function discoverOpenAIModelsList( providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext, @@ -752,7 +777,14 @@ export async function discoverOpenAIModelsList( } headers = h; return (await res.json()) as { - data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>; + data?: Array<{ + id?: string; + max_model_len?: unknown; + context_length?: unknown; + input?: unknown; + input_modalities?: unknown; + architecture?: unknown; + }>; }; }), nativeMetadataPromise, @@ -796,7 +828,9 @@ export async function discoverOpenAIModelsList( baseUrl, reasoning: reference?.reasoning ?? false, thinking: inheritReferenceThinking(undefined, reference, providerConfig.provider), - input: nativeMetadataForModel?.input ?? reference?.input ?? ["text"], + input: nativeMetadataForModel?.input ?? + extractOpenAIModelsListInputCapabilities(item) ?? + reference?.input ?? ["text"], ...(providerConfig.discovery.type === "lm-studio" ? { imageInputDecoder: "stb" as const } : {}), // Proxy/gateway pricing is provider-specific and rarely matches // upstream bundled catalogs, so keep costs local-unknown even diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 1662b2fed..45a87627b 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1715,7 +1715,10 @@ export class ModelRegistry { return resolveOllamaModelCacheProviderId(providerConfig.provider, providerConfig.baseUrl); } if (providerConfig.discovery.type === "openai-models-list") { - return `${providerConfig.provider}:openai-models-list-context-v2`; + // context-v3 invalidates rows cached before server-advertised input + // modalities were parsed from `/v1/models`; warm v2 rows pinned + // vision-capable ids at `input: ["text"]` until a forced refresh. + return `${providerConfig.provider}:openai-models-list-context-v3`; } if (providerConfig.discovery.type === "litellm") { // rich-v2 invalidates rows cached before reseller usage-suffix stripping diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 473fbd13c..8d3e01417 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -932,6 +932,28 @@ function normalizeModelPatternList(value: string | string[] | undefined): string return patterns.map(pattern => pattern.trim()).filter(Boolean); } +/** + * Extract the first explicit model-role alias from a raw model selection. + * + * This intentionally runs before role expansion so callers can retain the + * source identity (`@smol`, `pi/slow`, or `*`) even when it resolves to a + * concrete provider/model or inherited fallback. Bare role names and explicit + * provider/model selectors are not role aliases. + */ +export function resolveExplicitModelRole( + value: string | string[] | undefined, + settings?: ModelRoleLookup, +): string | undefined { + for (const pattern of normalizeModelPatternList(value)) { + const prefixLength = modelRoleAliasPrefixLength(pattern); + if (prefixLength === undefined) continue; + const { base } = splitThinkingSuffix(pattern, prefixLength, MAX_THINKING_SUFFIX_OPTIONS); + const role = getModelRoleAlias(base, settings); + if (role) return role; + } + return undefined; +} + function isSessionInheritedAgentPattern(value: string): boolean { return ( value === DEFAULT_MODEL_ROLE || @@ -1068,6 +1090,8 @@ export function resolveConfiguredModelPatterns( }); } export interface AgentModelPatternResolutionOptions { + /** Highest-priority request selector, when supplied by a caller. */ + requestModel?: string | string[]; settingsOverride?: string | string[]; agentModel?: string | string[]; settings?: Settings; @@ -1075,11 +1099,25 @@ export interface AgentModelPatternResolutionOptions { fallbackModelPattern?: string; } -export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOptions): string[] { - const { settingsOverride, agentModel, settings, activeModelPattern, fallbackModelPattern } = options; +interface EffectiveAgentModelSelection { + source?: string | string[]; + patterns: string[]; +} + +function resolveEffectiveAgentModelSelection( + options: AgentModelPatternResolutionOptions, +): EffectiveAgentModelSelection { + const { requestModel, settingsOverride, agentModel, settings, activeModelPattern, fallbackModelPattern } = options; + + const requestPatterns = resolveConfiguredModelPatterns(requestModel, settings); + if (requestPatterns.length > 0) { + return { source: requestModel, patterns: requestPatterns }; + } const overridePatterns = resolveConfiguredModelPatterns(settingsOverride, settings); - if (overridePatterns.length > 0) return overridePatterns; + if (overridePatterns.length > 0) { + return { source: settingsOverride, patterns: overridePatterns }; + } const normalizedAgentPatterns = normalizeModelPatternList(agentModel); const configuredAgentPatterns = resolveConfiguredModelPatterns(agentModel, settings); @@ -1090,14 +1128,23 @@ export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOp singleAgentPattern === formatModelRoleAlias("task") || singleAgentPattern === `${LEGACY_MODEL_ROLE_ALIAS_PREFIX}task` ) { - return configuredAgentPatterns; + return { source: agentModel, patterns: configuredAgentPatterns }; } - if (!agentInheritsSessionModel) return configuredAgentPatterns; + if (!agentInheritsSessionModel) return { source: agentModel, patterns: configuredAgentPatterns }; } const fallback = activeModelPattern?.trim() || fallbackModelPattern?.trim() || settings?.getModelRole("default")?.trim() || ""; - return resolveConfiguredModelPatterns(fallback, settings); + return { patterns: resolveConfiguredModelPatterns(fallback, settings) }; +} + +/** Return the raw selector source that supplies the effective agent patterns. */ +export function resolveAgentModelSource(options: AgentModelPatternResolutionOptions): string | string[] | undefined { + return resolveEffectiveAgentModelSelection(options).source; +} + +export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOptions): string[] { + return resolveEffectiveAgentModelSelection(options).patterns; } /** Default prewalk hand-off target when no explicit target is configured. */ export const DEFAULT_PREWALK_TARGET = "@smol"; diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index 4496b87c0..27d5f3880 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -28,9 +28,77 @@ const DISPLAY_NAME = "Agent Dirs (.agent/.agents)"; const PRIORITY = 70; const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const; -/** User-level paths: ~/.agent/ and ~/.agents/. */ -function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { - return AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments)); +interface UserPathCandidateOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + windowsUserProfile?: () => string | undefined; + wslPath?: (windowsPath: string) => string | undefined; +} + +const WINDOWS_DRIVE_PROFILE_PATTERN = /^([A-Za-z]):[\\/](.*)$/; + +function isWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean { + return platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP); +} + +function convertWindowsPathToDefaultWslMount(windowsPath: string): string | undefined { + const trimmed = windowsPath.trim(); + if (trimmed.length === 0) return undefined; + if (path.isAbsolute(trimmed)) return path.normalize(trimmed); + const match = WINDOWS_DRIVE_PROFILE_PATTERN.exec(trimmed); + if (!match) return undefined; + const [, drive, rest] = match; + const segments = rest.replace(/\\/g, "/").split("/").filter(Boolean); + return path.join("/mnt", drive.toLowerCase(), ...segments); +} + +function resolveWithWslPath(windowsPath: string): string | undefined { + try { + const result = Bun.spawnSync(["wslpath", "-u", windowsPath], { stdout: "pipe", stderr: "ignore" }); + if (result.exitCode !== 0) return undefined; + const resolved = result.stdout.toString().trim(); + return resolved.length > 0 ? resolved : undefined; + } catch { + return undefined; + } +} + +function resolveWindowsUserProfile(): string | undefined { + try { + const result = Bun.spawnSync(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"], { + stdout: "pipe", + stderr: "ignore", + }); + if (result.exitCode !== 0) return undefined; + const resolved = result.stdout.toString().trim(); + return resolved.length > 0 && resolved !== "%USERPROFILE%" ? resolved : undefined; + } catch { + return undefined; + } +} + +/** Resolve the Windows host profile home exposed to WSL, if available. */ +export function getWslWindowsHomeCandidate(options: UserPathCandidateOptions = {}): string | undefined { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + if (!isWsl(platform, env)) return undefined; + const userProfile = env.USERPROFILE ?? (options.windowsUserProfile ?? resolveWindowsUserProfile)(); + if (!userProfile) return undefined; + return (options.wslPath ?? resolveWithWslPath)(userProfile) ?? convertWindowsPathToDefaultWslMount(userProfile); +} + +function getUserHomeCandidates(ctx: LoadContext): string[] { + const homes = [ctx.home]; + const wslHome = getWslWindowsHomeCandidate(); + if (wslHome && !homes.includes(wslHome)) homes.push(wslHome); + return homes; +} + +/** User-level paths: ~/.agent[s]/, plus the Windows host profile under WSL. */ +export function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { + return getUserHomeCandidates(ctx).flatMap(home => + AGENT_DIR_CANDIDATES.map(baseDir => path.join(home, baseDir, ...segments)), + ); } /** diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index ab4637cf1..df7f16881 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -90,6 +90,7 @@ async function loadMCPServers(ctx: LoadContext): Promise> const serverConfig = config as Record; return { name, + enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined, timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined, command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, @@ -102,17 +103,19 @@ async function loadMCPServers(ctx: LoadContext): Promise> }); }; - for (let i = 0; i < userPaths.length; i++) { - const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level); + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). + const projectOffset = userPaths.length; + for (let i = 0; i < projectPaths.length; i++) { + const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level); if (servers.length > 0) { items.push(...servers); break; } } - const projectOffset = userPaths.length; - for (let i = 0; i < projectPaths.length; i++) { - const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level); + for (let i = 0; i < userPaths.length; i++) { + const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level); if (servers.length > 0) { items.push(...servers); break; diff --git a/packages/coding-agent/src/discovery/cursor.ts b/packages/coding-agent/src/discovery/cursor.ts index 2dcc23e1c..840f8d478 100644 --- a/packages/coding-agent/src/discovery/cursor.ts +++ b/packages/coding-agent/src/discovery/cursor.ts @@ -57,6 +57,7 @@ function parseMCPServers( const serverConfig = config as Record; items.push({ name, + enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined, command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, env: serverConfig.env as Record | undefined, @@ -86,15 +87,17 @@ async function loadMCPServers(ctx: LoadContext): Promise> const projectContentPromise = projectPath ? readFile(projectPath) : Promise.resolve(null); - if (userContent && userPath) { - const result = parseMCPServers(userContent, userPath, "user"); + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). + const projectContent = await projectContentPromise; + if (projectContent && projectPath) { + const result = parseMCPServers(projectContent, projectPath, "project"); items.push(...result.items); if (result.warning) warnings.push(result.warning); } - const projectContent = await projectContentPromise; - if (projectContent && projectPath) { - const result = parseMCPServers(projectContent, projectPath, "project"); + if (userContent && userPath) { + const result = parseMCPServers(userContent, userPath, "user"); items.push(...result.items); if (result.warning) warnings.push(result.warning); } diff --git a/packages/coding-agent/src/discovery/gemini.ts b/packages/coding-agent/src/discovery/gemini.ts index c7333ba20..dcb64ec24 100644 --- a/packages/coding-agent/src/discovery/gemini.ts +++ b/packages/coding-agent/src/discovery/gemini.ts @@ -48,14 +48,8 @@ async function loadMCPServers(ctx: LoadContext): Promise> const items: MCPServer[] = []; const warnings: string[] = []; - // User-level: ~/.gemini/settings.json → mcpServers - const userPath = getUserPath(ctx, "gemini", "settings.json"); - if (userPath) { - const result = await loadMCPFromSettings(ctx, userPath, "user"); - items.push(...result.items); - if (result.warnings) warnings.push(...result.warnings); - } - + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). // Project-level: .gemini/settings.json → mcpServers const projectPath = getProjectPath(ctx, "gemini", "settings.json"); if (projectPath) { @@ -64,6 +58,14 @@ async function loadMCPServers(ctx: LoadContext): Promise> if (result.warnings) warnings.push(...result.warnings); } + // User-level: ~/.gemini/settings.json → mcpServers + const userPath = getUserPath(ctx, "gemini", "settings.json"); + if (userPath) { + const result = await loadMCPFromSettings(ctx, userPath, "user"); + items.push(...result.items); + if (result.warnings) warnings.push(...result.warnings); + } + return { items, warnings }; } @@ -102,6 +104,7 @@ async function loadMCPFromSettings( items.push({ name, + enabled: typeof raw.enabled === "boolean" ? raw.enabled : undefined, command: typeof raw.command === "string" ? raw.command : undefined, args: Array.isArray(raw.args) ? (raw.args as string[]) : undefined, env: raw.env && typeof raw.env === "object" ? (raw.env as Record) : undefined, diff --git a/packages/coding-agent/src/discovery/vscode.ts b/packages/coding-agent/src/discovery/vscode.ts index 9239e953b..36116b3a1 100644 --- a/packages/coding-agent/src/discovery/vscode.ts +++ b/packages/coding-agent/src/discovery/vscode.ts @@ -83,6 +83,7 @@ async function loadMCPConfig( const server: MCPServer = { name, + enabled: typeof expanded.enabled === "boolean" ? expanded.enabled : undefined, command: typeof expanded.command === "string" ? expanded.command : undefined, args: Array.isArray(expanded.args) ? (expanded.args as string[]) : undefined, env: expanded.env && typeof expanded.env === "object" ? (expanded.env as Record) : undefined, diff --git a/packages/coding-agent/src/discovery/windsurf.ts b/packages/coding-agent/src/discovery/windsurf.ts index 0034467e1..2672cb0e6 100644 --- a/packages/coding-agent/src/discovery/windsurf.ts +++ b/packages/coding-agent/src/discovery/windsurf.ts @@ -48,6 +48,7 @@ function parseServerConfig( return { server: { name, + enabled: typeof server.enabled === "boolean" ? server.enabled : undefined, command: server.command as string | undefined, args: server.args as string[] | undefined, env: server.env as Record | undefined, @@ -71,10 +72,11 @@ async function loadMCPServers(ctx: LoadContext): Promise> ]); const projectContent = projectPath ? await readFile(projectPath) : null; - + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). const configs: Array<{ content: string | null; path: string | null; scope: "user" | "project" }> = [ - { content: userContent, path: userPath, scope: "user" }, { content: projectContent, path: projectPath, scope: "project" }, + { content: userContent, path: userPath, scope: "user" }, ]; for (const { content, path, scope } of configs) { diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index b8743be2e..10dbdd283 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -87,11 +87,11 @@ export class HashlineFilesystem extends Filesystem { return resolvePlanPath(this.session, relativePath); } - canonicalPath(relativePath: string): string { + override canonicalPath(relativePath: string): string { return canonicalSnapshotKey(this.resolveAbsolute(relativePath)); } - allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean { + override allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean { // Internal-URL authored targets (`local://`, `vault://`, …) are approved // at the lower "read" privilege; never let one redirect onto a "write". if (isInternalUrlPath(authoredPath)) return false; @@ -125,7 +125,7 @@ export class HashlineFilesystem extends Filesystem { return content; } - async readBinary(relativePath: string): Promise { + override async readBinary(relativePath: string): Promise { const absolutePath = this.resolveAbsolute(relativePath); if (isNotebookPath(absolutePath)) return undefined; try { @@ -136,7 +136,7 @@ export class HashlineFilesystem extends Filesystem { } } - async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise { + override async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise { const fileOp = options?.fileOp; if (fileOp?.kind === "rem") { enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); @@ -149,7 +149,7 @@ export class HashlineFilesystem extends Filesystem { enforcePlanModeWrite(this.session, relativePath, { op: "update" }); } - async delete(relativePath: string): Promise { + override async delete(relativePath: string): Promise { enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); const absolutePath = this.resolveAbsolute(relativePath); try { @@ -168,7 +168,7 @@ export class HashlineFilesystem extends Filesystem { invalidateFsScanAfterWrite(absolutePath); } - async move(fromRelative: string, toRelative: string, content?: string): Promise { + override async move(fromRelative: string, toRelative: string, content?: string): Promise { enforcePlanModeWrite(this.session, fromRelative, { op: "update", move: toRelative }); const fromAbsolute = this.resolveAbsolute(fromRelative); const toAbsolute = this.resolveAbsolute(toRelative); @@ -240,7 +240,7 @@ export class HashlineFilesystem extends Filesystem { return { text: content }; } - async exists(relativePath: string): Promise { + override async exists(relativePath: string): Promise { const absolutePath = this.resolveAbsolute(relativePath); return Bun.file(absolutePath).exists(); } diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts index 07483da39..296e7726b 100644 --- a/packages/coding-agent/src/eval/agent-bridge.ts +++ b/packages/coding-agent/src/eval/agent-bridge.ts @@ -23,7 +23,6 @@ export const EVAL_AGENT_BRIDGE_NAME = "__agent__"; const agentArgsSchema = type({ prompt: "string>0", "agent?": "string>0", - "model?": "string>0|string>0[]", "label?": "string", "schema?": "unknown", "schemaMode?": "'permissive' | 'strict'", @@ -31,12 +30,12 @@ const agentArgsSchema = type({ "apply?": "boolean", "merge?": "boolean", "handle?": "boolean", + "+": "delete", }); interface EvalAgentArgs { prompt: string; agent?: string; - model?: string | string[]; label?: string; schema?: unknown; schemaMode?: StructuredSubagentSchemaMode; @@ -148,7 +147,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption invocationKind: "eval", assignment: parsed.prompt, ...(parsed.agent !== undefined ? { agent: parsed.agent } : {}), - ...(parsed.model !== undefined ? { model: parsed.model } : {}), ...(Object.hasOwn(parsed, "schema") ? { outputSchema: parsed.schema } : {}), ...(parsed.schemaMode !== undefined ? { schemaMode: parsed.schemaMode } : {}), ...(parsed.label !== undefined ? { identity: { label: parsed.label } } : {}), diff --git a/packages/coding-agent/src/eval/jl/prelude.jl b/packages/coding-agent/src/eval/jl/prelude.jl index 13dcfd9c7..1adcdbcf7 100644 --- a/packages/coding-agent/src/eval/jl/prelude.jl +++ b/packages/coding-agent/src/eval/jl/prelude.jl @@ -519,14 +519,11 @@ function completion(prompt::String; model="default", system=nothing, schema=noth return schema === nothing ? text : Main.json_parse(string(text)) end -function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...) +function agent(prompt::String; agent="task", label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...) args_dict = Dict{String, Any}("prompt" => prompt) if agent !== nothing args_dict["agent"] = agent end - if model !== nothing - args_dict["model"] = model - end if label !== nothing args_dict["label"] = label end @@ -545,6 +542,9 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem if merge !== nothing args_dict["merge"] = Bool(merge) end + if haskey(kwargs, :model) + error("agent() no longer accepts a per-call model override; the selected agent's frontmatter model is used") + end handle_result = handle for (k, v) in kwargs args_dict[string(k)] = v diff --git a/packages/coding-agent/src/eval/js/shared/prelude.txt b/packages/coding-agent/src/eval/js/shared/prelude.txt index c29df1c9c..9acdf51a5 100644 --- a/packages/coding-agent/src/eval/js/shared/prelude.txt +++ b/packages/coding-agent/src/eval/js/shared/prelude.txt @@ -104,8 +104,8 @@ if (!globalThis.__omp_js_prelude_loaded__) { "agent", opts, rest, - ["agent", "model", "label", "schema", "isolated", "apply", "merge", "schemaMode"], - "{ agent, model, label, schema, isolated, apply, merge, schemaMode, handle }", + ["agent", "label", "schema", "isolated", "apply", "merge", "schemaMode"], + "{ agent, label, schema, isolated, apply, merge, schemaMode, handle }", ); const { handle, ...callArgs } = o; const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) }); diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index 8d6c8fd51..491eac219 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -488,7 +488,6 @@ if "__omp_prelude_loaded__" not in globals(): prompt, *, agent="task", - model=None, label=None, schema=None, schema_mode=None, @@ -506,8 +505,6 @@ if "__omp_prelude_loaded__" not in globals(): args = {"prompt": prompt} if agent is not None: args["agent"] = agent - if model is not None: - args["model"] = model if label is not None: args["label"] = label if schema is not None: diff --git a/packages/coding-agent/src/eval/rb/prelude.rb b/packages/coding-agent/src/eval/rb/prelude.rb index ddb8991ae..c6ae9c321 100644 --- a/packages/coding-agent/src/eval/rb/prelude.rb +++ b/packages/coding-agent/src/eval/rb/prelude.rb @@ -392,10 +392,9 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded schema.nil? ? text : JSON.parse(text) end - def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false) + def agent(prompt, agent: "task", label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false) args = { "prompt" => prompt } args["agent"] = agent unless agent.nil? - args["model"] = model unless model.nil? args["label"] = label unless label.nil? args["schema"] = schema unless schema.nil? args["schemaMode"] = schema_mode unless schema_mode.nil? diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 064d79dc4..30f483184 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -54,6 +54,7 @@ import { ReadTool } from "../tools/read"; import { formatBytes } from "../tools/render-utils"; import { WriteTool } from "../tools/write"; import { EventBus } from "../utils/event-bus"; +import { convertImageToPng } from "../utils/image-loading"; import { discoverExtensionPaths, loadExtensionFromFactory, loadExtensions } from "./extensions"; import { ExtensionRuntime } from "./extensions/loader"; import type { ExtensionFactory, ToolDefinition } from "./extensions/types"; @@ -384,6 +385,28 @@ async function executeLegacyBashOperations( } } +/** + * Convert an image attachment to PNG using the legacy package-root contract. + * + * Invalid or unsupported image data returns `null`, matching Pi's historical + * helper instead of surfacing Bun's decoder error to extensions. + */ +export async function convertToPng( + base64Data: string, + mimeType: string, +): Promise<{ data: string; mimeType: string } | null> { + if (mimeType === "image/png") { + return { data: base64Data, mimeType }; + } + + try { + const converted = await convertImageToPng({ type: "image", data: base64Data, mimeType }); + return { data: converted.data, mimeType: converted.mimeType }; + } catch { + return null; + } +} + /** Format the active shortcut for legacy extensions that render keybinding hints. */ export function keyText(action: Keybinding): string { return formatKeyHints(getKeybindings().getKeys(action)); diff --git a/packages/coding-agent/src/hindsight/state.ts b/packages/coding-agent/src/hindsight/state.ts index ae39a90e1..80fa6036d 100644 --- a/packages/coding-agent/src/hindsight/state.ts +++ b/packages/coding-agent/src/hindsight/state.ts @@ -411,24 +411,6 @@ export class HindsightSessionState { } } - async maybeRecallOnAgentStart(): Promise { - if (!this.config.autoRecall || this.hasRecalledForFirstTurn) return; - const messages = extractMessages(this.session.sessionManager); - const lastUser = messages.findLast(m => m.role === "user"); - if (!lastUser) return; - - const query = composeRecallQuery(lastUser.content, messages, this.config.recallContextTurns); - const truncated = truncateRecallQuery(query, lastUser.content, this.config.recallMaxQueryChars); - const { context, ok } = await this.recallForContext(truncated); - if (!ok) return; - - this.hasRecalledForFirstTurn = true; - if (!context) return; - - this.lastRecallSnippet = context; - await this.#refreshBaseSystemPromptAfter("recall"); - } - async beforeAgentStartPrompt(promptText: string): Promise { if (this.config.mentalModelsEnabled && this.mentalModelsLoadPromise && this.mentalModelsLoadedAt === undefined) { await Promise.race([this.mentalModelsLoadPromise, Bun.sleep(MENTAL_MODEL_FIRST_TURN_DEADLINE_MS)]); @@ -509,9 +491,7 @@ export class HindsightSessionState { attachSessionListeners(): void { this.unsubscribe?.(); this.unsubscribe = this.session.subscribe(event => { - if (event.type === "agent_start") { - void this.maybeRecallOnAgentStart(); - } else if (event.type === "agent_end") { + if (event.type === "agent_end") { void this.maybeRetainOnAgentEnd(); // Drain any queued tool-initiated retain calls now that the turn // is settled. The queue is also debounced/size-bounded, but @@ -540,7 +520,7 @@ export class HindsightSessionState { this.retainQueue.dispose(); } - async #refreshBaseSystemPromptAfter(reason: "recall" | "MM load" | "MM reload" | "MM TTL reload"): Promise { + async #refreshBaseSystemPromptAfter(reason: "MM load" | "MM reload" | "MM TTL reload"): Promise { try { await this.session.refreshBaseSystemPrompt(); } catch (err) { diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 6b8915957..36e8315bb 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -222,6 +222,18 @@ function mnemopiSessionStatesFromRegistry(): MnemopiSessionState[] { return states; } +function memoryBackendFromContext(context?: ResolveContext): string | undefined { + if (!context?.settings || typeof context.settings !== "object") return undefined; + try { + const get = Reflect.get(context.settings, "get"); + if (typeof get !== "function") return undefined; + const backend = Reflect.apply(get, context.settings, ["memory.backend"]); + return typeof backend === "string" ? backend : undefined; + } catch { + return undefined; + } +} + /** * Look up a mnemopi memory row by id across every live session's scoped banks. * First hit wins; returns `null` when the id is not stored anywhere in scope. @@ -290,6 +302,23 @@ export class MemoryProtocolHandler implements ProtocolHandler { // clipped recall preview before overwriting it (issue #4443). if (namespace !== MEMORY_NAMESPACE) { const mnemopiStates = mnemopiSessionStatesFromRegistry(); + const hindsightActive = + memoryBackendFromContext(context) === "hindsight" || + (mnemopiStates.length === 0 && + AgentRegistry.global() + .list() + .some(ref => ref.session?.getHindsightSessionState?.())); + if (hindsightActive) { + // Hindsight keeps memories server-side and exposes no + // `memory://` addressing, yet the shared `recall` tool + // description still steers a follow-up `read memory://`. + // Return a corrective pointer so that stray read self-corrects in + // one turn instead of derailing on the generic namespace error + // (issue #7587). + throw new Error( + "Hindsight memories are not addressable via memory://. Recall results are final — use `recall` to search or `reflect` to synthesize. `read memory://` is only available with memory.backend=mnemopi.", + ); + } if (mnemopiStates.length === 0) { throw new Error( `Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE} (file-backed memory summary), or a mnemopi memory id when memory.backend=mnemopi is active.`, diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index f4df6cb7b..a90b4fd02 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -17,7 +17,7 @@ import type { ServerConfig, WorkspaceEdit, } from "./types"; -import { detectLanguageId, fileToUri } from "./utils"; +import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils"; // ============================================================================= // Client State @@ -787,7 +787,7 @@ export async function getOrCreateClient( proc, config, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, dynamicCapabilityRegistrations: new Map(), openFiles: new Map(), diff --git a/packages/coding-agent/src/lsp/utils.ts b/packages/coding-agent/src/lsp/utils.ts index d9eeac7ac..d96f7f97b 100644 --- a/packages/coding-agent/src/lsp/utils.ts +++ b/packages/coding-agent/src/lsp/utils.ts @@ -78,6 +78,35 @@ function laxUriToFile(uri: string): string { return filePath; } +/** Map that treats equivalent file URI spellings as the same key. */ +export class EquivalentUriMap extends Map { + #key(uri: string): string { + if (!uri.startsWith("file://")) return uri; + const filePath = path.normalize(uriToFile(uri)); + return process.platform === "win32" ? filePath.toLowerCase() : filePath; + } + + override delete(uri: string): boolean { + const key = this.#key(uri); + return super.delete(key); + } + + override get(uri: string): Value | undefined { + const key = this.#key(uri); + return super.get(key); + } + + override has(uri: string): boolean { + const key = this.#key(uri); + return super.has(key); + } + + override set(uri: string, value: Value): this { + const key = this.#key(uri); + return super.set(key, value); + } +} + // ============================================================================= // Diagnostic Formatting // ============================================================================= diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 8ee0db5ee..d3d480564 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -2312,7 +2312,7 @@ export class AcpAgent implements Agent { this.#clientCapabilities, ); if (this.#clientCapabilities?.elicitation?.form != null) { - record.session.setUsageFallbackConfirmer(confirmation => { + record.session.setUsageFallbackConfirmer((confirmation, signal) => { const reserve = confirmation.remainingPercent === undefined ? "inside the configured reserve margin" @@ -2320,6 +2320,7 @@ export class AcpAgent implements Agent { return uiContext.confirm( "Coding-plan reserve reached", `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`, + { signal }, ); }); } diff --git a/packages/coding-agent/src/modes/components/agent-hub-projection.ts b/packages/coding-agent/src/modes/components/agent-hub-projection.ts new file mode 100644 index 000000000..6d908c527 --- /dev/null +++ b/packages/coding-agent/src/modes/components/agent-hub-projection.ts @@ -0,0 +1,248 @@ +import type { AgentMetricsSummary, AgentRef, AgentStatus } from "../../registry/agent-registry"; +import { MAIN_AGENT_ID } from "../../registry/agent-registry"; +import type { ObservableSession } from "../session-observer-registry"; + +export type AgentMetrics = AgentMetricsSummary; + +export interface AggregateMetrics extends AgentMetrics { + reportedAgents: number; + /** Rows whose duration is an observer-measured active runtime. */ + activeDurationAgents: number; +} + +export interface AgentTreeProjection { + rows: AgentRef[]; + depthById: Map; + parentById: Map; + lastSiblingById: Map; +} + +export const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; + +function finiteMetric(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** Exact observer usage for one roster entry. */ +export function progressMetrics(observed: ObservableSession | undefined): AgentMetrics | undefined { + const progress = observed?.progress; + if (!progress) return undefined; + const { tokens, requests, toolCount: tools, cost, durationMs } = progress; + if ( + typeof tokens !== "number" || + !Number.isFinite(tokens) || + typeof requests !== "number" || + !Number.isFinite(requests) || + typeof tools !== "number" || + !Number.isFinite(tools) || + typeof cost !== "number" || + !Number.isFinite(cost) || + typeof durationMs !== "number" || + !Number.isFinite(durationMs) + ) { + return undefined; + } + return { + tokens, + requests, + tools, + cost, + durationMs, + durationKind: "active", + contextTokens: + typeof progress.contextTokens === "number" && Number.isFinite(progress.contextTokens) + ? progress.contextTokens + : undefined, + contextWindow: + typeof progress.contextWindow === "number" && Number.isFinite(progress.contextWindow) + ? progress.contextWindow + : undefined, + }; +} + +/** + * Read direct assistant usage from a live session. SessionStats also includes + * usage embedded in completed `task` tool results, so using it for a parent + * row would double-count child rows in the aggregate. + */ +export function readSessionMetrics(session: NonNullable): AgentMetrics | undefined { + try { + const stats = session.getSessionStats(); + const messages = session.agent?.state?.messages; + if (!Array.isArray(messages)) { + return { + tokens: stats.tokens.input + stats.tokens.output + stats.tokens.cacheWrite, + requests: stats.assistantMessages, + tools: stats.toolCalls, + cost: stats.cost, + durationMs: 0, + durationKind: "unknown", + contextTokens: stats.contextUsage?.tokens, + contextWindow: stats.contextUsage?.contextWindow, + }; + } + + let tokens = 0; + let requests = 0; + let tools = 0; + let cost = 0; + for (const message of messages) { + if (message.role !== "assistant") continue; + requests++; + tokens += message.usage.input + message.usage.output + message.usage.cacheWrite; + tools += message.content.filter(content => content.type === "toolCall").length; + cost += message.usage.cost.total; + } + return { + tokens, + requests, + tools, + cost, + durationMs: 0, + durationKind: "unknown", + contextTokens: stats.contextUsage?.tokens, + contextWindow: stats.contextUsage?.contextWindow, + }; + } catch { + // Render-only doubles and sessions being torn down may not expose a + // complete statistics host. Missing metrics are preferable to a broken hub. + return undefined; + } +} + +export function aggregateMetrics(args: { + rows: readonly AgentRef[]; + observedById: ReadonlyMap; + metricsFor: (ref: AgentRef, observed: ObservableSession | undefined) => AgentMetrics | undefined; + fallbackStatsSession: ( + ref: AgentRef, + observed: ObservableSession | undefined, + ) => NonNullable | undefined; + sessionMetrics: WeakMap; + refreshFallback: boolean; +}): { metrics: AggregateMetrics; hasFallbackLiveSessions: boolean } { + const total: AggregateMetrics = { + tokens: 0, + requests: 0, + tools: 0, + cost: 0, + durationMs: 0, + durationKind: "active", + reportedAgents: 0, + activeDurationAgents: 0, + }; + let hasFallbackLiveSessions = false; + const countedFallbackSessions = new Set>(); + for (const ref of args.rows) { + const observed = args.observedById.get(ref.id); + const fallbackSession = args.fallbackStatsSession(ref, observed); + if (fallbackSession) { + hasFallbackLiveSessions = true; + if (args.refreshFallback || !args.sessionMetrics.has(fallbackSession)) { + args.sessionMetrics.set(fallbackSession, { metrics: readSessionMetrics(fallbackSession) }); + } + } + const metrics = args.metricsFor(ref, observed); + if (!metrics || (fallbackSession && countedFallbackSessions.has(fallbackSession))) continue; + if (fallbackSession) countedFallbackSessions.add(fallbackSession); + total.reportedAgents++; + total.tokens += finiteMetric(metrics.tokens); + total.requests += finiteMetric(metrics.requests); + total.tools += finiteMetric(metrics.tools); + total.cost += finiteMetric(metrics.cost); + if (metrics.durationKind === "active") { + total.durationMs += finiteMetric(metrics.durationMs); + total.activeDurationAgents++; + } + } + return { metrics: total, hasFallbackLiveSessions }; +} + +/** Parent-before-child projection preserving the roster's stable sibling order. */ +export function projectAgentTree(refs: readonly AgentRef[]): AgentTreeProjection { + const ids = new Set(); + const operationalIndex = new Map(); + for (let i = 0; i < refs.length; i++) { + ids.add(refs[i].id); + operationalIndex.set(refs[i].id, i); + } + + const parentById = new Map(); + const children = new Map(); + for (const ref of refs) { + const parent = + ref.parentId && ref.parentId !== MAIN_AGENT_ID && ids.has(ref.parentId) ? ref.parentId : MAIN_AGENT_ID; + parentById.set(ref.id, parent); + const siblings = children.get(parent); + if (siblings) siblings.push(ref); + else children.set(parent, [ref]); + } + + // A tree group occupies the position of its earliest operational row. + // Compute subtree minima iteratively so pathological lineage depth remains stack-safe. + const subtreeOrder = new Map(); + const visiting = new Set(); + const ranked = new Set(); + for (const start of refs) { + if (ranked.has(start.id)) continue; + const stack: Array<{ ref: AgentRef; expanded: boolean }> = [{ ref: start, expanded: false }]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + if (current.expanded) { + let order = operationalIndex.get(current.ref.id) ?? Number.MAX_SAFE_INTEGER; + for (const child of children.get(current.ref.id) ?? []) { + order = Math.min(order, subtreeOrder.get(child.id) ?? Number.MAX_SAFE_INTEGER); + } + subtreeOrder.set(current.ref.id, order); + visiting.delete(current.ref.id); + ranked.add(current.ref.id); + continue; + } + if (ranked.has(current.ref.id) || visiting.has(current.ref.id)) continue; + visiting.add(current.ref.id); + stack.push({ ref: current.ref, expanded: true }); + const descendants = children.get(current.ref.id); + if (!descendants) continue; + for (let i = descendants.length - 1; i >= 0; i--) { + const child = descendants[i]; + if (!ranked.has(child.id) && !visiting.has(child.id)) stack.push({ ref: child, expanded: false }); + } + } + } + for (const siblings of children.values()) { + siblings.sort( + (a, b) => + (subtreeOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (subtreeOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER) || + (operationalIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (operationalIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); + } + + const lastSiblingById = new Map(); + for (const siblings of children.values()) { + for (let i = 0; i < siblings.length; i++) lastSiblingById.set(siblings[i].id, i === siblings.length - 1); + } + + const rows: AgentRef[] = []; + const visited = new Set(); + const depthById = new Map(); + const visit = (root: AgentRef, rootDepth: number): void => { + const stack: Array<{ ref: AgentRef; depth: number }> = [{ ref: root, depth: rootDepth }]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current || visited.has(current.ref.id)) continue; + visited.add(current.ref.id); + depthById.set(current.ref.id, current.depth); + rows.push(current.ref); + const descendants = children.get(current.ref.id); + if (!descendants) continue; + for (let i = descendants.length - 1; i >= 0; i--) + stack.push({ ref: descendants[i], depth: current.depth + 1 }); + } + }; + for (const root of children.get(MAIN_AGENT_ID) ?? []) visit(root, 0); + // Corrupt persisted parent cycles remain visible as roots instead of disappearing. + for (const ref of refs) visit(ref, 0); + return { rows, depthById, parentById, lastSiblingById }; +} diff --git a/packages/coding-agent/src/modes/components/agent-hub-renderer.ts b/packages/coding-agent/src/modes/components/agent-hub-renderer.ts new file mode 100644 index 000000000..3200b76bd --- /dev/null +++ b/packages/coding-agent/src/modes/components/agent-hub-renderer.ts @@ -0,0 +1,194 @@ +import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Ellipsis, visibleWidth } from "@oh-my-pi/pi-tui"; +import { formatDuration, formatNumber, sanitizeText } from "@oh-my-pi/pi-utils"; +import { getRoleInfo } from "../../config/model-roles"; +import type { Settings } from "../../config/settings"; +import { type AgentRef, MAIN_AGENT_ID } from "../../registry/agent-registry"; +import { parseThinkingLevel } from "../../thinking"; +import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; +import type { ObservableSession } from "../session-observer-registry"; +import { theme } from "../theme/theme"; +import type { AgentMetrics } from "./agent-hub-projection"; + +export interface RosterRender { + lines: string[]; + hitRows: Array; +} + +/** Legacy progress snapshots may omit counters; snapshot absence remains distinct. */ +export function metricNumber(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** Compute the max content width for the current terminal, accounting for chrome. */ +export function contentWidth(): number { + return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6); +} + +/** Remove terminal controls and normalize a value before it reaches the TUI. */ +export function sanitizeDisplayText(text: string): string { + return replaceTabs(sanitizeText(text)).replace(/[\r\n]+/g, " "); +} + +/** Sanitize a line for TUI display and truncate it to the viewport width. */ +export function sanitizeLine(text: string, maxWidth?: number): string { + return truncateToWidth(sanitizeDisplayText(text), maxWidth ?? contentWidth()); +} + +export function clampHubLine(line: string, width: number): string { + return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width), Ellipsis.Omit); +} + +/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */ +export function statusGlyph(status: AgentRef["status"]): string { + switch (status) { + case "running": + return theme.fg("accent", theme.status.running); + case "idle": + return theme.fg("success", theme.status.enabled); + case "parked": + return theme.fg("muted", theme.status.shadowed); + case "aborted": + return theme.fg("error", theme.status.aborted); + } +} + +export function statusText(status: AgentRef["status"], text: string): string { + switch (status) { + case "running": + return theme.fg("accent", text); + case "idle": + return theme.fg("success", text); + case "parked": + return theme.fg("muted", text); + case "aborted": + return theme.fg("error", text); + } +} + +/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */ +export function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string { + const model = theme.fg("muted", sanitizeDisplayText(modelId)); + if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model; + const display = theme.thinking[level as keyof typeof theme.thinking] ?? level; + return `${model} ${theme.getThinkingBorderColor(level)(display)}`; +} + +/** Textual model-role tag; color reinforces (but never replaces) the label. */ +export function formatRoleBadge(role: string, settings: Settings): string { + const info = getRoleInfo(role, settings); + return theme.fg(info.color ?? "muted", sanitizeDisplayText(info.tag ?? info.name ?? role)); +} + +/** Format a resolved selector, preserving provider identity when requested. */ +export function formatResolvedModelBadge( + resolved: string, + preserveProvider = false, + fallbackLevel?: ThinkingLevel, +): string { + const cleanResolved = sanitizeDisplayText(resolved); + // Model ids may themselves contain colons (`qwen3:14b`), so only treat the + // suffix as a thinking level when it parses as one. + const colon = cleanResolved.lastIndexOf(":"); + const explicitLevel = colon >= 0 ? parseThinkingLevel(cleanResolved.slice(colon + 1)) : undefined; + const selector = explicitLevel !== undefined ? cleanResolved.slice(0, colon) : cleanResolved; + const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1); + return formatModelBadge(label, explicitLevel ?? fallbackLevel); +} + +/** + * Resolved model + reasoning level for a hub row. Exact executor progress is + * authoritative (and survives completion); direct live sessions are the + * fallback for agents without an observer snapshot. + */ +export function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined { + const progress = observed?.progress; + const liveThinkingLevel = ref.session?.thinkingLevel; + const fallbackSelector = + ref.session?.retryFallbackModel ?? + (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined) ?? + (ref.history?.resolvedModelIsFallback ? ref.history.resolvedModel : undefined); + if (fallbackSelector) { + return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true, liveThinkingLevel)}`; + } + const resolvedModel = progress?.resolvedModel ?? ref.history?.resolvedModel; + if (resolvedModel) return formatResolvedModelBadge(resolvedModel, false, liveThinkingLevel); + const model = ref.session?.model; + if (!model) return undefined; + const level = model.thinking ? liveThinkingLevel : undefined; + return formatModelBadge(model.id, level); +} + +export function formatMetricDuration(metrics: AgentMetrics): string | undefined { + const durationMs = metricNumber(metrics.durationMs); + if (durationMs <= 0) return undefined; + const label = metrics.durationKind === "active" ? "active" : metrics.durationKind === "span" ? "span" : "duration"; + return `${formatDuration(durationMs)} ${label}`; +} + +export function formatCost(cost: number): string { + const amount = metricNumber(cost); + if (amount < 0.01) return `$${amount.toFixed(4)}`; + if (amount < 1) return `$${amount.toFixed(3)}`; + return `$${amount.toFixed(2)}`; +} + +export function formatMetrics(metrics: AgentMetrics): string { + return [ + formatCost(metrics.cost), + formatMetricDuration(metrics) ?? "time —", + `${formatNumber(metrics.requests)} req`, + `${formatNumber(metrics.tools)} tools`, + `${formatNumber(metrics.tokens)} tok`, + ].join(theme.sep.dot); +} + +export function contextGauge(tokens: number, window: number): string { + const ratio = Math.max(0, Math.min(1, tokens / window)); + const filled = Math.round(ratio * 10); + return `${theme.fg("accent", "━".repeat(filled))}${theme.fg("dim", "─".repeat(10 - filled))} ${formatNumber(tokens)}/${formatNumber(window)} ${Math.round(ratio * 100)}%`; +} + +/** Fit a child-id preview without joining an arbitrarily large child set. */ +export function formatChildIds(children: readonly AgentRef[], width: number): string { + const max = Math.max(1, width); + let shown = 0; + let text = ""; + while (shown < children.length) { + const id = sanitizeLine(children[shown].id, max); + const candidate = text ? `${text}, ${id}` : id; + const remaining = children.length - shown - 1; + const suffix = remaining > 0 ? `, … +${remaining}` : ""; + if (visibleWidth(candidate + suffix) > max) { + const includesCurrent = text.length === 0; + const omitted = children.length - shown - Number(includesCurrent); + return truncateToWidth(`${includesCurrent ? id : text}${omitted > 0 ? `, … +${omitted}` : ""}`, max); + } + text = candidate; + shown++; + } + return text; +} + +/** Bash `tree`-style ancestry prefix, clipped from the left on pathological depth. */ +export function treeBranch( + ref: AgentRef, + maxWidth: number, + depthById: ReadonlyMap, + parentById: ReadonlyMap, + lastSiblingById: ReadonlyMap, +): string { + if ((depthById.get(ref.id) ?? 0) === 0) return ""; + const segments: string[] = [lastSiblingById.get(ref.id) ? "└── " : "├── "]; + const ancestry = new Set(); + let parent = parentById.get(ref.id); + while (parent && parentById.get(parent) !== MAIN_AGENT_ID && !ancestry.has(parent)) { + ancestry.add(parent); + segments.push(lastSiblingById.get(parent) ? " " : "│ "); + parent = parentById.get(parent); + } + const maxSegments = Math.max(1, Math.floor(Math.max(4, maxWidth - 2) / 4)); + const omitted = Math.max(0, segments.length - maxSegments); + const prefix = segments.slice(0, maxSegments).reverse().join(""); + return theme.fg("dim", `${omitted > 0 ? "… " : ""}${prefix}`); +} diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index a5a706c6e..e0ff47538 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -4,8 +4,8 @@ * One overlay, two views: * - Table view: every registered agent except Main (Main IS the ambient * chat), live from the global AgentRegistry — status, unread irc count, - * current/last task, last activity. Select with j/k, Enter opens a chat, - * `r` revives a parked agent, `x` aborts + releases one. + * current/last task, last activity. Navigate with keys, wheel, hover, and + * click; `r` revives a parked agent, `x` aborts + releases one. * - Chat view: per-agent transcript (incremental session-file tail, absorbed * from the old session observer overlay) plus an input line. Submitting * revives a parked agent, then prompts/steers it; the message lands in the @@ -13,108 +13,80 @@ * * Replaces the old SessionObserverOverlayComponent (ctrl+s observer). */ -import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui"; -import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { + Container, + matchesKey, + type OverlayHandle, + padding, + routeSelectListMouse, + routeSgrMouseInput, + type SelectListMouseTarget, + type TUI, + visibleWidth, + wrapTextWithAnsi, +} from "@oh-my-pi/pi-tui"; +import { formatAge, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; +import type { Settings } from "../../config/settings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; import { registerPersistedSubagents } from "../../registry/persisted-agents"; import { USER_INTERRUPT_LABEL } from "../../session/messages"; -import { parseThinkingLevel } from "../../thinking"; -import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; +import { shortenPath, truncateToWidth } from "../../tools/render-utils"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; +import { + type AgentMetrics, + type AggregateMetrics, + aggregateMetrics, + progressMetrics, + projectAgentTree, + STATUS_ORDER, +} from "./agent-hub-projection"; +import { + clampHubLine, + contextGauge, + formatChildIds, + formatCost, + formatMetricDuration, + formatMetrics, + formatRoleBadge, + modelBadge, + type RosterRender, + sanitizeDisplayText, + sanitizeLine, + statusGlyph, + statusText, + treeBranch, +} from "./agent-hub-renderer"; import { AgentTranscriptViewer } from "./agent-transcript-viewer"; -import { DynamicBorder } from "./dynamic-border"; +import { + bottomBorder, + divider, + dividerSplit, + row, + splitBodyWidth, + splitRow, + topBorder, + topBorderSplit, +} from "./overlay-box"; -/** Refresh cadence for the relative-time column */ +type HubViewMode = "roster" | "tree"; + +/** Refresh cadence for the relative-time column. */ const AGE_TICK_MS = 5_000; const DATA_CHANGE_RENDER_COALESCE_MS = 100; /** Double-tap window for the table's left-left "close hub" gesture. */ const LEFT_TAP_WINDOW_MS = 500; -/** Compute the max content width for the current terminal, accounting for chrome. */ -function contentWidth(): number { - return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6); -} - -/** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */ -function sanitizeLine(text: string, maxWidth?: number): string { - const singleLine = replaceTabs(text).replace(/[\r\n]+/g, " "); - return truncateToWidth(singleLine, maxWidth ?? contentWidth()); -} - -function clampHubLine(line: string, width: number): string { - return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width - 2), Ellipsis.Omit); -} - -const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; - -/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */ -function statusGlyph(status: AgentStatus): string { - switch (status) { - case "running": - return theme.fg("accent", theme.status.running); - case "idle": - return theme.fg("success", theme.status.enabled); - case "parked": - return theme.fg("muted", theme.status.shadowed); - case "aborted": - return theme.fg("error", theme.status.aborted); - } -} - -/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */ -function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string { - const model = theme.fg("muted", replaceTabs(modelId)); - if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model; - const display = theme.thinking[level as keyof typeof theme.thinking] ?? level; - return `${model} ${theme.getThinkingBorderColor(level)(display)}`; -} - -/** Format a resolved selector, preserving provider identity when requested. */ -function formatResolvedModelBadge(resolved: string, preserveProvider = false): string { - // Model ids may themselves contain colons (`qwen3:14b`), so only treat the - // suffix as a thinking level when it parses as one. - const colon = resolved.lastIndexOf(":"); - const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined; - const selector = level !== undefined ? resolved.slice(0, colon) : resolved; - const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1); - return formatModelBadge(label, level); -} - -/** - * Active model + reasoning level for a hub row: live session state when the - * agent is attached, else the executor-reported `resolvedModel` selector - * (`provider/id`, optionally `:`). Active retry fallbacks retain their - * provider and carry an explicit marker. Undefined when no model is known - * (e.g. a parked historical agent restored from disk). - */ -function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined { - const progress = observed?.progress; - // Prefer the live session's own resolved fallback selector; else honor the - // executor-reported fallback flag. The latter covers observer-only rows (no - // live session) AND live rows whose fallback armed no session retry state — - // e.g. the Fireworks Fast → base degrade, which emits `retry_fallback_applied` - // without populating `#activeRetryFallback`, so `retryFallbackModel` is undefined. - const fallbackSelector = - ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined); - if (fallbackSelector) { - return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true)}`; - } - const model = ref.session?.model; - if (model) { - const level = model.thinking ? ref.session?.thinkingLevel : undefined; - return formatModelBadge(model.id, level); - } - const resolved = progress?.resolvedModel; - return resolved ? formatResolvedModelBadge(resolved) : undefined; -} - +/** Two-pane mode needs a useful roster and a readable inspector. */ +const SPLIT_MIN_WIDTH = 96; +const DETAIL_MIN_WIDTH = 34; +const ROSTER_MIN_WIDTH = 48; /** Result of one host-backed transcript read for the Agent Hub viewer. */ export interface AgentHubRemoteTranscript { text: string; @@ -135,6 +107,8 @@ export interface AgentHubRemote { export interface AgentHubDeps { /** Progress/status snapshot source (task lifecycle + progress channels). */ observers: SessionObserverRegistry; + /** Production settings used to resolve textual model-role tags. */ + settings?: Settings; /** Keys that toggle the hub closed from inside (app.agents.hub + app.session.observe). */ hubKeys: KeyId[]; onDone: () => void; @@ -162,13 +136,15 @@ export interface AgentHubDeps { focusAgent?: (id: string) => Promise; /** Current main session file; used to seed parked historical subagents after restart. */ sessionFile?: string | null; + /** Collab guest: route actions/transcripts to the host instead of local sessions. */ remote?: AgentHubRemote; } -export class AgentHubOverlayComponent extends Container { +export class AgentHubOverlayComponent extends Container implements SelectListMouseTarget { #registry: AgentRegistry; #observers: SessionObserverRegistry; + #settings: Settings | undefined; #irc: IrcBus; #lifecycle: () => AgentLifecycleManager; #onDone: () => void; @@ -178,17 +154,54 @@ export class AgentHubOverlayComponent extends Container { #ageTimer: NodeJS.Timeout | undefined; #dataChangeTimer?: NodeJS.Timeout; #remote: AgentHubRemote | undefined; + #disposed = false; /** Resolves after persisted historical subagents have been registered and rows refreshed. */ readonly persistedSubagentsReady: Promise; + /** Prevent the async persisted-session scan from flashing a false empty state. */ + #loadingPersistedSubagents = false; // Table state #rows: AgentRef[] = []; + #statusCounts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; #selectedRow = 0; + #hoveredRow: number | null = null; + /** Per-render screen-line to agent-row map, shared by click and hover routing. */ + #hitRows: Array = []; #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ #rowOrder: Map | undefined; + #nextRowOrder = 0; /** Double-tap window state for the table's left-left "close hub" gesture. */ #lastLeftTap = 0; + /** Operational ordering by default; tree mode groups descendants under their spawner. */ + #viewMode: HubViewMode = "roster"; + #treeDepthById = new Map(); + #treeParentById = new Map(); + #treeLastSiblingById = new Map(); + /** Current observer index and summary data, rebuilt on source changes rather than every paint. */ + #observedById = new Map(); + #aggregate: AggregateMetrics = { + tokens: 0, + requests: 0, + tools: 0, + cost: 0, + durationMs: 0, + durationKind: "active", + reportedAgents: 0, + activeDurationAgents: 0, + }; + #childrenByParent = new Map(); + /** Transcript-derived fallback stats are sampled only on the bounded age cadence. */ + #sessionMetrics = new WeakMap(); + /** Avoid a cadence-time row scan for the common persisted-only roster. */ + #hasFallbackLiveSessions = false; + /** On narrow terminals Tab replaces the roster with the selected-agent inspector. */ + #narrowDetailsOpen = false; + #lastRenderWasSplit = false; + #lastSplitRosterWidth: number | undefined; + /** Scroll offset for the selected-agent inspector when its content overflows. */ + #detailScrollOffset = 0; + #detailAgentId: string | undefined; // Transcript-viewer launch deps (passed through to AgentTranscriptViewer). #ui: TUI; @@ -208,6 +221,7 @@ export class AgentHubOverlayComponent extends Container { super(); this.#registry = deps.registry ?? AgentRegistry.global(); this.#observers = deps.observers; + this.#settings = deps.settings; this.#irc = deps.irc ?? IrcBus.global(); // Lazy: the lifecycle global self-constructs against the global // registry, so only touch it when revive/kill actually needs it. @@ -216,6 +230,7 @@ export class AgentHubOverlayComponent extends Container { this.#requestRender = deps.requestRender; this.#hubKeys = deps.hubKeys; this.#remote = deps.remote; + this.#loadingPersistedSubagents = !this.#remote && Boolean(deps.sessionFile?.endsWith(".jsonl")); this.#ui = deps.ui ?? ({ @@ -232,19 +247,29 @@ export class AgentHubOverlayComponent extends Container { this.#unsubscribers.push(this.#registry.onChange(() => this.#scheduleDataChange())); this.#unsubscribers.push(this.#observers.onChange(() => this.#scheduleDataChange())); - this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS); + this.#ageTimer = setInterval(() => { + if (this.#hasFallbackLiveSessions) { + this.#refreshAggregate(true); + } + this.#requestRender(); + }, AGE_TICK_MS); this.#ageTimer.unref?.(); this.persistedSubagentsReady = this.#remote ? Promise.resolve() - : registerPersistedSubagents(this.#registry, deps.sessionFile) + : registerPersistedSubagents(this.#registry, deps.sessionFile, { + shouldContinue: () => !this.#disposed, + }) + .then(() => { + if (!this.#disposed) this.#refreshRows(); + }) .catch((error: unknown) => { logger.warn("Failed to register persisted subagents", { error }); }) - .then(() => { - this.#refreshRows(); - }) - .finally(() => this.#requestRender()); + .finally(() => { + this.#loadingPersistedSubagents = false; + if (!this.#disposed) this.#requestRender(); + }); this.#refreshRows(); } @@ -258,7 +283,9 @@ export class AgentHubOverlayComponent extends Container { } /** Tear down every subscription and timer. Called by the overlay owner on close. */ - dispose(): void { + override dispose(): void { + if (this.#disposed) return; + this.#disposed = true; for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe(); if (this.#ageTimer) { clearInterval(this.#ageTimer); @@ -272,10 +299,28 @@ export class AgentHubOverlayComponent extends Container { } override render(width: number): readonly string[] { - return this.#renderTable(width).map(line => clampHubLine(line, width)); + const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40; + const frame = this.#renderTable(width, termHeight).map(line => clampHubLine(line, width)); + if (frame.length <= termHeight) return frame; + + // A tiny terminal can leave less room than the fixed chrome needs. Keep + // the title and footer visible instead of spilling into scrollback. + const footerLines = Math.min(3, frame.length); + const bodyEnd = Math.max(0, termHeight - footerLines); + return [...frame.slice(0, bodyEnd), ...frame.slice(-footerLines)].slice(0, termHeight); } handleInput(keyData: string): void { + if ( + routeSgrMouseInput(keyData, event => { + const split = this.#lastSplitRosterWidth; + if (split !== undefined && event.wheel === null && event.col > split + 2) return false; + return routeSelectListMouse(this, event, event.row); + }) + ) { + return; + } + // The hub/observe keys always close the overlay (toggle semantics) for (const key of this.#hubKeys) { if (matchesKey(keyData, key)) { @@ -306,11 +351,12 @@ export class AgentHubOverlayComponent extends Container { * restored when the viewer closes. No-op without a real TUI (render-only test stub). */ openChat(id: string): void { - if (!this.#registry.get(id)) return; + if (this.#disposed || !this.#registry.get(id)) return; if (typeof this.#ui.showOverlay !== "function") return; this.#closeTranscriptOverlay(); this.#notice = undefined; - const viewer = new AgentTranscriptViewer({ + let viewer: AgentTranscriptViewer; + viewer = new AgentTranscriptViewer({ agentId: id, registry: this.#registry, remote: this.#remote, @@ -325,10 +371,11 @@ export class AgentHubOverlayComponent extends Container { expandKeys: this.#expandKeys, hubKeys: this.#hubKeys, requestRender: this.#requestRender, - onClose: () => this.#closeTranscriptOverlay(), + onClose: () => this.#closeTranscriptOverlay(viewer), onHubClose: () => { - this.#closeTranscriptOverlay(); - this.#onDone(); + if (this.#disposed) return; + this.#closeTranscriptOverlay(viewer); + if (!this.#disposed) this.#onDone(); }, }); this.#transcriptViewer = viewer; @@ -338,13 +385,19 @@ export class AgentHubOverlayComponent extends Container { } /** Close and dispose the transcript overlay, restoring focus to the hub table. */ - #closeTranscriptOverlay(): void { - this.#transcriptOverlay?.hide(); + #closeTranscriptOverlay(expectedViewer?: AgentTranscriptViewer): void { + if (expectedViewer && this.#transcriptViewer !== expectedViewer) return; + const overlay = this.#transcriptOverlay; + const viewer = this.#transcriptViewer; + if (!overlay && !viewer) return; + overlay?.hide(); this.#transcriptOverlay = undefined; - this.#transcriptViewer?.dispose(); + viewer?.dispose(); this.#transcriptViewer = undefined; - if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this); - this.#requestRender(); + if (!this.#disposed) { + if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this); + this.#requestRender(); + } } // ======================================================================== @@ -368,123 +421,459 @@ export class AgentHubOverlayComponent extends Container { #refreshRows(): void { const selectedId = this.#rows[this.#selectedRow]?.id; const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); - - if (!this.#rowOrder) { - // First refresh (usually the constructor): order by status, then recency. - this.#rows = refs.sort( - (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity, + this.#observedById = new Map(); + for (const session of this.#observers.getSessions()) this.#observedById.set(session.id, session); + const rowOrder = this.#rowOrder; + let rosterRows: AgentRef[]; + if (!rowOrder) { + rosterRows = refs.sort( + (a, b) => + STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || + b.lastActivity - a.lastActivity || + a.id.localeCompare(b.id), ); - this.#rowOrder = new Map(this.#rows.map((ref, i) => [ref.id, i])); + this.#rowOrder = new Map(); + for (const ref of rosterRows) this.#rowOrder.set(ref.id, this.#nextRowOrder++); } else { - // After the hub is open, freeze the relative order so keyboard selection - // does not jump around as agents heartbeat or update activity. New agents - // are appended at the end and then stay put. - this.#rows = refs.sort((a, b) => { - const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; - if (statusDiff !== 0) return statusDiff; - const aOrder = this.#rowOrder!.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const bOrder = this.#rowOrder!.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return aOrder - bOrder; - }); - for (const ref of this.#rows) { - if (!this.#rowOrder.has(ref.id)) { - this.#rowOrder.set(ref.id, this.#rowOrder.size); - } + rosterRows = refs.sort( + (a, b) => (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); + for (const ref of rosterRows) { + if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, this.#nextRowOrder++); } } + if (this.#viewMode === "tree") { + const tree = projectAgentTree(rosterRows); + this.#rows = tree.rows; + this.#treeDepthById = tree.depthById; + this.#treeParentById = tree.parentById; + this.#treeLastSiblingById = tree.lastSiblingById; + } else { + this.#rows = rosterRows; + this.#treeDepthById.clear(); + this.#treeParentById.clear(); + this.#treeLastSiblingById.clear(); + } const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1; this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); + const detailAgentId = this.#rows[this.#selectedRow]?.id; + if (detailAgentId !== this.#detailAgentId) { + this.#detailAgentId = detailAgentId; + this.#detailScrollOffset = 0; + } + + this.#childrenByParent.clear(); + for (const ref of rosterRows) { + const parent = ref.parentId ?? MAIN_AGENT_ID; + const children = this.#childrenByParent.get(parent); + if (children) children.push(ref); + else this.#childrenByParent.set(parent, [ref]); + } + this.#statusCounts = { running: 0, idle: 0, parked: 0, aborted: 0 }; + for (const ref of rosterRows) this.#statusCounts[ref.status]++; + this.#refreshAggregate(); } - #observableFor(id: string): ObservableSession | undefined { - return this.#observers.getSessions().find(s => s.id === id); + #metricsFor(ref: AgentRef, observed: ObservableSession | undefined): AgentMetrics | undefined { + if (observed?.progress) return progressMetrics(observed); + if (ref.history?.metrics) return ref.history.metrics; + const session = this.#fallbackStatsSession(ref, observed); + return session ? this.#sessionMetrics.get(session)?.metrics : undefined; + } + + #fallbackStatsSession( + ref: AgentRef, + observed: ObservableSession | undefined, + ): NonNullable | undefined { + if (observed?.progress) return undefined; + const session = ref.session; + return session && typeof session.getSessionStats === "function" ? session : undefined; } // ======================================================================== // Table view // ======================================================================== - #renderTable(width: number): string[] { + #renderTable(width: number, termHeight: number): string[] { + this.#hitRows.length = 0; + const contentRows = Math.max(1, termHeight - 4); + const observedById = this.#observedById; + const split = this.#splitRosterWidth(width); + this.#lastRenderWasSplit = split !== undefined; + this.#lastSplitRosterWidth = split; + const selected = this.#rows[this.#selectedRow]; const lines: string[] = []; - lines.push(...new DynamicBorder().render(width)); - const counts = this.#statusSummary(); - lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`); - lines.push(...new DynamicBorder().render(width)); - if (this.#rows.length === 0) { - lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`); + if (split !== undefined) { + const detailWidth = splitBodyWidth(width, split); + const roster = this.#renderRosterPanel(split, contentRows, observedById); + const details = this.#renderDetailPanel(selected, detailWidth, contentRows, observedById); + lines.push(topBorderSplit(width, "Agent Hub", split)); + for (let i = 0; i < contentRows; i++) { + const hit = roster.hitRows[i]; + if (hit !== undefined) this.#hitRows[lines.length] = hit; + lines.push(splitRow(roster.lines[i] ?? "", details[i] ?? "", width, split)); + } + lines.push(dividerSplit(width, split)); + lines.push(row(this.#footer(false, Math.max(1, width - 4)), width)); + lines.push(bottomBorder(width)); + return lines; + } + + const innerWidth = Math.max(1, width - 4); + if (this.#narrowDetailsOpen && selected) { + const details = this.#renderDetailPanel(selected, innerWidth, contentRows, observedById); + lines.push(topBorder(width, `Agent Hub · ${selected.id}`)); + for (const detail of details) lines.push(row(detail, width)); } else { - const termHeight = process.stdout.rows || 40; - // Chrome: 2 borders + title + notice? + blank + hints + border - const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0)); - const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width)); - // Entries are 1-2 lines tall; grow a window around the selection until - // the line budget is spent, so the selected entry stays centered. - let start = this.#selectedRow; - let end = this.#selectedRow + 1; - let used = entries[start]?.length ?? 0; - for (let grew = true; grew; ) { - grew = false; - if (end < entries.length && used + entries[end].length <= budget) { - used += entries[end].length; - end++; - grew = true; + const roster = this.#renderRosterPanel(innerWidth, contentRows, observedById); + lines.push(topBorder(width, "Agent Hub")); + for (let i = 0; i < contentRows; i++) { + const hit = roster.hitRows[i]; + if (hit !== undefined) this.#hitRows[lines.length] = hit; + lines.push(row(roster.lines[i] ?? "", width)); + } + } + lines.push(divider(width)); + lines.push(row(this.#footer(this.#narrowDetailsOpen, innerWidth), width)); + lines.push(bottomBorder(width)); + return lines; + } + + #splitRosterWidth(width: number): number | undefined { + if (width < SPLIT_MIN_WIDTH) return undefined; + const rosterWidth = Math.max(ROSTER_MIN_WIDTH, Math.min(Math.floor(width * 0.58), width - DETAIL_MIN_WIDTH - 7)); + return splitBodyWidth(width, rosterWidth) >= DETAIL_MIN_WIDTH ? rosterWidth : undefined; + } + + #footer(showingNarrowDetails: boolean, availableWidth: number): string { + const nextView = this.#viewMode === "roster" ? "by parent" : "flat"; + if (showingNarrowDetails) { + return theme.fg("dim", `Tab:roster PgUp/PgDn:scroll Enter:open t:${nextView} Esc:roster`); + } + if (availableWidth < 96) { + return theme.fg("dim", `j/k:select Enter:open t:${nextView} Tab:details r/x:manage Esc:close`); + } + return theme.fg( + "dim", + `j/k/wheel:select PgUp/PgDn:details Enter/click:open t:${nextView} r:revive x:kill Esc:close`, + ); + } + + #renderRosterPanel(width: number, rows: number, observedById: ReadonlyMap): RosterRender { + const lines = this.#summaryLines(width); + const hitRows: Array = Array.from({ length: lines.length }); + if (rows >= 8) { + lines.push(""); + hitRows.push(undefined); + } + + const noticeLines = this.#notice ? [theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width)))] : []; + const budget = Math.max(0, rows - lines.length - noticeLines.length); + if (this.#rows.length === 0) { + if (this.#loadingPersistedSubagents) { + if (budget > 0) { + lines.push(`${statusGlyph("running")} ${theme.fg("accent", "Loading saved agents…")}`); + hitRows.push(undefined); } - if (start > 0 && used + entries[start - 1].length <= budget) { - start--; - used += entries[start].length; + } else { + const emptyState = [ + `${theme.fg("muted", theme.status.shadowed)} ${theme.bold("No agents in this session")}`, + theme.fg("dim", "Finished, parked, and killed subagents remain with the session that created them."), + theme.fg("dim", "Resume that session with omp-dev --continue, or spawn a task here."), + ]; + for (const line of emptyState.slice(0, budget)) { + lines.push(line); + hitRows.push(undefined); + } + } + } else if (budget > 0) { + const window = this.#renderRosterWindow(width, budget, observedById); + lines.push(...window.lines); + hitRows.push(...window.hitRows); + } + for (const notice of noticeLines) { + lines.push(notice); + hitRows.push(undefined); + } + while (lines.length < rows) { + lines.push(""); + hitRows.push(undefined); + } + return { lines: lines.slice(0, rows), hitRows: hitRows.slice(0, rows) }; + } + + #renderRosterWindow( + width: number, + budget: number, + _observedById: ReadonlyMap, + ): RosterRender { + const lines: string[] = []; + const hitRows: Array = []; + const rendered = new Map(); + const entryAt = (index: number): string[] => { + const cached = rendered.get(index); + if (cached) return cached; + const entry = this.#renderEntry( + this.#rows[index], + index === this.#selectedRow, + width, + this.#observableFor(this.#rows[index].id), + index === this.#hoveredRow, + ); + rendered.set(index, entry); + return entry; + }; + const appendEntry = (index: number, entry = entryAt(index)): void => { + for (const line of entry) { + lines.push(line); + hitRows.push(index); + } + }; + + let start = this.#selectedRow; + let end = this.#selectedRow + 1; + let used = entryAt(this.#selectedRow).length; + if (used > budget) { + appendEntry(this.#selectedRow, entryAt(this.#selectedRow).slice(0, budget)); + return { lines, hitRows }; + } + + // Grow a window around the selection. Only visible entries are rendered, + // so the 5,000-agent Hub retains bounded paint cost. + for (let grew = true; grew; ) { + grew = false; + if (end < this.#rows.length) { + const next = entryAt(end); + if (used + next.length <= budget) { + used += next.length; + end++; grew = true; } } if (start > 0) { - lines.push(` ${theme.fg("dim", `… ${start} more`)}`); - } - for (let i = start; i < end; i++) { - lines.push(...entries[i]); - } - if (end < this.#rows.length) { - lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); + const previous = entryAt(start - 1); + if (used + previous.length <= budget) { + start--; + used += previous.length; + grew = true; + } } } + // Overflow labels consume real rows. Trim the farthest visible neighbors + // before painting them so the selected entry and both labels fit. + for ( + let markerRows = Number(start > 0) + Number(end < this.#rows.length); + used + markerRows > budget && start < end; + markerRows = Number(start > 0) + Number(end < this.#rows.length) + ) { + if (end - 1 > this.#selectedRow) { + end--; + used -= entryAt(end).length; + } else if (start < this.#selectedRow) { + used -= entryAt(start).length; + start++; + } else { + break; + } + } + const showTopOverflow = start > 0 && used < budget; + const showBottomOverflow = end < this.#rows.length && used + Number(showTopOverflow) < budget; + if (showTopOverflow) { + lines.push(theme.fg("dim", `… ${start} more`)); + hitRows.push(undefined); + } + for (let i = start; i < end; i++) appendEntry(i); + if (showBottomOverflow) { + lines.push(theme.fg("dim", `… ${this.#rows.length - end} more`)); + hitRows.push(undefined); + } + return { lines, hitRows }; + } - if (this.#notice) { - lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`); + #summaryLines(width: number): string[] { + const active = (label: string): string => theme.bg("selectedBg", theme.bold(theme.fg("accent", ` ${label} `))); + const inactive = (label: string): string => theme.fg("muted", ` ${label} `); + const projection = + this.#viewMode === "roster" + ? `${active("Flat")}${theme.fg("dim", "/")}${inactive("By parent")}` + : `${inactive("Flat")}${theme.fg("dim", "/")}${active("By parent")}`; + const counts = this.#statusSummary(); + const header = `${theme.bold("Roster")}${theme.fg("dim", theme.sep.dot)}${projection}${counts ? theme.fg("dim", theme.sep.dot) + counts : ""}`; + const lines = wrapTextWithAnsi(header, Math.max(1, width)); + + const metrics = this.#aggregate; + if (metrics.reportedAgents === 0) { + lines.push( + ...wrapTextWithAnsi( + theme.fg("dim", `Usage —${theme.sep.dot}0/${this.#rows.length} measured`), + Math.max(1, width), + ), + ); + return lines; } - lines.push(""); - lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`); - lines.push(...new DynamicBorder().render(width)); + const activeTime = formatMetricDuration(metrics); + const usage = [ + theme.fg("statusLineCost", formatCost(metrics.cost)), + theme.fg("dim", activeTime ? `${activeTime} agent time` : "agent time —"), + theme.fg("dim", `${formatNumber(metrics.requests)} req`), + theme.fg("dim", `${formatNumber(metrics.tools)} tools`), + theme.fg("dim", `${formatNumber(metrics.tokens)} tok`), + theme.fg("dim", `${metrics.activeDurationAgents}/${metrics.reportedAgents} timed`), + theme.fg("dim", `${metrics.reportedAgents}/${this.#rows.length} measured`), + ].join(theme.fg("dim", theme.sep.dot)); + lines.push(...wrapTextWithAnsi(usage, Math.max(1, width))); return lines; } #statusSummary(): string { - const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; - for (const ref of this.#rows) { - counts[ref.status]++; - } const parts: string[] = []; for (const status of ["running", "idle", "parked", "aborted"] as const) { - const count = counts[status]; - if (count > 0) parts.push(`${count} ${status}`); + const count = this.#statusCounts[status]; + if (count > 0) parts.push(`${statusGlyph(status)} ${statusText(status, `${count} ${status}`)}`); } return parts.join(theme.sep.dot); } - /** - * One agent entry, 1-2 lines: - * `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity - * left, metadata right-aligned (inlined when the terminal is too narrow) — - * plus an indented dim task line when the agent's work is known. - */ - #renderEntry(ref: AgentRef, selected: boolean, width: number): string[] { - const max = Math.max(1, width - 2); - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`]; - if (ref.displayName && ref.displayName !== ref.id) { - fields.push(theme.fg("dim", replaceTabs(ref.displayName))); + #refreshAggregate(refreshFallback = false): void { + const result = aggregateMetrics({ + rows: this.#rows, + observedById: this.#observedById, + metricsFor: (ref, observed) => this.#metricsFor(ref, observed), + fallbackStatsSession: (ref, observed) => this.#fallbackStatsSession(ref, observed), + sessionMetrics: this.#sessionMetrics, + refreshFallback, + }); + this.#aggregate = result.metrics; + this.#hasFallbackLiveSessions = result.hasFallbackLiveSessions; + } + + #observableFor(id: string): ObservableSession | undefined { + return this.#observedById.get(id) ?? this.#observers.getSession(id); + } + + #renderDetailPanel( + ref: AgentRef | undefined, + width: number, + rows: number, + _observedById: ReadonlyMap, + ): string[] { + if (!ref) return [theme.fg("dim", "Select an agent to inspect"), ...Array.from({ length: rows - 1 }, () => "")]; + const observed = this.#observableFor(ref.id); + const progress = observed?.progress; + const metrics = this.#metricsFor(ref, observed); + const children = this.#childrenByParent.get(ref.id) ?? []; + const lines: string[] = []; + const add = (line = ""): void => { + lines.push(truncateToWidth(line, width)); + }; + const addWrapped = (text: string, maxRows = 2): void => { + for (const wrapped of wrapTextWithAnsi(sanitizeLine(text), Math.max(1, width)).slice(0, maxRows)) add(wrapped); + }; + const section = (label: string, contentRows = 0): void => { + if (lines.length > 0 && lines.length + 1 + contentRows < rows) add(); + add(theme.bold(theme.fg("accent", label))); + }; + + add(`${statusGlyph(ref.status)} ${theme.bold(sanitizeDisplayText(ref.displayName || ref.id))}`); + if (ref.displayName && ref.displayName !== ref.id) add(theme.fg("dim", sanitizeDisplayText(ref.id))); + const lifecycleDetails = [ + metrics ? formatMetricDuration(metrics) : undefined, + `active ${formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))}`, + ].filter(Boolean); + add( + `${statusText(ref.status, ref.status)}${theme.fg("dim", `${theme.sep.dot}${lifecycleDetails.join(theme.sep.dot)}`)}`, + ); + const modelDetails: string[] = []; + const modelRole = progress?.modelRole ?? ref.history?.modelRole; + if (modelRole && this.#settings) modelDetails.push(formatRoleBadge(modelRole, this.#settings)); + const badge = modelBadge(ref, observed); + if (badge) modelDetails.push(badge); + if (modelDetails.length > 0) add(modelDetails.join(theme.sep.dot)); + + const task = observed?.description ?? progress?.task ?? ref.activity; + if (task) { + section("Task"); + addWrapped(task); } - if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) { - fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`)); + + const current = progress?.currentTool + ? `${progress.currentTool}${progress.currentToolArgs ? ` · ${progress.currentToolArgs}` : ""}` + : (progress?.lastIntent ?? ref.activity); + if (current) { + section("Current"); + addWrapped(current); + if (progress?.retryState) { + add(theme.fg("warning", `retry ${progress.retryState.attempt}/${progress.retryState.maxAttempts}`)); + } + } + + section("Usage", 1); + if (metrics) { + addWrapped(formatMetrics(metrics), 3); + if (metrics.contextTokens !== undefined && metrics.contextWindow) { + add(contextGauge(metrics.contextTokens, metrics.contextWindow)); + } + } else { + add(theme.fg("dim", "usage —")); + } + + section("Lineage"); + add( + `Spawned by ${sanitizeDisplayText(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`, + ); + if (children.length > 0) add(theme.fg("dim", formatChildIds(children, width))); + add(theme.fg("dim", `Registered ${new Date(ref.createdAt).toISOString().slice(0, 16).replace("T", " ")}Z`)); + + section("Changes"); + add( + theme.fg( + "dim", + ref.kind === "advisor" || ref.history?.readOnly + ? "Read-only · 0 LoC" + : "Shared workspace · per-agent LoC not attributable", + ), + ); + const artifacts = ref.history; + if (artifacts?.outputPath) addWrapped(`Output ${shortenPath(artifacts.outputPath)}`); + if (artifacts?.patchPath) addWrapped(`Patch ${shortenPath(artifacts.patchPath)}`); + if (artifacts?.branchName) addWrapped(`Worktree branch ${artifacts.branchName}`); + + const maxScroll = Math.max(0, lines.length - rows); + this.#detailScrollOffset = Math.min(this.#detailScrollOffset, maxScroll); + const visible = lines.slice(this.#detailScrollOffset, this.#detailScrollOffset + rows); + while (visible.length < rows) visible.push(""); + return visible; + } + + /** + * One agent entry keeps identity/model metadata on one line when it fits, + * then packs task and all five usage metrics together below. Narrow rows wrap + * only those dense secondary fields. + */ + #renderEntry( + ref: AgentRef, + selected: boolean, + width: number, + observed: ObservableSession | undefined, + hovered = false, + ): string[] { + const max = Math.max(1, width); + const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; + const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0; + const branch = + this.#viewMode === "tree" + ? treeBranch(ref, max, this.#treeDepthById, this.#treeParentById, this.#treeLastSiblingById) + : ""; + const id = sanitizeDisplayText(ref.id); + const styledId = selected ? theme.bold(theme.fg("accent", id)) : theme.bold(id); + const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${styledId}`]; + if (ref.displayName && ref.displayName !== ref.id) { + fields.push(theme.fg("dim", sanitizeDisplayText(ref.displayName))); + } + if (this.#viewMode === "roster" && ref.parentId && ref.parentId !== MAIN_AGENT_ID) { + fields.push(theme.fg("dim", `↳ ${sanitizeDisplayText(ref.parentId)}`)); } if (ref.kind === "advisor") { fields.push(theme.fg("warning", "read-only")); @@ -493,10 +882,13 @@ export class AgentHubOverlayComponent extends Container { if (unread > 0) { fields.push(theme.fg("warning", `⧉ ${unread}`)); } - const left = ` ${fields.join(" ")}`; + const left = fields.join(" "); - const observed = this.#observableFor(ref.id); const meta: string[] = []; + const modelRole = observed?.progress?.modelRole ?? ref.history?.modelRole; + if (modelRole && this.#settings) { + meta.push(formatRoleBadge(modelRole, this.#settings)); + } const badge = modelBadge(ref, observed); if (badge) meta.push(badge); meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000))))); @@ -504,25 +896,110 @@ export class AgentHubOverlayComponent extends Container { const leftWidth = visibleWidth(left); const rightWidth = visibleWidth(right); - const line = - leftWidth + 2 + rightWidth <= max - ? left + padding(max - leftWidth - rightWidth) + right - : truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max); - const entry = [line]; - - const task = observed?.description ?? observed?.progress?.task ?? ref.activity; - if (task) { - entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`); + const entry: string[] = []; + const detailIndent = Math.min(max - 1, 4 + depth * 2); + if (leftWidth + 2 + rightWidth <= max) { + entry.push(left + padding(max - leftWidth - rightWidth) + right); + } else { + entry.push(truncateToWidth(left.replace(/[\r\n]+/g, " "), max)); + entry.push(`${padding(Math.max(0, detailIndent))}${truncateToWidth(right, Math.max(1, max - detailIndent))}`); } - return entry; + + const metrics = this.#metricsFor(ref, observed); + const usage = metrics ? theme.fg("dim", formatMetrics(metrics)) : theme.fg("dim", "usage —"); + const task = observed?.description ?? observed?.progress?.task ?? ref.activity; + const detailWidth = Math.max(1, max - detailIndent); + const details = task + ? `${theme.fg("muted", sanitizeLine(task, detailWidth))}${theme.fg("dim", theme.sep.dot)}${usage}` + : usage; + for (const wrapped of wrapTextWithAnsi(details, detailWidth)) { + entry.push(`${padding(Math.max(0, detailIndent))}${wrapped}`); + } + if (!hovered) return entry; + return entry.map(lineRow => { + const rowWidth = visibleWidth(lineRow); + return theme.bg("selectedBg", rowWidth < max ? lineRow + padding(max - rowWidth) : lineRow); + }); + } + + #scrollDetails(direction: -1 | 1): void { + this.#detailScrollOffset = Math.max(0, this.#detailScrollOffset + direction * 5); + this.#requestRender(); + } + + #selectRow(index: number): void { + if (index !== this.#selectedRow) { + this.#detailScrollOffset = 0; + this.#detailAgentId = this.#rows[index]?.id; + } + this.#selectedRow = index; + } + + handleWheel(delta: -1 | 1): void { + this.#hoveredRow = null; + if (this.#rows.length > 0) { + this.#selectRow(Math.max(0, Math.min(this.#selectedRow + delta, this.#rows.length - 1))); + } + this.#requestRender(); + } + + hitTest(line: number): number | undefined { + return this.#hitRows[line]; + } + + setHoverIndex(index: number | null): void { + if (index === this.#hoveredRow) return; + this.#hoveredRow = index; + this.#requestRender(); + } + + clickItem(index: number): void { + const selected = this.#rows[index]; + if (!selected) return; + this.#hoveredRow = index; + this.#selectRow(index); + this.#requestRender(); + this.#activateAgent(selected); } #handleTableInput(keyData: string): void { if (matchesKey(keyData, "escape")) { - this.#onDone(); + if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) { + this.#narrowDetailsOpen = false; + this.#requestRender(); + } else { + this.#onDone(); + } + return; + } + if ((matchesKey(keyData, "tab") || keyData === "\t") && !this.#lastRenderWasSplit) { + if (this.#rows.length > 0) this.#narrowDetailsOpen = !this.#narrowDetailsOpen; + this.#requestRender(); + return; + } + if (this.#lastRenderWasSplit || this.#narrowDetailsOpen) { + if (matchesKey(keyData, "pageUp")) { + this.#scrollDetails(-1); + return; + } + if (matchesKey(keyData, "pageDown")) { + this.#scrollDetails(1); + return; + } + } + if (keyData === "t") { + this.#hoveredRow = null; + this.#viewMode = this.#viewMode === "roster" ? "tree" : "roster"; + this.#refreshRows(); + this.#requestRender(); return; } if (matchesKey(keyData, "left")) { + if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) { + this.#narrowDetailsOpen = false; + this.#requestRender(); + return; + } const now = Date.now(); if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) { this.#lastLeftTap = 0; @@ -532,16 +1009,17 @@ export class AgentHubOverlayComponent extends Container { } return; } + this.#hoveredRow = null; if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) { if (this.#rows.length > 0) { - this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); + this.#selectRow(Math.min(this.#selectedRow + 1, this.#rows.length - 1)); } this.#requestRender(); return; } if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) { if (this.#rows.length > 0) { - this.#selectedRow = Math.max(this.#selectedRow - 1, 0); + this.#selectRow(Math.max(this.#selectedRow - 1, 0)); } this.#requestRender(); return; diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index baabe2bb2..0b8b8c428 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -613,9 +613,7 @@ export class AgentTranscriptViewer implements Component { } #statsLine(): string { - const observed: ObservableSession | undefined = this.deps.observers - ?.getSessions() - .find(s => s.id === this.deps.agentId); + const observed: ObservableSession | undefined = this.deps.observers?.getSession(this.deps.agentId); const progress = observed?.progress; if (!progress) return ""; const stats: string[] = []; diff --git a/packages/coding-agent/src/modes/components/bordered-loader.ts b/packages/coding-agent/src/modes/components/bordered-loader.ts index a3aaa81bf..861cbbf54 100644 --- a/packages/coding-agent/src/modes/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/components/bordered-loader.ts @@ -35,7 +35,7 @@ export class BorderedLoader extends Container { this.#loader.handleInput(data); } - dispose(): void { + override dispose(): void { this.#loader.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 315c58245..9110fc6fe 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -466,7 +466,7 @@ export class CustomEditor extends Editor { /** Decorate magic keywords, attachments, and the queue-composer header/list markers. * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential * item markers use the accent color so separate follow-ups remain visible while composing. */ - decorateText = (text: string): string => { + override decorateText = (text: string): string => { const editorText = this.getText(); const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText); const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0; @@ -775,7 +775,7 @@ export class CustomEditor extends Editor { void promise.then(this.#onPasteSettled, this.#onPasteSettled); } - handleInput(data: string): void { + override handleInput(data: string): void { // Serialize behind any in-flight async paste so a trailing Enter / follow-up key can't // submit before the clipboard image reaches `pendingImages` (Codex PR #3602 review). if (this.#pasteInFlight > 0) { diff --git a/packages/coding-agent/src/modes/components/hook-input.ts b/packages/coding-agent/src/modes/components/hook-input.ts index e1fc3a930..203c13b1f 100644 --- a/packages/coding-agent/src/modes/components/hook-input.ts +++ b/packages/coding-agent/src/modes/components/hook-input.ts @@ -81,7 +81,7 @@ export class HookInputComponent extends Container { this.#input.pasteText(text); } - dispose(): void { + override dispose(): void { this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index fa91932ef..2833cb99a 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -135,7 +135,7 @@ class OutlinedList extends Container { this.invalidate(); } - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const borderColor = (text: string) => theme.fg("border", text); const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width))); const innerWidth = Math.max(1, width - 2); @@ -685,7 +685,7 @@ export class HookSelectorComponent extends Container { return super.render(renderWidth); } - dispose(): void { + override dispose(): void { this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index e0d555882..ccad9309c 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -905,7 +905,7 @@ export class SessionSelectorComponent extends Container { * is mounted the list is detached from the child tree, so Container's * child-walking dispose would miss its pending history-merge timer. */ - dispose(): void { + override dispose(): void { this.#sessionList.dispose(); super.dispose(); } @@ -971,7 +971,7 @@ export class SessionSelectorComponent extends Container { * footer is always visible and never drifts as the list window resizes. The * in-editor selector just appends the footer directly. */ - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const lines: string[] = []; for (const child of this.children) { const childLines = child.render(width); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 242196fae..0be56fdaf 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -170,6 +170,26 @@ export class EventController { #prevHideThinking = false; #handlers: AgentSessionEventHandlers; #terminalProgressActive = false; + // Coalescing window for `message_update` events at the subscription boundary. + // `message_update` carries the CUMULATIVE assistant message (every update + // re-lists all content blocks), so when a burst of deltas arrives faster than + // this window only the latest snapshot needs to rebuild streaming state — the + // intermediate rebuilds are redundant work. The TUI already caps the paint + // rate via its own render cadence; this caps the per-token handler work that + // feeds it. Speech stays intact: `#vocalizeDelta` runs at ARRIVAL for every + // delta before the snapshot is coalesced away. + #pendingMessageUpdate: Extract | undefined = undefined; + #messageUpdateTimer: NodeJS.Timeout | undefined = undefined; + /** Tail of the serialized dispatch chain; see #runSerialized. */ + #dispatchTail: Promise = Promise.resolve(); + /** Whether a chained run is currently in flight (awaiting its own awaits). */ + #dispatchInFlight = false; + // Deltas already fed to speech at arrival by the coalescer. `#handleMessageUpdate` + // also vocalizes so the direct `handleEvent` path (tests, session focus replay) + // keeps working — the WeakSet makes the coalesced path speak each delta exactly + // once instead of twice. + #vocalizedMessageUpdates = new WeakSet(); + static readonly #MESSAGE_UPDATE_COALESCE_MS = 33; constructor(private ctx: InteractiveModeContext) { // Enhanced speech (`speech.enhanced`) rewrites blocks through the @@ -253,6 +273,11 @@ export class EventController { } dispose(): void { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + this.#pendingMessageUpdate = undefined; this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.#cancelIdleCompaction(); @@ -425,10 +450,135 @@ export class EventController { } subscribeToAgent(): void { + // Serialize non-update dispatch behind any in-flight handler run: + // AgentSession.#emit fires listeners fire-and-forget (it does not await + // listener promises), so without this a rapid stream tail + // (message_update → message_end → agent_end) could let a later callback + // overtake the coalesced flush's handler mid-await — agent_end removing + // `streamingComponent` before #handleMessageEnd finalizes and records + // the final message (issue #7443 follow-up). When the tail has settled, + // dispatch stays synchronous: the flush's streaming rebuild runs before + // the listener's first await, preserving the timing the coalescing + // tests assert on. `message_update` enqueue is itself synchronous and + // needs no serialization. this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => { - await this.handleEvent(event); + // Coalesce the cumulative `message_update` deltas of a streaming turn + // into at most one handler run per window. `#handleMessageUpdate` is + // synchronous, so without this every token re-runs the whole + // streaming rebuild (splitAssistantMessageToolTimeline, reveal + // setTarget, per-block tool-call reconciliation) even though the TUI + // paints at most ~30fps — at 40-100 tps the handler work then + // dominates the CPU profile of an idle-looking streaming session + // (issue #7443). Only the latest snapshot is meaningful; non-update + // events flush the pending snapshot first so ordering is preserved. + if (event.type === "message_update") { + this.#enqueueMessageUpdate(event); + return; + } + await this.#runSerialized(async () => { + await this.#flushPendingMessageUpdate(); + await this.handleEvent(event); + }); }); } + + /** + * Run `run` in the serialized dispatch chain: every run is its own link on + * the tail, so a burst of events queued behind an in-flight run start one + * after the other, never concurrently. This closes two races (issue #7443 + * follow-up): a rapid stream tail (message_update → message_end → + * agent_end) cannot overtake the coalesced flush mid-await — agent_end + * removing `streamingComponent` before #handleMessageEnd finalizes and + * records the final message — and two+ events landing in the same window + * cannot all resume from one shared await and dispatch in parallel. When + * the chain is drained, `run` starts synchronously (no intermediate + * microtask), preserving the synchronous-flush timing the coalescing + * tests assert on. A rejection propagates to the caller (the session's + * fire-and-forget emit) and the next event starts a fresh chain link + * instead of being dropped. + */ + async #runSerialized(run: () => Promise): Promise { + if (this.#dispatchInFlight) { + // Queue behind the CURRENT tail: the next run starts only after + // the previous one settles. Each waiter gets its own link, so a + // burst cannot fan out from the same shared await. + const link = this.#dispatchTail.then( + () => run(), + () => run(), + ); + this.#dispatchTail = link; + void link.then( + () => { + // Only the tail owner clears the flag: a later chained + // link clears it when it settles as the tail. + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + ); + await link; + return; + } + this.#dispatchInFlight = true; + const link = run(); + this.#dispatchTail = link; + void link.then( + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + ); + await link; + } + + /** + * Queue a streaming `message_update` for the next coalesced handler run. + * Speech is per-delta, so the delta is vocalized at arrival before the + * snapshot is (possibly) superseded by a newer one. + */ + #enqueueMessageUpdate(event: Extract): void { + // Speech is per-delta: every delta is spoken at arrival even when its + // cumulative snapshot is later superseded and never rebuilt. + this.#vocalizeDelta(event); + this.#vocalizedMessageUpdates.add(event); + this.#pendingMessageUpdate = event; + if (this.#messageUpdateTimer) return; + this.#messageUpdateTimer = setTimeout(() => { + this.#messageUpdateTimer = undefined; + // Mirror AgentSession.#emit: attach a catch so a streaming rebuild + // failure surfaces as a logged warning instead of a process-level + // unhandled rejection (the timer path has no listener to attach one). + // Runs inside the serialized dispatch chain so a message_end / + // agent_end landing mid-window cannot overtake this flush (issue + // #7443 follow-up). + void this.#runSerialized(async () => { + await this.#flushPendingMessageUpdate(); + }).catch(err => { + logger.warn("Message update flush rejected", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, EventController.#MESSAGE_UPDATE_COALESCE_MS); + } + + /** + * Run the coalesced `message_update` handler on the latest pending snapshot + * (dropping any superseded intermediates) and clear the queue. Safe to call + * more than once; no-ops when nothing is pending. + */ + async #flushPendingMessageUpdate(): Promise { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + const event = this.#pendingMessageUpdate; + if (!event) return; + this.#pendingMessageUpdate = undefined; + await this.handleEvent(event); + } /** * Clear every transcript-anchored/turn-scoped piece of state. Used by the * session focus proxy when re-pointing the transcript at another session: @@ -436,6 +586,11 @@ export class EventController { * session's transcript and must not bleed into the new one. */ resetTranscriptAnchors(): void { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + this.#pendingMessageUpdate = undefined; this.#resetReadGroup(); this.#lastVisibleBlockCount = 0; this.#renderedCustomMessages.clear(); @@ -814,7 +969,9 @@ export class EventController { async #handleMessageUpdate(event: Extract): Promise { this.#ensureWorkingLoaderWhileStreaming(); - this.#vocalizeDelta(event); + if (!this.#vocalizedMessageUpdates.delete(event)) { + this.#vocalizeDelta(event); + } if (this.ctx.streamingComponent && event.message.role === "assistant") { const unlockedThinkingVisibility = this.ctx.noteDisplayableThinkingContent(event.message); if (unlockedThinkingVisibility) { 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 750bdd193..942b03da8 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -128,7 +128,7 @@ export class ExtensionUiController { }; this.ctx.setToolUIContext(uiContext, true); this.#toolUIContext = uiContext; - this.ctx.session.setUsageFallbackConfirmer?.(confirmation => { + this.ctx.session.setUsageFallbackConfirmer?.((confirmation, signal) => { const reserve = confirmation.remainingPercent === undefined ? "inside the configured reserve margin" @@ -136,6 +136,7 @@ export class ExtensionUiController { return this.showHookConfirm( "Coding-plan reserve reached", `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`, + { signal }, ); }); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index c1875a9af..b3fd2384b 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -110,6 +110,23 @@ const MANUAL_LOGIN_PROMPT = "Paste the authorization code (or full redirect URL) export class SelectorController { constructor(private ctx: InteractiveModeContext) {} + /** + * Mount a primary fullscreen menu through the one polished modal path shared + * by Settings, Model Hub, and Agent Hub. + */ + #showFullscreenMenu(component: Component): OverlayHandle { + const handle = this.ctx.ui.showOverlay(component, { + anchor: "bottom-center", + width: "100%", + maxHeight: "100%", + margin: 0, + fullscreen: true, + }); + this.ctx.ui.setFocus(component); + this.ctx.ui.requestRender(); + return handle; + } + #defaultRoleMutationTail = Promise.resolve(); async #acquireDefaultRoleMutation(): Promise<() => void> { @@ -242,15 +259,7 @@ export class SelectorController { }, }, ); - overlayHandle = this.ctx.ui.showOverlay(selector, { - anchor: "bottom-center", - width: "100%", - maxHeight: "100%", - margin: 0, - fullscreen: true, - }); - this.ctx.ui.setFocus(selector); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(selector); }); } @@ -1023,15 +1032,7 @@ export class SelectorController { initialProviderId: hubOptions.initialProviderId, }, ); - overlayHandle = this.ctx.ui.showOverlay(hub, { - anchor: "bottom-center", - width: "100%", - maxHeight: "100%", - margin: 0, - fullscreen: true, - }); - this.ctx.ui.setFocus(hub); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(hub); } /** /login round-trip for a locked provider; reopen the hub on that provider only after a successful login. */ @@ -2000,27 +2001,23 @@ export class SelectorController { ...this.ctx.keybindings.getKeys("app.agents.hub"), ...this.ctx.keybindings.getKeys("app.session.observe"), ]; - let hub: AgentHubOverlayComponent | undefined; + let overlayHandle: OverlayHandle | undefined; + let closed = false; - // Render the hub inline in the editor slot — the same anchored region - // every other selector (model, session, tree, the `ask` tool) uses — - // rather than a floating overlay. A non-fullscreen overlay composited over - // a live transcript strands a stale copy in native scrollback every time a - // running subagent's progress grows the frame and scrolls the window; the - // hub is opened mid-run, so those copies stacked into a wall of duplicate - // "Agent Hub" frames bleeding the task tree behind them. As an editor-slot - // component it rides the normal append-only commit path: the transcript - // commits above it exactly once and the hub repaints in place. const done = () => { - hub?.dispose(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + if (closed) return; + closed = true; + hub.dispose(); + overlayHandle?.hide(); + // A gated empty Hub may never have been mounted. Restoring editor + // focus in that case would steal focus from a menu opened meanwhile. + if (overlayHandle) this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; - hub = new AgentHubOverlayComponent({ + const hub = new AgentHubOverlayComponent({ observers, + settings: this.ctx.settings, hubKeys, expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"), onDone: done, @@ -2038,30 +2035,24 @@ export class SelectorController { }); const showReadyHub = () => { - // The double-← gesture passes requireContent so it stays inert when - // neither live nor persisted subagents are available. Persisted rows now - // load asynchronously, so defer the gate until that scan has refreshed the - // hub instead of treating the initial empty table as authoritative. + if (closed) return; + // The double-← gesture stays inert when neither live nor persisted + // subagents are available, so wait for discovery before making the gate. if (options?.requireContent && hub.isEmpty) { - hub.dispose(); + done(); return; } - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(hub); - this.ctx.ui.setFocus(hub); - // When the hub was raised by the editor's double-← gesture, prime its own - // close detector so the *next* single ← dismisses it — the two taps that - // opened it were consumed by the editor's detector (issue #4780). + // Prime the detector before the first frame when the editor's double-← + // gesture opened the hub, so the next single ← dismisses it. if (options?.armCloseTap) hub.armCloseTap(); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(hub); }; if (options?.requireContent && hub.isEmpty) { void hub.persistedSubagentsReady.then(showReadyHub); - return; + } else { + showReadyHub(); } - - showReadyHub(); } } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 3da757e7d..8c2c30a01 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -91,11 +91,29 @@ export function printableEvent(event: AgentSessionEvent): unknown { export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise { const { mode, messages = [], initialMessage, initialImages, printThoughts } = options; + // process.stdout.write is fire-and-forget: a large final record (e.g. a + // multi-MB agent_end) can be dropped when the process exits before the pipe + // drains, truncating the record mid-line while the process still exits 0. + // Serialize every stdout write on the previous write's completion callback so + // records stay ordered and honor backpressure, then block shutdown on the + // tail before dispose/exit. Same truncation class as issue #5309 (issue #7635). + let stdoutTail: Promise = Promise.resolve(); + const writeStdoutLine = (text: string): void => { + stdoutTail = stdoutTail.then(() => { + const { promise, resolve, reject } = Promise.withResolvers(); + process.stdout.write(text, err => { + if (err) reject(err); + else resolve(); + }); + return promise; + }); + }; + // Emit session header for JSON mode if (mode === "json") { const header = session.sessionManager.getHeader(); if (header) { - process.stdout.write(`${JSON.stringify(header)}\n`); + writeStdoutLine(`${JSON.stringify(header)}\n`); } } // Set up extensions for print mode (no UI, no command context) @@ -172,7 +190,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti } // In JSON mode, output all events if (mode === "json") { - process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`); + writeStdoutLine(`${JSON.stringify(printableEvent(event))}\n`); } }); @@ -186,12 +204,14 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Send initial message with attachments if (initialMessage !== undefined) { writeTextWorkingIndicator(); + if (mode === "text") session.setTextOutputCommitted(false); await logger.time("print:prompt:initial", () => session.prompt(initialMessage, { images: initialImages })); } // Send remaining messages for (const message of messages) { writeTextWorkingIndicator(); + if (mode === "text") session.setTextOutputCommitted(false); await logger.time("print:prompt:next", () => session.prompt(message)); } @@ -243,23 +263,20 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Output text content for (const content of assistantMsg.content) { if (content.type === "text") { - process.stdout.write(`${sanitizeText(content.text)}\n`); + writeStdoutLine(`${sanitizeText(content.text)}\n`); } else if (printThoughts && content.type === "thinking" && content.thinking.trim().length > 0) { - process.stdout.write(`${sanitizeText(content.thinking)}\n`); + writeStdoutLine(`${sanitizeText(content.thinking)}\n`); } } } + session.setTextOutputCommitted(true); } await session.waitForAdvisorCatchup(PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS); - // Ensure stdout, including late JSON advisor events, is fully flushed before returning. - // This prevents race conditions where the process exits before all output is written. - await new Promise((resolve, reject) => { - process.stdout.write("", err => { - if (err) reject(err); - else resolve(); - }); - }); + // Block shutdown until every serialized stdout write (including the final + // agent_end and late JSON advisor events) has drained; process.exit would + // otherwise discard the buffered tail and truncate the last record. + await stdoutTail; await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS }); } diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts index e94ffa9d9..3074f073b 100644 --- a/packages/coding-agent/src/modes/session-observer-registry.ts +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -91,6 +91,11 @@ export class SessionObserverRegistry { this.#notifyListeners("main"); } + /** Return one tracked session without copying or sorting the registry. */ + getSession(id: string): ObservableSession | undefined { + return this.#sessions.get(id); + } + getSessions(): ObservableSession[] { const sessions = [...this.#sessions.values()]; sessions.sort((a, b) => { diff --git a/packages/coding-agent/src/registry/agent-lifecycle.ts b/packages/coding-agent/src/registry/agent-lifecycle.ts index 3864c60f1..ae8dd13e6 100644 --- a/packages/coding-agent/src/registry/agent-lifecycle.ts +++ b/packages/coding-agent/src/registry/agent-lifecycle.ts @@ -20,18 +20,31 @@ * a superseded revive) can never clobber a newer same-id ref. */ -import { logger } from "@oh-my-pi/pi-utils"; +import * as fs from "node:fs/promises"; +import { logger, untilAborted } from "@oh-my-pi/pi-utils"; import type { AgentSession } from "../session/agent-session"; +import { trackLateCleanup } from "../utils/late-cleanup"; import { type AgentRef, type AgentRefExpectation, AgentRegistry, + getAgentTombstonePath, MAIN_AGENT_ID, type RegistryEvent, } from "./agent-registry"; export type AgentReviver = (expected: AgentRef) => Promise; +const AGENT_RELEASE_GRACE_MS = 5000; + +async function persistAgentTombstone(sessionFile: string): Promise { + try { + await fs.writeFile(getAgentTombstonePath(sessionFile), "", { encoding: "utf8", flag: "wx", mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } +} + /** * Builds a reviver for a `parked` ref restored from disk (Agent Hub scan, * collab mirror, resumed process) that carries a sessionFile but no in-memory @@ -379,13 +392,10 @@ export class AgentLifecycleManager { } if (options?.tombstone) { - // Explicit kill: mark the ref terminal `aborted` and detach the session - // BEFORE disposing. aborted refs must satisfy the AgentRef invariant - // (session === null) so ensureLive / hub focus can never route into a - // disposed session; setting the terminal status first also makes - // createAgentSession's dispose wrapper (unregisterUnlessParked) preserve - // the ref instead of removing it, so a later persisted-subagent rescan - // skips it. The transcript file is left intact (history://). + // Persist the terminal decision before detaching the session. The + // sidecar prevents a later discovery pass from reviving this transcript + // as a fresh parked ref. + if (ref.sessionFile) await persistAgentTombstone(ref.sessionFile); this.#registry.setStatus(id, "aborted", ref); } const live = this.#registry.get(id) === ref ? ref.session : null; @@ -402,11 +412,26 @@ export class AgentLifecycleManager { } /** Teardown everything (process exit / main session dispose). */ - async dispose(): Promise { + async dispose(deadlineAt: number = Date.now() + AGENT_RELEASE_GRACE_MS): Promise { this.#unsubscribe?.(); this.#unsubscribe = undefined; const ids = [...new Set([...this.#adopted.keys(), ...this.#parks.keys()])]; - await Promise.all(ids.map(id => this.release(id))); + await Promise.all( + ids.map(async id => { + const release = this.release(id).then(() => {}); + try { + await untilAborted(AbortSignal.timeout(Math.max(0, deadlineAt - Date.now())), () => release); + } catch (error) { + if (Date.now() >= deadlineAt) { + trackLateCleanup(release, { id, resource: "adopted-agent" }); + } + logger.warn("Agent cleanup exceeded its deadline", { + id, + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); this.#revivals.clear(); this.#parks.clear(); this.#persistedReviverFactory = undefined; diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index 7baf1dd05..2b5929184 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -14,6 +14,13 @@ import { oneLineLabel } from "../task/types"; export const MAIN_AGENT_ID = "Main"; +/** Sidecar marker retained beside a child transcript after an explicit kill. */ +export const AGENT_TOMBSTONE_SUFFIX = ".tombstone"; + +export function getAgentTombstonePath(sessionFile: string): string { + return `${sessionFile}${AGENT_TOMBSTONE_SUFFIX}`; +} + /** * - `running`: a turn is in flight. * - `idle`: live AgentSession in memory, awaiting work. Finished agents are @@ -22,6 +29,8 @@ export const MAIN_AGENT_ID = "Main"; * - `aborted`: hard-killed, terminal. */ export type AgentStatus = "running" | "idle" | "parked" | "aborted"; +/** Provenance of a displayed duration: active runtime, transcript span, or unavailable. */ +export type AgentDurationKind = "active" | "span" | "unknown"; /** * - `main`/`sub`: the user-facing agent tree (driving agent + task subagents). * - `advisor`: a passive review transcript persisted like a subagent for usage @@ -30,6 +39,35 @@ export type AgentStatus = "running" | "idle" | "parked" | "aborted"; */ export type AgentKind = "main" | "sub" | "advisor"; +/** Persisted per-agent totals reconstructed from the child session transcript. */ +export interface AgentMetricsSummary { + tokens: number; + requests: number; + tools: number; + cost: number; + durationMs: number; + durationKind?: AgentDurationKind; + contextTokens?: number; + contextWindow?: number; +} + +/** Historical identity and telemetry that remain available after the live session is disposed. */ +export interface AgentHistorySummary { + agent?: string; + modelRole?: string; + resolvedModel?: string; + /** Whether the last resolved model was selected by retry fallback routing. */ + resolvedModelIsFallback?: boolean; + metrics?: AgentMetricsSummary; + readOnly?: boolean; + /** Durable task output artifact, when the executor wrote one. */ + outputPath?: string; + /** Captured isolated-worktree patch, when patch capture succeeded. */ + patchPath?: string; + /** Isolated branch identity, when branch-mode capture succeeded. */ + branchName?: string; +} + export interface AgentRef { id: string; displayName: string; @@ -43,6 +81,8 @@ export interface AgentRef { lastActivity: number; /** Short gist of what the agent is currently doing (latest intent or tool), for the work-aware roster. Display-only. */ activity?: string; + /** Persisted identity and telemetry restored after the live observer is gone. */ + history?: AgentHistorySummary; } export type AgentRefExpectation = AgentRef | AgentSession; @@ -50,6 +90,7 @@ export type AgentRefExpectation = AgentRef | AgentSession; export type RegistryEvent = | { type: "registered"; ref: AgentRef } | { type: "status_changed"; ref: AgentRef } + | { type: "metadata_changed"; ref: AgentRef } | { type: "removed"; ref: AgentRef }; type RegistryListener = (event: RegistryEvent) => void; @@ -62,6 +103,14 @@ export interface RegisterInput { session: AgentSession | null; sessionFile?: string | null; status?: AgentStatus; + /** Last persisted task summary, when restoring a historical agent. */ + activity?: string; + /** Original registration timestamp, when known from persisted history. */ + createdAt?: number; + /** Last transcript activity timestamp, when known from persisted history. */ + lastActivity?: number; + /** Persisted identity and telemetry restored after the live observer is gone. */ + history?: AgentHistorySummary; } export class AgentRegistry { @@ -96,8 +145,10 @@ export class AgentRegistry { status: input.status ?? "running", session: input.session, sessionFile: input.sessionFile ?? null, - createdAt: now, - lastActivity: now, + createdAt: input.createdAt ?? now, + lastActivity: input.lastActivity ?? now, + activity: input.activity, + history: input.history, }; this.#refs.set(ref.id, ref); this.#emit({ type: "registered", ref }); @@ -116,6 +167,18 @@ export class AgentRegistry { return current === expected && current.status === "parked" && !current.session ? current : undefined; } + /** Attach transcript-derived identity and telemetry without changing lifecycle state. */ + setHistory(id: string, history: AgentHistorySummary, expectedSessionFile?: string): boolean { + const ref = this.#refs.get(id); + if (!ref || (expectedSessionFile !== undefined && ref.sessionFile !== expectedSessionFile)) return false; + const definedHistory = Object.fromEntries( + Object.entries(history).filter(([, value]) => value !== undefined), + ) as AgentHistorySummary; + ref.history = { ...ref.history, ...definedHistory }; + this.#emit({ type: "metadata_changed", ref }); + return true; + } + setStatus(id: string, status: AgentStatus, expected?: AgentRefExpectation): boolean { const ref = this.#refs.get(id); if (!ref || !this.#matchesExpected(ref, expected)) return false; diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 4804919bc..79b19a417 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -1,38 +1,312 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../advisor/transcript-recorder"; -import { SessionManager } from "../session/session-manager"; +import { resolveExplicitModelRole } from "../config/model-resolver"; +import { EPHEMERAL_MODEL_CHANGE_ROLE } from "../session/session-entries"; +import { visitEntriesFromFileStream } from "../session/session-loader"; +import { loadBundledAgents } from "../task/agents"; +import { isReadOnlyAgent } from "../task/read-only-policy"; import { persistedVibeChildIds } from "../vibe/runtime"; -import { type AgentRegistry, MAIN_AGENT_ID } from "./agent-registry"; +import { + type AgentHistorySummary, + type AgentMetricsSummary, + type AgentRegistry, + getAgentTombstonePath, + MAIN_AGENT_ID, +} from "./agent-registry"; + +/** Maximum prefix entries inspected for task metadata. */ +const MAX_METADATA_LINES = 64; + +interface PersistedAgentMetadata { + activity?: string; + createdAt?: number; + lastActivity?: number; + history?: AgentHistorySummary; +} + +interface PersistedTranscript { + id: string; + sessionFile: string; + createdAt?: number; + lastActivity?: number; +} + +function recordOf(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function timestampOf(value: unknown): number | undefined { + if (typeof value !== "string") return undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function summarizePersistedTask(task: string): string | undefined { + const withoutPreamble = task.replace(/^Complete the assignment below,\s*thoroughly:\s*/i, ""); + const lines = withoutPreamble.split(/\r?\n/); + const targetIndex = lines.findIndex(line => line.trim().toLowerCase() === "# target"); + const targetLines: string[] = []; + if (targetIndex >= 0) { + for (const line of lines.slice(targetIndex + 1)) { + if (line.trimStart().startsWith("# ")) break; + targetLines.push(line); + } + } + const summary = (targetLines.length > 0 ? targetLines : lines).join(" ").replace(/\s+/g, " ").trim(); + return summary ? summary.slice(0, 1_000) : undefined; +} + +function finiteNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function inferBundledAgent(systemPrompt: string): { agent?: string; modelRole?: string; readOnly?: boolean } { + const matches = loadBundledAgents().filter(agent => { + const rolePrompt = agent.systemPrompt.trim(); + return rolePrompt.length > 0 && systemPrompt.includes(rolePrompt); + }); + // `task` and `sonic` intentionally share a prompt body. Ambiguous historical + // prompts stay unlabelled rather than inventing provenance. + if (matches.length !== 1) return {}; + const [agent] = matches; + return { + agent: agent.name, + modelRole: resolveExplicitModelRole(agent.model), + readOnly: isReadOnlyAgent(agent), + }; +} + +function usageTokens(usage: Record): number { + const computed = finiteNumber(usage.input) + finiteNumber(usage.output) + finiteNumber(usage.cacheWrite); + return computed > 0 ? computed : finiteNumber(usage.totalTokens); +} + +interface AssistantMetrics { + tokens: number; + tools: number; + cost: number; + contextTokens?: number; + resolvedModel?: string; +} + +function assistantMetrics(message: Record): AssistantMetrics { + const usage = recordOf(message.usage) ?? {}; + const cost = recordOf(usage.cost); + const content = Array.isArray(message.content) ? message.content : []; + const provider = typeof message.provider === "string" ? message.provider : undefined; + const model = typeof message.model === "string" ? message.model : undefined; + return { + tokens: usageTokens(usage), + tools: content.filter(part => recordOf(part)?.type === "toolCall").length, + cost: finiteNumber(cost?.total), + contextTokens: finiteNumber(usage.totalTokens) || undefined, + resolvedModel: provider && model ? `${provider}/${model}` : undefined, + }; +} + +async function readPersistedAgentHistory( + transcript: PersistedTranscript, + shouldContinue: () => boolean, +): Promise { + const parents = new Map(); + const assistantById = new Map(); + const modelChangeById = new Map(); + let leafId: string | undefined; + let leafTimestamp: number | undefined; + try { + await visitEntriesFromFileStream( + transcript.sessionFile, + entry => { + const record = recordOf(entry); + if (!record) return; + const id = typeof record.id === "string" ? record.id : undefined; + if (!id) return; + const parentId = typeof record.parentId === "string" ? record.parentId : undefined; + parents.set(id, parentId); + leafId = id; + const parsedTimestamp = timestampOf(record.timestamp); + if (parsedTimestamp !== undefined) leafTimestamp = parsedTimestamp; + if (record.type === "model_change" && typeof record.model === "string") { + modelChangeById.set(id, { + model: record.model, + role: typeof record.role === "string" ? record.role : undefined, + resolvedModelIsFallback: record.resolvedModelIsFallback === true, + }); + return; + } + if (record.type !== "message") return; + const message = recordOf(record.message); + if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); + }, + { shouldContinue }, + ); + } catch { + return {}; + } + + const metrics: AgentMetricsSummary = { + tokens: 0, + requests: 0, + tools: 0, + cost: 0, + durationMs: Math.max( + 0, + (leafTimestamp ?? transcript.lastActivity ?? transcript.createdAt ?? 0) - + (transcript.createdAt ?? leafTimestamp ?? 0), + ), + durationKind: "span", + }; + let resolvedModel: string | undefined; + let resolvedModelIsFallback: boolean | undefined; + let modelRole: string | undefined; + let contextTokens: number | undefined; + let modelChangeFound = false; + const visited = new Set(); + for (let id = leafId; id && !visited.has(id); id = parents.get(id)) { + visited.add(id); + const modelChange = modelChangeById.get(id); + if (modelChange && !modelChangeFound) { + modelChangeFound = true; + resolvedModel = modelChange.model; + resolvedModelIsFallback = modelChange.resolvedModelIsFallback; + if (modelChange.role && modelChange.role !== EPHEMERAL_MODEL_CHANGE_ROLE) { + modelRole = modelChange.role; + } + } + const assistant = assistantById.get(id); + if (!assistant) continue; + if (!modelChangeFound && resolvedModel === undefined && assistant.resolvedModel) { + resolvedModel = assistant.resolvedModel; + } + metrics.requests++; + metrics.tokens += assistant.tokens; + metrics.tools += assistant.tools; + metrics.cost += assistant.cost; + contextTokens ??= assistant.contextTokens; + } + if (contextTokens !== undefined) metrics.contextTokens = contextTokens; + return { + ...(metrics.requests > 0 ? { metrics } : {}), + ...(resolvedModel ? { resolvedModel, resolvedModelIsFallback } : {}), + ...(modelRole ? { modelRole } : {}), + }; +} /** - * Child ids owned by the Vibe roster persisted in this session file. Vibe - * workers are revived through the Vibe registry's own journal, so the generic - * persisted-subagent scan must not register them as plain `sub` refs. + * Read only the small session prefix needed by the Hub. A subagent's first + * `session_init` is written before its conversation, so this never walks a + * multi-megabyte historical transcript just to populate one roster row. */ -async function readPersistedVibeChildIds(sessionFile: string): Promise> { - let sessionManager: SessionManager; +async function readPersistedAgentMetadata(sessionFile: string): Promise { + const stat = fs.promises.stat(sessionFile).catch(() => undefined); + const artifactBase = sessionFile.slice(0, -".jsonl".length); + const outputPath = `${artifactBase}.md`; + const patchPath = `${artifactBase}.patch`; + const artifactFiles = Promise.all([Bun.file(outputPath).exists(), Bun.file(patchPath).exists()]); + let createdAt: number | undefined; + let activity: string | undefined; + let history: AgentHistorySummary = {}; try { - sessionManager = await SessionManager.open(sessionFile, undefined, undefined, { suppressBreadcrumb: true }); + await visitEntriesFromFileStream( + sessionFile, + entry => { + const record = recordOf(entry); + if (!record) return; + if (record.type === "session") { + createdAt ??= timestampOf(record.timestamp); + return; + } + if (record.type === "model_change") { + if (typeof record.model === "string") history.resolvedModel = record.model; + if (typeof record.role === "string" && record.role !== EPHEMERAL_MODEL_CHANGE_ROLE) { + history.modelRole = record.role; + } + if (typeof record.resolvedModelIsFallback === "boolean") { + history.resolvedModelIsFallback = record.resolvedModelIsFallback; + } + return; + } + if (record.type !== "session_init") return; + createdAt ??= timestampOf(record.timestamp); + if (typeof record.task === "string") activity = summarizePersistedTask(record.task); + const inferred = typeof record.systemPrompt === "string" ? inferBundledAgent(record.systemPrompt) : {}; + history = { + ...history, + ...inferred, + agent: typeof record.agent === "string" ? record.agent : inferred.agent, + modelRole: + typeof record.modelRole === "string" ? record.modelRole : (history.modelRole ?? inferred.modelRole), + resolvedModel: typeof record.resolvedModel === "string" ? record.resolvedModel : history.resolvedModel, + readOnly: typeof record.readOnly === "boolean" ? record.readOnly : inferred.readOnly, + }; + return false; + }, + { maxRecords: MAX_METADATA_LINES }, + ); + } catch { + // A readable transcript is still useful even when its optional metadata + // prefix is malformed. + } + const [file, [hasOutput, hasPatch]] = await Promise.all([stat, artifactFiles]); + return { + activity, + createdAt: createdAt ?? file?.birthtimeMs, + lastActivity: file?.mtimeMs, + history: { + ...history, + ...(hasOutput ? { outputPath } : {}), + ...(hasPatch ? { patchPath } : {}), + }, + }; +} + +async function readPersistedVibeChildIds(sessionFile: string, shouldContinue: () => boolean): Promise> { + const ids = new Set(); + try { + await visitEntriesFromFileStream( + sessionFile, + entry => { + for (const id of persistedVibeChildIds([entry])) ids.add(id); + }, + { shouldContinue }, + ); + return ids; } catch { return new Set(); } - try { - return persistedVibeChildIds(sessionManager.getEntries()); - } finally { - await sessionManager.close(); - } } /** Register persisted subagent and advisor transcripts as parked registry refs. */ export async function registerPersistedSubagents( registry: AgentRegistry, sessionFile: string | null | undefined, + options: { shouldContinue?: () => boolean } = {}, ): Promise { if (!sessionFile?.endsWith(".jsonl")) return; - const vibeOwnedIds = await readPersistedVibeChildIds(sessionFile); + const shouldContinue = options.shouldContinue ?? (() => true); + if (!shouldContinue()) return; + const vibeOwnedIds = await readPersistedVibeChildIds(sessionFile, shouldContinue); + if (!shouldContinue()) return; const root = sessionFile.slice(0, -6); - await registerPersistedSubagentsFromDir(registry, root, undefined, vibeOwnedIds); + const transcripts: PersistedTranscript[] = []; + await registerPersistedSubagentsFromDir(registry, root, undefined, vibeOwnedIds, transcripts, shouldContinue); + if (!shouldContinue()) return; + let nextTranscript = 0; + const workers = Array.from({ length: Math.min(4, transcripts.length) }, async () => { + for (;;) { + if (!shouldContinue()) return; + const index = nextTranscript++; + const transcript = transcripts[index]; + if (!transcript) return; + const history = await readPersistedAgentHistory(transcript, shouldContinue); + if (!shouldContinue()) return; + registry.setHistory(transcript.id, history, transcript.sessionFile); + } + }); + await Promise.all(workers); } async function registerPersistedSubagentsFromDir( @@ -40,14 +314,25 @@ async function registerPersistedSubagentsFromDir( dir: string, parentId: string | undefined, vibeOwnedIds: ReadonlySet, + transcripts: PersistedTranscript[], + shouldContinue: () => boolean, ): Promise { + if (!shouldContinue()) return; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; } + if (!shouldContinue()) return; + let entriesSinceYield = 0; for (const entry of entries) { + if (!shouldContinue()) return; + if (++entriesSinceYield >= 16) { + entriesSinceYield = 0; + await Bun.sleep(0); + } + if (!shouldContinue()) return; if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue; const sessionFile = path.join(dir, entry.name); // The advisor transcript is observability-only: register it as a non-peer @@ -66,6 +351,8 @@ async function registerPersistedSubagentsFromDir( // user task literally named `/advisor`): leave it, skip the advisor. if (existing && existing.kind !== "advisor") continue; if (existing?.sessionFile !== sessionFile) { + const metadata = await readPersistedAgentMetadata(sessionFile); + if (!shouldContinue()) return; // The id is reused across `/new`; refresh it to the current session's file. if (existing) registry.unregister(advisorId); registry.register({ @@ -75,14 +362,34 @@ async function registerPersistedSubagentsFromDir( parentId: owner, session: null, sessionFile, + activity: metadata.activity, + createdAt: metadata.createdAt, + lastActivity: metadata.lastActivity, + history: { ...metadata.history, readOnly: true }, status: "parked", }); + transcripts.push({ + id: advisorId, + sessionFile, + createdAt: metadata.createdAt, + lastActivity: metadata.lastActivity, + }); } continue; } const id = entry.name.slice(0, -6); if (vibeOwnedIds.has(id) && registry.get(id)?.sessionFile !== sessionFile) continue; + let tombstoned = false; + try { + await fs.promises.access(getAgentTombstonePath(sessionFile)); + tombstoned = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue; + } + if (!shouldContinue()) return; if (!registry.get(id)) { + const metadata = await readPersistedAgentMetadata(sessionFile); + if (!shouldContinue()) return; registry.register({ id, displayName: id, @@ -90,9 +397,27 @@ async function registerPersistedSubagentsFromDir( parentId: parentId ?? MAIN_AGENT_ID, session: null, sessionFile, - status: "parked", + activity: metadata.activity, + createdAt: metadata.createdAt, + lastActivity: metadata.lastActivity, + history: metadata.history, + status: tombstoned ? "aborted" : "parked", + }); + const ref = registry.get(id); + transcripts.push({ + id, + sessionFile, + createdAt: ref?.createdAt, + lastActivity: ref?.lastActivity, }); } - await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id, vibeOwnedIds); + await registerPersistedSubagentsFromDir( + registry, + path.join(dir, id), + id, + vibeOwnedIds, + transcripts, + shouldContinue, + ); } } diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4ee9a24c5..3f3ae2bcc 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2283,9 +2283,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro } } const usageReservePolicy = settings.get("retry.usageReservePolicy"); + const modelFallbackEnabled = settings.get("retry.modelFallback"); if ( - (hasUsageFallbackCandidate || usageReservePolicy === "fail-closed") && - settings.get("retry.modelFallback") && + ((modelFallbackEnabled && (hasUsageFallbackCandidate || usageFallbackTriggered)) || + usageReservePolicy === "fail-closed") && settings.get("retry.usageAwareFallback") ) { let usageHealth: ModelUsageHealth | undefined; @@ -2308,8 +2309,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro `Usage depleted for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`, ); } - usageFallbackTriggered = true; - continue; + if (modelFallbackEnabled) { + usageFallbackTriggered = true; + continue; + } } if (usageHealth?.state === "reserve") { if (usageReservePolicy === "fail-closed") { @@ -2317,7 +2320,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro `Usage reserve reached for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`, ); } - if (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) { + if ( + modelFallbackEnabled && + (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) + ) { usageFallbackTriggered = true; continue; } diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 51a2e6fc8..6d7241b3a 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -92,6 +92,14 @@ export interface UsageFallbackConfirmation { remainingPercent: number | undefined; } +/** + * Confirms whether a reserve-triggered model fallback may proceed. + * + * Interactive callers use the confirmation details to present the pending + * route change; aborting `signal` cancels that pending confirmation. + */ +export type UsageFallbackConfirmer = (confirmation: UsageFallbackConfirmation, signal: AbortSignal) => Promise; + /** Identifies a retry fallback chain already entered during startup model resolution. */ export interface InitialRetryFallbackState { /** Role whose configured primary was unavailable. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b86ad9bdb..9a34d5da4 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -58,7 +58,6 @@ import type { ImageContent, Message, Model, - ModelUsageHealth, OAuthAccountIdentity, ProviderSessionState, ResetCreditAccountStatus, @@ -83,6 +82,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; import { $env, + APP_NAME, escapeXmlText, formatDuration, getAgentDbPath, @@ -101,7 +101,7 @@ import { type AdvisorConfig, type AdvisorRuntimeStatus, loadAdvisorTranscriptCos import { type AsyncJob, AsyncJobManager } from "../async"; import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode"; import type { ModelRegistry } from "../config/model-registry"; -import { type ResolvedModelRoleValue, resolveModelOverride } from "../config/model-resolver"; +import type { ResolvedModelRoleValue } from "../config/model-resolver"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import { buildServiceTierByFamily } from "../config/service-tier"; import type { Settings, SkillsSettings } from "../config/settings"; @@ -230,7 +230,7 @@ import type { SessionHandoffOptions, SessionOAuthAccountList, SessionStats, - UsageFallbackConfirmation, + UsageFallbackConfirmer, } from "./agent-session-types"; import { ASYNC_INLINE_RESULT_MAX_CHARS, @@ -312,7 +312,6 @@ import { queueChipText, toRestoredQueuedMessage, } from "./queued-messages"; -import { formatRetryFallbackSelector, type RetryFallbackSelector } from "./retry-fallback-chains"; import { type AdvisorStats, SessionAdvisors, type SessionAdvisorsHost } from "./session-advisors"; import type { BuildSessionContextOptions, SessionContext } from "./session-context"; import { getRestorableSessionModels } from "./session-context"; @@ -447,6 +446,7 @@ export class AgentSession { // Event subscription state #unsubscribeAgent?: () => void; #cancelExitRecorder?: () => void; + #cancelFatalRecoveryHint?: () => void; #exitRecorded = false; #unsubscribeAppendOnly?: () => void; #unsubscribeModelRoles?: () => void; @@ -485,6 +485,7 @@ export class AgentSession { // Retry state readonly #recovery: TurnRecovery; + #textOutputCommitted = true; #planModeReminderCount = 0; #planModeReminderAwaitingProgress = false; readonly #todo: TodoTracker; @@ -558,9 +559,13 @@ export class AgentSession { // Model registry for API key resolution #modelRegistry: ModelRegistry; - #usageFallbackConfirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined; - #usageReserveApprovedSelector: string | undefined; + #usageFallbackConfirmer: UsageFallbackConfirmer | undefined; #usagePreflightAbortControllers = new Set(); + #queuedMessageDrainBlocked = false; + #usagePreflightReadyForNextModelCall = false; + #usagePreflightReadyModel: Model | undefined; + #detachUsageBeforeQueueDequeue: (() => void) | undefined; + #detachUsageBeforeModelCall: (() => void) | undefined; #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise; #onPayload: SimpleStreamOptions["onPayload"] | undefined; @@ -768,8 +773,10 @@ export class AgentSession { !this.#canAutoContinueForFollowUp() ? [...this.agent.peekFollowUpQueue()] : []; + const parkedQueueDrainBlocked = parkedFollowUps.length > 0 && this.#queuedMessageDrainBlocked; if (parkedFollowUps.length > 0) { this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []); + if (parkedQueueDrainBlocked) this.#queuedMessageDrainBlocked = false; } let finishObservation: ((error?: unknown) => void | Promise) | undefined; try { @@ -805,6 +812,7 @@ export class AgentSession { [...this.agent.peekSteeringQueue()], [...parkedFollowUps, ...this.agent.peekFollowUpQueue()], ); + this.#queuedMessageDrainBlocked ||= parkedQueueDrainBlocked; } this.#endInFlight(async () => { try { @@ -830,6 +838,7 @@ export class AgentSession { steering.filter(m => !isAdvisorCard(m)), followUp.filter(m => !isAdvisorCard(m)), ); + this.#reconcileQueuedMessageDrain(); return cards; } @@ -1025,6 +1034,7 @@ export class AgentSession { modelRegistry: this.#modelRegistry, configWarnings: this.configWarnings, model: () => this.model, + textOutputCommitted: () => this.#textOutputCommitted, thinkingLevel: () => this.thinkingLevel, configuredThinkingLevel: () => this.configuredThinkingLevel(), setThinkingLevel: level => this.setThinkingLevel(level), @@ -1050,6 +1060,31 @@ export class AgentSession { withBashBranchTransition: operation => this.#bash.withBranchTransition(operation), }; this.#recovery = new TurnRecovery(recoveryHost, { initialRetryFallback: config.initialRetryFallback }); + this.#detachUsageBeforeQueueDequeue = this.agent.addBeforeQueuedMessageDequeueHook(async signal => { + if ( + !this.settings.get("retry.usageAwareFallback") || + (this.#usagePreflightReadyForNextModelCall && this.#usagePreflightReadyModel === this.model) + ) { + return; + } + if (!(await this.#runQueuedUsageAwarePreflight(signal))) { + signal?.throwIfAborted(); + throw new DOMException("Usage preflight cancelled", "AbortError"); + } + }); + this.#detachUsageBeforeModelCall = this.agent.addBeforeModelCallHook(async signal => { + if (!this.settings.get("retry.usageAwareFallback")) return; + if (this.#usagePreflightReadyForNextModelCall) { + const checkedModel = this.#usagePreflightReadyModel; + this.#usagePreflightReadyForNextModelCall = false; + this.#usagePreflightReadyModel = undefined; + if (checkedModel === this.model) return; + } + if (!(await this.#runUsageAwarePreflight(signal))) { + signal?.throwIfAborted(); + throw new DOMException("Usage preflight cancelled", "AbortError"); + } + }); const statsHost: SessionStatsTrackerHost = { session: this, agent: this.agent, @@ -1343,6 +1378,14 @@ export class AgentSession { this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => { this.#recordSessionExit(reason); }); + this.#cancelFatalRecoveryHint = postmortem.registerFatalRecoveryHint(() => { + const sessionId = this.sessionManager.getSessionId(); + if (!sessionId || !this.sessionManager.getSessionFile()) return undefined; + return { + label: this.#agentId ?? (this.#agentKind === "main" ? "Main" : "Agent"), + command: `${APP_NAME} --resume ${sessionId}`, + }; + }); const advisorsHost: SessionAdvisorsHost = { agent: this.agent, @@ -2658,14 +2701,21 @@ export class AgentSession { // outside the session transcript (issue #6177). logProviderTurnError(msg); - // Invalidate GitHub Copilot credentials on auth failure so stale tokens - // aren't reused on the next request - if ( - msg.stopReason === "error" && - msg.provider === "github-copilot" && - AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed) - ) { - await this.#modelRegistry.authStorage.remove("github-copilot"); + // Invalidate GitHub Copilot credentials on a hard auth failure (401, or an + // expired/revoked token) so stale tokens aren't reused on the next request. + // Account usage caps and concurrency caps leave the credential valid: the + // former rotates until its reset window, while the latter is retried after + // a short backoff without touching the credential pool. + if (msg.stopReason === "error" && msg.provider === "github-copilot") { + const errorId = AIError.classifyMessage(msg); + const isConcurrencyCap = AIError.parseRateLimitReason(msg.errorMessage ?? "") === "CONCURRENT_LIMIT"; + if ( + AIError.is(errorId, AIError.Flag.AuthFailed) && + !AIError.is(errorId, AIError.Flag.UsageLimit) && + !isConcurrencyCap + ) { + await this.#modelRegistry.authStorage.remove("github-copilot"); + } } if (this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) { @@ -2967,19 +3017,17 @@ export class AgentSession { this.#beginInFlight(); try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); - if ( - this.settings.get("retry.modelFallback") && - this.settings.get("retry.usageAwareFallback") && - !(await this.#runUsageAwarePreflight()) - ) { - this.#skipAgentContinue("session-unavailable", options); - return; - } if (signal.aborted || this.#isDisposed) { this.#skipAgentContinue("post-restore-unavailable", options); return; } - await this.agent.continue(); + if (this.settings.get("retry.usageAwareFallback")) { + if (!(await this.#runQueuedUsageAwarePreflight(signal))) { + this.#skipAgentContinue("session-unavailable", options); + return; + } + } + await this.agent.continue(signal); } catch (error) { logger.warn("agent.continue failed after scheduling", { error: error instanceof Error ? error.message : String(error), @@ -2987,6 +3035,7 @@ export class AgentSession { }); options?.onError?.(error); } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#endInFlight(); } }, @@ -3605,6 +3654,12 @@ export class AgentSession { */ beginDispose(): void { this.#isDisposed = true; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; + this.#detachUsageBeforeQueueDequeue?.(); + this.#detachUsageBeforeQueueDequeue = undefined; + this.#detachUsageBeforeModelCall?.(); + this.#detachUsageBeforeModelCall = undefined; this.#memory.cancelLocalMemoryStartup(); this.#titleGenerationAbortController.abort(); this.#abortAutolearnCapture(); @@ -3713,6 +3768,8 @@ export class AgentSession { this.#recordSessionExit(options.reason ?? "dispose"); this.#cancelExitRecorder?.(); this.#cancelExitRecorder = undefined; + this.#cancelFatalRecoveryHint?.(); + this.#cancelFatalRecoveryHint = undefined; try { await emitSessionShutdownEvent(this.#extensionRunner); } catch (error) { @@ -3926,176 +3983,71 @@ export class AgentSession { } /** Install the interactive decision surface for reserve-triggered model changes. */ - setUsageFallbackConfirmer( - confirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined, - ): void { + setUsageFallbackConfirmer(confirmer: UsageFallbackConfirmer | undefined): void { this.#usageFallbackConfirmer = confirmer; } - async #runUsageAwarePreflight(): Promise { + #allowQueuedMessageDrainRetry(): void { + this.#queuedMessageDrainBlocked = false; + } + + #reconcileQueuedMessageDrain(): void { + if (!this.agent.hasQueuedMessages()) { + this.#queuedMessageDrainBlocked = false; + } + } + + async #runQueuedUsageAwarePreflight(signal?: AbortSignal): Promise { + try { + const allowed = await this.#runUsageAwarePreflight(signal); + this.#usagePreflightReadyForNextModelCall = allowed; + this.#usagePreflightReadyModel = allowed ? this.model : undefined; + this.#queuedMessageDrainBlocked = !allowed && this.agent.hasQueuedMessages(); + return allowed; + } catch (error) { + this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages(); + throw error; + } + } + + async #runUsageAwarePreflightForNextModelCall(signal?: AbortSignal): Promise { + const allowed = await this.#runUsageAwarePreflight(signal); + this.#usagePreflightReadyForNextModelCall = allowed; + this.#usagePreflightReadyModel = allowed ? this.model : undefined; + return allowed; + } + + async #runUsageAwarePreflight(signal?: AbortSignal): Promise { + if (signal?.aborted) return false; const generation = this.#promptGeneration; + const controller = new AbortController(); + const onAbort = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", onAbort, { once: true }); this.#usagePreflightAbortControllers.add(controller); try { - await this.#maybeApplyUsageAwareFallback(controller.signal); - return !controller.signal.aborted && this.#promptGeneration === generation; - } catch (error) { - if (controller.signal.aborted || this.#promptGeneration !== generation) return false; - throw error; + while (true) { + const model = this.model; + try { + const fallbackCommitted = await this.#recovery.maybeApplyUsageAwareFallback( + controller.signal, + this.#usageFallbackConfirmer, + ); + if (fallbackCommitted) return true; + if (controller.signal.aborted || this.#promptGeneration !== generation) return false; + if (this.model === model || modelsAreEqual(this.model, model)) return true; + } catch (error) { + if (controller.signal.aborted || this.#promptGeneration !== generation) return false; + if (this.model !== model && !modelsAreEqual(this.model, model)) continue; + throw error; + } + } } finally { + signal?.removeEventListener("abort", onAbort); this.#usagePreflightAbortControllers.delete(controller); } } - async #confirmUsageFallback(confirmation: UsageFallbackConfirmation, signal: AbortSignal): Promise { - const confirmer = this.#usageFallbackConfirmer; - if (!confirmer || signal.aborted) return false; - const aborted = Promise.withResolvers(); - const onAbort = () => aborted.resolve(false); - signal.addEventListener("abort", onAbort, { once: true }); - try { - return await Promise.race([confirmer(confirmation), aborted.promise]); - } finally { - signal.removeEventListener("abort", onAbort); - } - } - - async #maybeApplyUsageAwareFallback(signal: AbortSignal): Promise { - if (!this.settings.get("retry.modelFallback") || !this.settings.get("retry.usageAwareFallback")) return; - const currentModel = this.model; - if (!currentModel) return; - const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel); - let health: ModelUsageHealth; - try { - health = await this.#modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, { - modelId: currentModel.id, - sessionId: this.sessionId, - baseUrl: currentModel.baseUrl, - reserveFraction: this.settings.get("retry.usageReservePct") / 100, - signal, - }); - } catch (error) { - logger.debug("Usage-aware runtime preflight failed open", { - provider: currentModel.provider, - model: currentModel.id, - error: String(error), - }); - return; - } - if (signal.aborted) return; - - if (health.state === "healthy") { - this.#usageReserveApprovedSelector = undefined; - const selected = health.accounts.find(account => account.selected); - if (selected && selected.state !== "healthy" && health.accounts.some(account => account.state === "healthy")) { - this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( - currentModel.provider, - this.sessionId, - ); - } - return; - } - if (health.state === "unknown") { - this.#usageReserveApprovedSelector = undefined; - return; - } - const reservePolicy = this.settings.get("retry.usageReservePolicy"); - if (reservePolicy === "fail-closed") { - const condition = health.state === "reserve" ? "reserve reached" : "usage depleted"; - throw new Error(`${condition} for ${currentSelector}; reserve policy is fail-closed.`); - } - - const role = this.#recovery.resolveRetryFallbackRole(currentSelector, currentModel); - if (!role) return; - let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; - for (const candidate of this.#recovery.findRetryFallbackCandidates(role, currentSelector, currentModel)) { - if (this.#recovery.isRetryFallbackSelectorSuppressed(candidate)) continue; - const resolved = resolveModelOverride([candidate.raw], this.#modelRegistry, this.settings); - const candidateModel = resolved.model ?? this.#modelRegistry.find(candidate.provider, candidate.id); - if (!candidateModel) continue; - if (!this.#modelRegistry.hasConfiguredAuth(candidateModel)) continue; - try { - const candidateHealth = await this.#modelRegistry.authStorage.getModelUsageHealth(candidateModel.provider, { - modelId: candidateModel.id, - sessionId: this.sessionId, - baseUrl: candidateModel.baseUrl, - reserveFraction: this.settings.get("retry.usageReservePct") / 100, - signal, - }); - if (signal.aborted) return; - if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; - if (candidateHealth.state === "healthy") { - const selected = candidateHealth.accounts.find(account => account.selected); - if ( - selected && - selected.state !== "healthy" && - candidateHealth.accounts.some(account => account.state === "healthy") - ) { - this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( - candidateModel.provider, - this.sessionId, - ); - } - } - } catch { - if (signal.aborted) return; - // Unknown usage fails open for an otherwise valid fallback. - } - if (signal.aborted) return; - let apiKey: string | undefined; - try { - apiKey = await this.#modelRegistry.getApiKey(candidateModel, this.sessionId, { signal }); - } catch { - if (signal.aborted) return; - continue; - } - if (signal.aborted) return; - if (!apiKey) continue; - fallback = { selector: candidate, apiKey }; - break; - } - if (!fallback) return; - - if (health.state === "reserve") { - if (reservePolicy === "confirm" && this.#usageFallbackConfirmer) { - if (this.#usageReserveApprovedSelector === currentSelector) return; - const selected = health.accounts.find(account => account.selected); - const remainingFraction = - selected?.remainingFraction ?? - health.accounts.reduce( - (minimum, account) => - account.remainingFraction === undefined - ? minimum - : minimum === undefined - ? account.remainingFraction - : Math.min(minimum, account.remainingFraction), - undefined, - ); - const shouldFallback = await this.#confirmUsageFallback( - { - from: currentSelector, - to: fallback.selector.raw, - remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100), - }, - signal, - ); - if (signal.aborted) return; - if (!shouldFallback) { - this.#usageReserveApprovedSelector = currentSelector; - return; - } - } - } - - if (signal.aborted) return; - this.#usageReserveApprovedSelector = undefined; - await this.#recovery.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, { - pinFallback: true, - apiKey: fallback.apiKey, - signal, - }); - } - /** Effective thinking level applied to the agent (the resolved level when `auto`). */ get thinkingLevel(): ThinkingLevel | undefined { return this.#models.thinkingLevel; @@ -4184,6 +4136,11 @@ export class AgentSession { return this.agent.state.systemPrompt; } + /** Marks streamed text as committed or buffered for turn-recovery replay decisions. */ + setTextOutputCommitted(committed: boolean): void { + this.#textOutputCommitted = committed; + } + /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { return this.#recovery.attempt; @@ -5026,7 +4983,7 @@ export class AgentSession { if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return false; + // Steer/follow-up the keyword notices BEFORE the queued user message so the // model reads the steering notice ahead of the prompt it modifies. for (const notice of keywordNotices) { @@ -5121,7 +5078,7 @@ export class AgentSession { if (options?.queueOnly) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return; + for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } @@ -5131,7 +5088,7 @@ export class AgentSession { if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return; + for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } @@ -5168,7 +5125,7 @@ export class AgentSession { const generation = this.#promptGeneration; try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); - if (!(await this.#runUsageAwarePreflight())) return; + if (!(await this.#runUsageAwarePreflightForNextModelCall())) return; // Flush any pending bash messages before the new prompt await this.#bash.flushPending(); this.#eval.flushPending(); @@ -5379,6 +5336,7 @@ export class AgentSession { await this.#waitForPostPromptRecovery(generation); } } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#endInFlight(); } } @@ -5544,7 +5502,6 @@ export class AgentSession { } const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]); - if (!(await this.#runUsageAwarePreflight())) return; await this.#queueUserMessage(expandedText, images, "steer"); } @@ -5562,7 +5519,6 @@ export class AgentSession { const expandedText = options?.expandPromptTemplates === false ? text : expandPromptTemplate(text, [...this.#promptTemplates]); - if (!(await this.#runUsageAwarePreflight())) return; if (!options?.synthetic) { await this.#queueUserMessage(expandedText, images, "followUp"); return; @@ -5579,6 +5535,7 @@ export class AgentSession { const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; + this.#allowQueuedMessageDrainRetry(); if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ role: "developer", @@ -5608,6 +5565,7 @@ export class AgentSession { const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; + this.#allowQueuedMessageDrainRetry(); if (mode === "followUp") { if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ @@ -5634,7 +5592,12 @@ export class AgentSession { } #scheduleQueuedMessageDrain(): void { - if (this.#queuedMessageDrainScheduled || !this.#canAutoContinueForFollowUp() || !this.agent.hasQueuedMessages()) { + if ( + this.#queuedMessageDrainScheduled || + this.#queuedMessageDrainBlocked || + !this.#canAutoContinueForFollowUp() || + !this.agent.hasQueuedMessages() + ) { return; } this.#queuedMessageDrainScheduled = true; @@ -5648,6 +5611,7 @@ export class AgentSession { }, onError: () => { this.#queuedMessageDrainScheduled = false; + this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages(); }, }); } @@ -5784,7 +5748,7 @@ export class AgentSession { ): Promise { this.#beginInFlight(); try { - if (!(await this.#runUsageAwarePreflight())) return; + if (!(await this.#runUsageAwarePreflightForNextModelCall())) return; const acceptTerminalEmptyStop = options?.acceptTerminalEmptyStop === true; if (acceptTerminalEmptyStop) { this.#resetPromptMaintenanceState(); @@ -5793,6 +5757,7 @@ export class AgentSession { await this.agent.prompt(message); await this.#waitForPostPromptRecovery(); } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#recovery.setAcceptTerminalEmptyStop(false); this.#endInFlight(); } @@ -5824,6 +5789,7 @@ export class AgentSession { timestamp: Date.now(), }; const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); + this.#allowQueuedMessageDrainRetry(); if (deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); } else { @@ -5880,7 +5846,7 @@ export class AgentSession { this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false); return false; } - if (!(await this.#runUsageAwarePreflight())) return false; + this.#allowQueuedMessageDrainRetry(); if (options?.deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); @@ -5963,8 +5929,6 @@ export class AgentSession { if (images.length === 0) images = undefined; } - if (options?.deliverAs && !(await this.#runUsageAwarePreflight())) return; - if (options?.deliverAs === "followUp") { await this.#queueUserMessage(text, images, "followUp"); return; @@ -6004,6 +5968,7 @@ export class AgentSession { ? isAdvisorCard : m => !isUserQueuedMessage(m) && !isHiddenUserCompanion(m); this.agent.replaceQueues(steeringAll.filter(keep), followUpAll.filter(keep)); + this.#reconcileQueuedMessageDrain(); return { steering, followUp }; } @@ -6053,12 +6018,14 @@ export class AgentSession { if (fromSteer >= 0) { const removed = steering[fromSteer]; this.agent.replaceQueues(removeWithCompanions(steering, fromSteer), followUp.slice()); + this.#reconcileQueuedMessageDrain(); return toRestoredQueuedMessage(removed); } const fromFollowUp = lastUserIndex(followUp); if (fromFollowUp >= 0) { const removed = followUp[fromFollowUp]; this.agent.replaceQueues(steering.slice(), removeWithCompanions(followUp, fromFollowUp)); + this.#reconcileQueuedMessageDrain(); return toRestoredQueuedMessage(removed); } return undefined; @@ -6348,6 +6315,8 @@ export class AgentSession { await this.#memory.resetContextForNewTranscript(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel()); this.sessionManager.appendServiceTierChange(this.#models.serviceTierEntry()); @@ -7372,6 +7341,9 @@ export class AgentSession { const previousFollowUpMessages = [...this.agent.peekFollowUpQueue()]; const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages]; const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration; + const previousQueuedMessageDrainBlocked = this.#queuedMessageDrainBlocked; + const previousUsagePreflightReadyForNextModelCall = this.#usagePreflightReadyForNextModelCall; + const previousUsagePreflightReadyModel = this.#usagePreflightReadyModel; const previousModel = this.model; const previousThinkingLevel = this.thinkingLevel; const previousAutoThinking = this.isAutoThinking; @@ -7396,6 +7368,9 @@ export class AgentSession { this.agent.clearAllQueues(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; + this.#usagePreflightReadyModel = undefined; try { if (switchingToDifferentSession) { @@ -7564,6 +7539,9 @@ export class AgentSession { this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages); this.#pendingNextTurnMessages = previousPendingNextTurnMessages; this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; + this.#queuedMessageDrainBlocked = previousQueuedMessageDrainBlocked; + this.#usagePreflightReadyForNextModelCall = previousUsagePreflightReadyForNextModelCall; + this.#usagePreflightReadyModel = previousUsagePreflightReadyModel; this.#inheritedProviderPromptCacheKey = previousInheritedProviderPromptCacheKey; this.#checkpointState = previousCheckpointState; this.#pendingRewindReport = previousPendingRewindReport; @@ -7652,6 +7630,8 @@ export class AgentSession { // Clear pending messages (bound to old session state) this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; await this.#bash.flushPending(); // Flush pending writes before branching @@ -7779,6 +7759,8 @@ export class AgentSession { this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; this.agent.replaceQueues([], []); + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; await this.#bash.flushPending(); await this.sessionManager.flush(); const bashTransition = this.#bash.beginSessionTransition(); diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index bd28e4332..36c627759 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -84,6 +84,8 @@ export interface ModelChangeEntry extends SessionEntryBase { model: string; /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */ role?: string; + /** True when this transition selected a retry-fallback model rather than the configured model. */ + resolvedModelIsFallback?: boolean; } export interface ServiceTierChangeEntry extends SessionEntryBase { @@ -207,6 +209,14 @@ export interface SessionInitEntry extends SessionEntryBase { task: string; /** Tools available to the agent */ tools: string[]; + /** Agent definition name (for example `scout` or `reviewer`). */ + agent?: string; + /** Semantic model role declared by the agent, retained even after concrete model resolution. */ + modelRole?: string; + /** Initially resolved provider/model selector for historical display. */ + resolvedModel?: string; + /** Whether the agent definition is read-only, allowing an exact zero-LoC attribution. */ + readOnly?: boolean; /** Output schema if structured output was requested. */ outputSchema?: unknown; /** Enforcement policy recorded with the output schema for faithful revival. */ diff --git a/packages/coding-agent/src/session/session-loader.ts b/packages/coding-agent/src/session/session-loader.ts index f493e974e..2c2f0fab2 100644 --- a/packages/coding-agent/src/session/session-loader.ts +++ b/packages/coding-agent/src/session/session-loader.ts @@ -21,6 +21,19 @@ import { } from "./session-title-slot"; const STREAM_LOAD_THRESHOLD_BYTES = 8 * 1024 * 1024; +const STREAM_YIELD_BYTES = 1 * 1024 * 1024; +const STREAM_YIELD_ENTRIES = 8_192; + +export interface VisitEntriesFromFileStreamOptions { + /** Stop after the visitor returns `false`. */ + shouldContinue?: () => boolean; + /** Stop after this many valid or malformed JSONL records have been consumed. */ + maxRecords?: number; + /** Yield to the macrotask queue after this many bytes have been consumed. */ + yieldEveryBytes?: number; + /** Yield to the macrotask queue after this many entries have been visited. */ + yieldEveryEntries?: number; +} function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } { const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content)); @@ -59,11 +72,19 @@ export function parseSessionContent(content: string): { /** Parse session JSONL and visit each entry without retaining prior entries. */ export async function visitEntriesFromFileStream( filePath: string, - visit: (entry: FileEntry) => void, + visit: (entry: FileEntry) => void | boolean, + options: VisitEntriesFromFileStreamOptions = {}, ): Promise { let titleSlot: SessionTitleUpdate | undefined; let sawFirstLine = false; + let bytesSinceYield = 0; + let entriesSinceYield = 0; + let recordsSeen = 0; + const maxRecords = Math.max(0, options.maxRecords ?? Number.POSITIVE_INFINITY); + let stopped = false; let visitorThrew = false; + const yieldEveryBytes = Math.max(0, options.yieldEveryBytes ?? STREAM_YIELD_BYTES); + const yieldEveryEntries = Math.max(0, options.yieldEveryEntries ?? STREAM_YIELD_ENTRIES); // Byte buffer (NOT a decoded string): multibyte UTF-8 sequences that straddle // a stream-chunk boundary stay intact, and Bun.JSONL.parseChunk accepts typed // arrays directly. Only the unconsumed remainder is held (≤ one record + a @@ -72,22 +93,62 @@ export async function visitEntriesFromFileStream( let buffer: Uint8Array = new Uint8Array(); const decoder = new TextDecoder(); - const drain = () => { - while (buffer.length > 0) { + const yieldToMacrotask = async (): Promise => { + if (yieldEveryBytes === 0 && yieldEveryEntries === 0) return; + const bytesReady = yieldEveryBytes === 0 || bytesSinceYield < yieldEveryBytes; + const entriesReady = yieldEveryEntries === 0 || entriesSinceYield < yieldEveryEntries; + if (bytesReady && entriesReady) { + return; + } + bytesSinceYield = 0; + entriesSinceYield = 0; + await Bun.sleep(0); + }; + + const drain = async (): Promise => { + while (buffer.length > 0 && !stopped) { + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } const { values, error, read, done } = Bun.JSONL.parseChunk(buffer); for (const value of values) { + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } + if (options.shouldContinue && !options.shouldContinue()) { + stopped = true; + break; + } try { - visit(value as FileEntry); + if (visit(value as FileEntry) === false) { + stopped = true; + break; + } + recordsSeen++; + entriesSinceYield++; + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } } catch (err) { visitorThrew = true; throw err; } + await yieldToMacrotask(); } + if (stopped) break; if (error) { // 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 + recordsSeen++; buffer = buffer.subarray(nextNewline + 1); + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } continue; } if (read === 0) break; // incomplete record awaiting more data @@ -101,6 +162,8 @@ export async function visitEntriesFromFileStream( try { for await (const chunk of Bun.file(filePath).stream()) { + if (stopped) break; + bytesSinceYield += chunk.byteLength; buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]); // The optional fixed-width title slot is a physical first line that is // NOT JSON; peel it before the parser would (correctly) reject it. The @@ -121,14 +184,15 @@ export async function visitEntriesFromFileStream( } } } - drain(); + await drain(); + await yieldToMacrotask(); } // A trailing record without a final newline: terminate it so the parser // can complete it (readline yielded it; parseChunk needs the delimiter). - if (buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) { + if (!stopped && buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) { buffer = Buffer.concat([buffer, new Uint8Array([0x0a])]); + await drain(); } - drain(); } catch (err) { if (visitorThrew) throw err; if (isEnoent(err)) return undefined; @@ -144,7 +208,9 @@ export async function loadEntriesFromFileStream(filePath: string): Promise<{ titleSlot: SessionTitleUpdate | undefined; }> { const entries: FileEntry[] = []; - const titleSlot = await visitEntriesFromFileStream(filePath, entry => entries.push(entry)); + const titleSlot = await visitEntriesFromFileStream(filePath, entry => { + entries.push(entry); + }); return { entries: foldTitleSlot(entries, titleSlot), titleSlot }; } diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 6b2d7def5..5a311c508 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -2047,9 +2047,16 @@ export class SessionManager { * Append a model change as a child of the current leaf, then advance the leaf. * @param model Model in "provider/modelId" format * @param role Optional role (default: "default") + * @param resolvedModelIsFallback Whether this transition selected a retry-fallback model */ - appendModelChange(model: string, role?: string): string { - const entry: ModelChangeEntry = { type: "model_change", ...this.#freshEntryFields(), model, role }; + appendModelChange(model: string, role?: string, resolvedModelIsFallback = false): string { + const entry: ModelChangeEntry = { + type: "model_change", + ...this.#freshEntryFields(), + model, + role, + resolvedModelIsFallback, + }; this.#recordEntry(entry); return entry.id; } @@ -2058,6 +2065,10 @@ export class SessionManager { systemPrompt: string; task: string; tools: string[]; + agent?: string; + modelRole?: string; + resolvedModel?: string; + readOnly?: boolean; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; restrictToolNames?: boolean; @@ -2547,6 +2558,9 @@ export class SessionManager { systemPrompt: string; task: string; tools: string[]; + agent?: string; + modelRole?: string; + resolvedModel?: string; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; restrictToolNames?: boolean; @@ -2567,6 +2581,9 @@ export class SessionManager { systemPrompt: string; task: string; tools: string[]; + agent?: string; + modelRole?: string; + resolvedModel?: string; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; restrictToolNames?: boolean; @@ -2580,6 +2597,9 @@ export class SessionManager { systemPrompt: entry.systemPrompt, task: entry.task, tools: entry.tools, + agent: entry.agent, + modelRole: entry.modelRole, + resolvedModel: entry.resolvedModel, outputSchema: entry.outputSchema, outputSchemaMode: entry.outputSchemaMode, restrictToolNames: entry.restrictToolNames, diff --git a/packages/coding-agent/src/session/session-paths.ts b/packages/coding-agent/src/session/session-paths.ts index 9f47d02e9..f76d6e183 100644 --- a/packages/coding-agent/src/session/session-paths.ts +++ b/packages/coding-agent/src/session/session-paths.ts @@ -2,17 +2,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { getTerminalId } from "@oh-my-pi/pi-tui"; -import { - getSessionsDir, - getTerminalSessionsDir, - isEnoent, - isRecord, - logger, - resolveEquivalentPath, -} from "@oh-my-pi/pi-utils"; +import { getSessionsDir, getTerminalSessionsDir, isEnoent, logger, resolveEquivalentPath } from "@oh-my-pi/pi-utils"; import type { SessionStorage } from "./session-storage"; -const SESSION_HEADER_PREFIX_BYTES = 4096; +const migratedSessionRoots = new Set(); /** * Merge or rename a legacy session directory into its canonical target. @@ -24,11 +17,13 @@ function migrateSessionDirPath(oldPath: string, newPath: string): void { for (const file of fs.readdirSync(oldPath)) { const src = path.join(oldPath, file); const dst = path.join(newPath, file); - if (!fs.existsSync(dst)) { - fs.renameSync(src, dst); + if (fs.existsSync(dst)) { + logger.warn("Session directory migration collision; preserving legacy entry", { src, dst }); + continue; } + fs.renameSync(src, dst); } - fs.rmSync(oldPath, { recursive: true, force: true }); + fs.rmdirSync(oldPath); return; } if (existing) { @@ -42,16 +37,12 @@ function encodeLegacyAbsoluteSessionDirName(cwd: string): string { return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } -function encodeLegacyRelativeSessionDirName(prefix: string, relative: string): string { +function encodeRelativeSessionDirName(prefix: string, relative: string): string { const encoded = relative.replace(/[/\\:]/g, "-"); return encoded ? (prefix.endsWith("-") ? `${prefix}${encoded}` : `${prefix}-${encoded}`) : prefix; } -function getDefaultSessionDirName(cwd: string): { - encodedDirName: string; - legacyRelativeDirName: string | undefined; - resolvedCwd: string; -} { +function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } { const resolvedCwd = path.resolve(cwd); const canonicalCwd = resolveEquivalentPath(resolvedCwd); const home = os.homedir(); @@ -60,111 +51,80 @@ function getDefaultSessionDirName(cwd: string): { const canonicalTempRoot = resolveEquivalentPath(tempRoot); const homeRelative = path.relative(canonicalHome, canonicalCwd); const tempRelative = path.relative(canonicalTempRoot, canonicalCwd); - - let scope: "home" | "tmp" | "abs"; - let legacyRelativeDirName: string | undefined; - if (homeRelative === "" || (!homeRelative.startsWith("..") && !path.isAbsolute(homeRelative))) { - scope = "home"; - legacyRelativeDirName = encodeLegacyRelativeSessionDirName("-", homeRelative); - } else if (tempRelative === "" || (!tempRelative.startsWith("..") && !path.isAbsolute(tempRelative))) { - scope = "tmp"; - legacyRelativeDirName = encodeLegacyRelativeSessionDirName("-tmp", tempRelative); - } else { - scope = "abs"; - } - - const normalized = canonicalCwd.replaceAll("\\", "/"); - const readable = path - .basename(canonicalCwd) - .replace(/[^a-zA-Z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(-80); - const digest = Bun.SHA256.hash(normalized, "hex"); - const encodedDirName = `${scope}-${readable || "project"}-${digest}`; - return { encodedDirName, legacyRelativeDirName, resolvedCwd }; + const encodedDirName = + homeRelative === "" || (!homeRelative.startsWith("..") && !path.isAbsolute(homeRelative)) + ? encodeRelativeSessionDirName("-", homeRelative) + : tempRelative === "" || (!tempRelative.startsWith("..") && !path.isAbsolute(tempRelative)) + ? encodeRelativeSessionDirName("-tmp", tempRelative) + : encodeLegacyAbsoluteSessionDirName(canonicalCwd); + return { encodedDirName, resolvedCwd }; } -function readSessionCwd(sessionFile: string, buffer: Buffer): string | undefined { - let descriptor: number | undefined; +/** + * Migrate old `---*--` session dirs to the new `-*` format. + * Runs once per sessions root on first access, best-effort. + */ +function migrateHomeSessionDirs(sessionsRoot: string): void { + if (migratedSessionRoots.has(sessionsRoot)) return; + migratedSessionRoots.add(sessionsRoot); + + const home = os.homedir(); + const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); + const oldPrefix = `--${homeEncoded}-`; + const oldExact = `--${homeEncoded}--`; + + let entries: string[]; try { - descriptor = fs.openSync(sessionFile, "r"); - const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0); - const prefix = buffer.toString("utf8", 0, bytesRead); - for (const line of prefix.split(/\r?\n/, 3)) { - try { - const record: unknown = JSON.parse(line); - if (isRecord(record) && record.type === "session" && typeof record.cwd === "string") { - return record.cwd; - } - } catch { - // Ignore title slots or truncated/corrupt headers. - } - } + entries = fs.readdirSync(sessionsRoot); } catch { - // Best-effort migration leaves unreadable sessions in the current bucket. - } finally { - if (descriptor !== undefined) fs.closeSync(descriptor); + return; } - return undefined; -} -function moveSessionBundle(sourceDir: string, targetDir: string, sessionName: string, entries: string[]): void { - fs.mkdirSync(targetDir, { recursive: true }); - const artifactsName = path.basename(sessionName, ".jsonl"); for (const entry of entries) { - if (entry !== sessionName && entry !== artifactsName && !entry.startsWith(`${sessionName}.`)) continue; - const source = path.join(sourceDir, entry); - if (!fs.existsSync(source)) continue; - const target = path.join(targetDir, entry); - const existing = fs.statSync(target, { throwIfNoEntry: false }); - if (!existing) { - fs.renameSync(source, target); - } else if (existing.isDirectory() && fs.statSync(source).isDirectory()) { - migrateSessionDirPath(source, target); + let remainder: string; + if (entry === oldExact) { + remainder = ""; + } else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) { + remainder = entry.slice(oldPrefix.length, -2); } else { - fs.rmSync(source, { recursive: true, force: true }); - } - } -} - -function rerouteCollidingSessions( - cwd: string, - legacyDir: string, - sessionsRoot: string, - kind: "relative" | "absolute", -): void { - const entries = fs.readdirSync(legacyDir); - const currentCwd = resolveEquivalentPath(path.resolve(cwd)); - const buffer = Buffer.allocUnsafe(SESSION_HEADER_PREFIX_BYTES); - for (const entry of entries) { - if (!entry.endsWith(".jsonl")) continue; - const recordedCwd = readSessionCwd(path.join(legacyDir, entry), buffer); - if (!recordedCwd) continue; - const recordedCanonical = resolveEquivalentPath(path.resolve(recordedCwd)); - if ( - recordedCanonical === currentCwd || - !fs.statSync(recordedCanonical, { throwIfNoEntry: false })?.isDirectory() - ) { continue; } - const recordedNames = getDefaultSessionDirName(recordedCanonical); - const recordedLegacyName = - kind === "relative" - ? recordedNames.legacyRelativeDirName - : encodeLegacyAbsoluteSessionDirName(recordedCanonical); - if (recordedLegacyName !== path.basename(legacyDir)) continue; - moveSessionBundle(legacyDir, path.join(sessionsRoot, recordedNames.encodedDirName), entry, entries); + + const newName = remainder ? `-${remainder}` : "-"; + const oldPath = path.join(sessionsRoot, entry); + const newPath = path.join(sessionsRoot, newName); + + try { + migrateSessionDirPath(oldPath, newPath); + } catch (error) { + logger.warn("Failed to migrate legacy home session directory", { + oldPath, + newPath, + error: String(error), + }); + } + } +} + +function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void { + const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd)); + if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return; + + try { + migrateSessionDirPath(legacyDir, sessionDir); + } catch (error) { + logger.warn("Failed to migrate legacy session directory", { + oldPath: legacyDir, + newPath: sessionDir, + error: String(error), + }); } } export function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined { const currentDirName = path.basename(sessionDir); - const { encodedDirName, legacyRelativeDirName } = getDefaultSessionDirName(cwd); - if ( - currentDirName !== encodedDirName && - currentDirName !== legacyRelativeDirName && - currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd) - ) { + const { encodedDirName } = getDefaultSessionDirName(cwd); + if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) { return undefined; } return path.dirname(sessionDir); @@ -180,23 +140,10 @@ export function computeDefaultSessionDir( storage: SessionStorage, sessionsRoot: string = getSessionsDir(), ): string { - const { encodedDirName, legacyRelativeDirName, resolvedCwd } = getDefaultSessionDirName(cwd); + const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd); + migrateHomeSessionDirs(sessionsRoot); const sessionDir = path.join(sessionsRoot, encodedDirName); - const legacyDirs: Array<{ kind: "relative" | "absolute"; name: string | undefined }> = [ - { kind: "relative", name: legacyRelativeDirName }, - { kind: "absolute", name: encodeLegacyAbsoluteSessionDirName(resolvedCwd) }, - ]; - for (const legacy of legacyDirs) { - if (!legacy.name) continue; - const legacyDir = path.join(sessionsRoot, legacy.name); - if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) continue; - try { - rerouteCollidingSessions(resolvedCwd, legacyDir, sessionsRoot, legacy.kind); - migrateSessionDirPath(legacyDir, sessionDir); - } catch { - // Best effort - } - } + migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot); storage.ensureDirSync(sessionDir); return sessionDir; } diff --git a/packages/coding-agent/src/session/settings-stream-fn.ts b/packages/coding-agent/src/session/settings-stream-fn.ts index be58243ed..41dcdc386 100644 --- a/packages/coding-agent/src/session/settings-stream-fn.ts +++ b/packages/coding-agent/src/session/settings-stream-fn.ts @@ -34,9 +34,13 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn = openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined; const antigravityEndpointMode = settings.get("providers.antigravityEndpoint"); const textVerbosity = - model.api === "openai-codex-responses" || model.api === "openai-responses" - ? settings.get("textVerbosity") - : undefined; + model.api === "openai-codex-responses" + ? settings.isConfigured("textVerbosity") + ? settings.get("textVerbosity") + : undefined + : model.api === "openai-responses" + ? settings.get("textVerbosity") + : undefined; const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds")); const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds")); // Server-side fallback (opt-in): when the user enables it AND the diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 629138402..1efab6ccf 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -13,16 +13,20 @@ import type { CodexCompactionContext, Effort, Model, + ModelUsageHealth, TextContent, + ThinkingContent, ToolChoice, } from "@oh-my-pi/pi-ai"; import { calculateRateLimitBackoffMs, parseRateLimitReason } 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 { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id"; +import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { extractRetryHint, logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelStringWithRouting, resolveModelOverride } from "../config/model-resolver"; + import type { Settings } from "../config/settings"; import type { RecoveredRetryError } from "../extensibility/shared-events"; import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" }; @@ -35,7 +39,11 @@ import { modelSupportsEffortCeiling, } from "../thinking"; import type { AgentSessionEvent } from "./agent-session-events"; -import type { InitialRetryFallbackState } from "./agent-session-types"; +import type { + InitialRetryFallbackState, + UsageFallbackConfirmation, + UsageFallbackConfirmer, +} from "./agent-session-types"; import { isEmptyErrorTurn } from "./messages"; import { type ActiveRetryFallbackState, @@ -64,6 +72,7 @@ const UNEXPECTED_STOP_TIMEOUT_MS = 4000; 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:"; function hasNonWhitespace(value: string): boolean { return NON_WHITESPACE_RE.test(value); @@ -101,6 +110,8 @@ export interface TurnRecoveryHost { modelRegistry: ModelRegistry; configWarnings: string[]; model(): Model | undefined; + /** Whether streamed text has already been committed to the active output sink. */ + textOutputCommitted(): boolean; thinkingLevel(): ThinkingLevel | undefined; configuredThinkingLevel(): ConfiguredThinkingLevel | undefined; setThinkingLevel(level: ConfiguredThinkingLevel | undefined): void; @@ -172,6 +183,7 @@ export class TurnRecovery { #retryPromise: Promise | undefined; #retryResolve: (() => void) | undefined; #activeRetryFallback: ActiveRetryFallbackState | undefined; + #usageReserveApprovedSelector: string | undefined; #pendingRecoveredRetryErrors: PendingRecoveredRetryError[] = []; #usageLimitOutcomes = new WeakMap>(); #emptyStopRetryCount = 0; @@ -298,6 +310,11 @@ export class TurnRecovery { return this.#maybeRestoreRetryFallbackPrimary(); } + /** Applies model fallback policy from live usage health before a turn starts. */ + maybeApplyUsageAwareFallback(signal: AbortSignal, confirmer?: UsageFallbackConfirmer): Promise { + return this.#maybeApplyUsageAwareFallback(signal, confirmer); + } + /** Applies automatic retry, credential rotation, and model fallback policy. */ handleRetryableError( message: AssistantMessage, @@ -591,11 +608,19 @@ export class TurnRecovery { return false; } - const text = assistantMessage.content + let text = assistantMessage.content .filter((content): content is TextContent => content.type === "text") .map(content => content.text) .join("\n"); - if (!/\S/.test(text)) { + // Thinking-only stops carry their signal in the thinking block (a trapped + // response or a truncated fragment); classify on that when there is no text. + if (!hasNonWhitespace(text)) { + text = assistantMessage.content + .filter((content): content is ThinkingContent => content.type === "thinking") + .map(content => content.thinking) + .join("\n"); + } + if (!hasNonWhitespace(text)) { this.#unexpectedStopRetryCount = 0; return false; } @@ -840,6 +865,9 @@ export class TurnRecovery { return id; } + #isUsagePreflightBlocked(message: AssistantMessage): boolean { + return message.errorMessage?.startsWith(USAGE_PREFLIGHT_BLOCKED_PREFIX) === true; + } /** * Retry an empty, reason-less provider abort: a turn with no content that * carries the generic sentinel (bare `abort()`), whether the provider @@ -883,6 +911,7 @@ export class TurnRecovery { */ isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; const id = this.#classifyRetryMessage(message); // Context overflow is handled by compaction, not retry @@ -890,8 +919,8 @@ export class TurnRecovery { if (AIError.isContextOverflow(message, contextWindow)) return false; // A classifier refusal/sensitivity stop is the model's decision, not a route - // failure, but only after we confirm no user-visible output has already been - // streamed. Visible text, images, tool calls, or server tools must not be + // failure, but only after we confirm no replay-unsafe output has already + // streamed. Committed text, images, tool calls, or server tools must not be // discarded and replayed. if (this.#hasReplayUnsafeOutput(message)) return false; if (this.isClassifierRefusal(message)) return true; @@ -966,10 +995,10 @@ export class TurnRecovery { * Thinking-only partials are safe to discard and replay: reasoning models * routinely stall after long thinking with no visible output, and duplicated * thinking display is materially lower harm than duplicated final text. - * Whitespace-only text is likewise safe since nothing meaningful reached the - * user. Visible text, generated images, server tools, and retained tool calls - * are NOT safe: each has already rendered or may have side effects, so replaying - * the turn can duplicate user-visible output or work. + * Whitespace-only and buffered text are likewise safe since nothing meaningful + * reached the user. Committed text, generated images, server tools, and retained + * tool calls are NOT safe: each has already rendered or may have side effects, + * so replaying the turn can duplicate user-visible output or work. */ #hasReplayUnsafeOutput(message: AssistantMessage): boolean { return message.content.some( @@ -977,7 +1006,7 @@ export class TurnRecovery { block.type === "toolCall" || block.type === "image" || block.type === "anthropicServerTool" || - (block.type === "text" && block.text.trim().length > 0), + (block.type === "text" && this.#host.textOutputCommitted() && block.text.trim().length > 0), ); } @@ -1008,7 +1037,6 @@ export class TurnRecovery { modelLookup: this.#host.modelRegistry, }; } - #getRetryFallbackChains(): RetryFallbackChains { return getRetryFallbackChains(this.#host.settings); } @@ -1071,23 +1099,187 @@ export class TurnRecovery { ); } + async #maybeApplyUsageAwareFallback(signal: AbortSignal, confirmer?: UsageFallbackConfirmer): Promise { + if (!this.#host.settings.get("retry.usageAwareFallback")) return false; + const currentModel = this.#host.model(); + if (!currentModel) return false; + const currentSelector = formatRetryFallbackSelector(currentModel, this.#host.thinkingLevel()); + let health: ModelUsageHealth; + try { + health = await this.#host.modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, { + modelId: currentModel.id, + sessionId: this.#host.sessionId(), + baseUrl: currentModel.baseUrl, + reserveFraction: this.#host.settings.get("retry.usageReservePct") / 100, + signal, + }); + } catch (error) { + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + logger.debug("Usage-aware runtime preflight failed open", { + provider: currentModel.provider, + model: currentModel.id, + error: String(error), + }); + return false; + } + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + const selectedAccount = health.accounts.find(account => account.selected); + if (health.state === "healthy") { + this.#usageReserveApprovedSelector = undefined; + if ( + selectedAccount && + selectedAccount.state !== "healthy" && + health.accounts.some(account => account.state === "healthy") + ) { + this.#host.modelRegistry.authStorage.releaseSessionCredentialForReselection( + currentModel.provider, + this.#host.sessionId(), + ); + } + return false; + } + if (health.state === "unknown") { + this.#usageReserveApprovedSelector = undefined; + return false; + } + if (health.state !== "reserve") this.#usageReserveApprovedSelector = undefined; + + const reservePolicy = this.#host.settings.get("retry.usageReservePolicy"); + if (reservePolicy === "fail-closed") { + const condition = health.state === "reserve" ? "reserve reached" : "usage depleted"; + throw new Error( + `${USAGE_PREFLIGHT_BLOCKED_PREFIX} ${condition} for ${currentSelector}; reserve policy is fail-closed.`, + ); + } + if ( + reservePolicy === "confirm" && + health.state === "reserve" && + this.#usageReserveApprovedSelector === currentSelector + ) { + return false; + } + if (!this.#host.settings.get("retry.modelFallback")) return false; + + const role = this.#activeRetryFallback?.role ?? this.resolveRetryFallbackRole(currentSelector, currentModel); + if (!role) return false; + let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; + const ceiling = this.#host.thinkingLevelCeiling(); + for (const candidate of this.findRetryFallbackCandidates(role, currentSelector, currentModel)) { + if (this.isRetryFallbackSelectorSuppressed(candidate)) continue; + const resolved = resolveModelOverride([candidate.raw], this.#host.modelRegistry, this.#host.settings); + const candidateModel = resolved.model ?? this.#host.modelRegistry.find(candidate.provider, candidate.id); + if (!candidateModel || !this.#host.modelRegistry.hasConfiguredAuth(candidateModel)) continue; + if (ceiling !== undefined && !modelSupportsEffortCeiling(candidateModel, ceiling)) continue; + try { + const candidateHealth = await this.#host.modelRegistry.authStorage.getModelUsageHealth( + candidateModel.provider, + { + modelId: candidateModel.id, + sessionId: this.#host.sessionId(), + baseUrl: candidateModel.baseUrl, + reserveFraction: this.#host.settings.get("retry.usageReservePct") / 100, + signal, + }, + ); + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; + if (candidateHealth.state === "healthy") { + const selected = candidateHealth.accounts.find(account => account.selected); + if ( + selected && + selected.state !== "healthy" && + candidateHealth.accounts.some(account => account.state === "healthy") + ) { + this.#host.modelRegistry.authStorage.releaseSessionCredentialForReselection( + candidateModel.provider, + this.#host.sessionId(), + ); + } + } + } catch { + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + // Unknown usage fails open for an otherwise valid fallback. + } + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + let apiKey: string | undefined; + try { + apiKey = await this.#host.modelRegistry.getApiKey(candidateModel, this.#host.sessionId(), { signal }); + } catch { + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + continue; + } + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + if (!apiKey) continue; + fallback = { selector: candidate, apiKey }; + break; + } + if (!fallback) return false; + + let shouldFallback = health.state === "depleted" || reservePolicy === "auto" || !confirmer; + if (!shouldFallback && health.state === "reserve" && confirmer) { + const remainingFraction = + selectedAccount?.remainingFraction ?? + health.accounts.reduce((minimum, account) => { + if (account.remainingFraction === undefined) return minimum; + return minimum === undefined ? account.remainingFraction : Math.min(minimum, account.remainingFraction); + }, undefined); + shouldFallback = await this.#confirmUsageFallback( + confirmer, + { + from: currentSelector, + to: fallback.selector.raw, + remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100), + }, + signal, + ); + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; + } + if (!shouldFallback) { + this.#usageReserveApprovedSelector = currentSelector; + return false; + } + this.#usageReserveApprovedSelector = undefined; + return this.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, { + pinFallback: true, + apiKey: fallback.apiKey, + signal, + }); + } + + async #confirmUsageFallback( + confirmer: UsageFallbackConfirmer, + confirmation: UsageFallbackConfirmation, + signal: AbortSignal, + ): Promise { + if (signal.aborted) return false; + const aborted = Promise.withResolvers(); + const onAbort = () => aborted.resolve(false); + signal.addEventListener("abort", onAbort, { once: true }); + try { + return await Promise.race([confirmer(confirmation, signal), aborted.promise]); + } finally { + signal.removeEventListener("abort", onAbort); + } + } + async applyRetryFallbackCandidate( role: string, selector: RetryFallbackSelector, currentSelector: string, options?: { pinFallback?: boolean; apiKey?: string; signal?: AbortSignal }, - ): Promise { + ): Promise { 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) { throw new Error(`Retry fallback model not found: ${selector.raw}`); } const apiKey = - options?.apiKey ?? (await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), options)); + options?.apiKey ?? + (await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), { signal: options?.signal })); if (!apiKey) { throw new Error(`No API key for retry fallback ${selector.raw}`); } - if (options?.signal?.aborted) return; + if (options?.signal?.aborted) return false; // Capture the configured selector (auto-aware) so a fallback chain preserves // `auto` instead of collapsing it to the level it resolved to this turn. @@ -1101,8 +1293,16 @@ export class TurnRecovery { ? requestedThinkingLevel : clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling()); const candidateSelector = formatModelStringWithRouting(candidate); + const previousModel = this.#host.model(); await this.#host.setModelWithProviderSessionReset(candidate); - this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE); + if (options?.signal?.aborted) { + if (previousModel && this.#host.model() === candidate) { + await this.#host.setModelWithProviderSessionReset(previousModel); + } + return false; + } + if (this.#host.model() !== candidate) return false; + this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true); this.#host.settings.getStorage()?.recordModelUsage(candidateSelector); this.#host.setThinkingLevel(nextThinkingLevel); if (!this.#activeRetryFallback) { @@ -1123,6 +1323,7 @@ export class TurnRecovery { to: selector.raw, role, }); + return true; } async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise { @@ -1140,8 +1341,7 @@ export class TurnRecovery { if (ceiling !== undefined && !modelSupportsEffortCeiling(candidate, ceiling)) continue; const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); if (!apiKey) continue; - await this.applyRetryFallbackCandidate(role, selector, currentSelector, options); - return true; + return this.applyRetryFallbackCandidate(role, selector, currentSelector, options); } return false; @@ -1167,6 +1367,7 @@ export class TurnRecovery { const model = this.#activeFireworksFastModel(); if (!model) return false; if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; if (this.#hasReplayUnsafeOutput(message)) return false; // A content refusal/sensitivity stop is the model's decision, not a route // failure — switching to the base model would just re-trigger it. @@ -1190,6 +1391,7 @@ export class TurnRecovery { */ isHardErrorFallbackEligible(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; const model = this.#host.model(); if (!model) return false; const retrySettings = this.#host.settings.getGroup("retry"); @@ -1220,7 +1422,7 @@ export class TurnRecovery { if (!apiKey) return false; const baseSelector = formatModelStringWithRouting(baseModel); await this.#host.setModelWithProviderSessionReset(baseModel); - this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE); + this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true); this.#host.settings.getStorage()?.recordModelUsage(baseSelector); await this.#host.emitSessionEvent({ type: "retry_fallback_applied", @@ -1385,6 +1587,22 @@ export class TurnRecovery { let delayMs = staleOpenAIResponsesReplayError ? 0 : calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt); + // Concurrency caps shed-and-backoff (5s) rather than burning a sibling + // credential, so the usage-limit rotation branch below is deliberately + // skipped for them. Apply the reason-based backoff to the transient + // same-model retry path too — otherwise the default exponential base + // (≈500ms) re-hits the cap immediately and burns the retry budget while + // the concurrency slot stays occupied. A categorical 402 billing cap whose + // body merely mentions concurrency is still a usage limit (handled below), + // so gate on the flag matching the rotation decision. + if ( + !staleOpenAIResponsesReplayError && + !AIError.is(id, AIError.Flag.UsageLimit) && + parseRateLimitReason(errorMessage) === "CONCURRENT_LIMIT" + ) { + const concurrentBackoffMs = calculateRateLimitBackoffMs("CONCURRENT_LIMIT"); + if (concurrentBackoffMs > delayMs) delayMs = concurrentBackoffMs; + } let switchedCredential = false; let switchedModel = false; // Set when a usage-limit error pinned the wait to credential diff --git a/packages/coding-agent/src/session/unexpected-stop-classifier.ts b/packages/coding-agent/src/session/unexpected-stop-classifier.ts index 5bf7c3456..bb3525eab 100644 --- a/packages/coding-agent/src/session/unexpected-stop-classifier.ts +++ b/packages/coding-agent/src/session/unexpected-stop-classifier.ts @@ -34,14 +34,23 @@ export interface ClassifyUnexpectedStopDeps { export function isUnexpectedStopCandidate(message: AssistantMessage): boolean { if (message.stopReason !== "stop") return false; - let hasText = false; + let hasContent = false; for (const content of message.content) { if (content.type === "toolCall") return false; if (content.type === "text" && /\S/.test(content.text)) { - hasText = true; + hasContent = true; + } + // A signed thinking-only stop is still a candidate: reasoning models can + // trap the intended response (or a truncated fragment) in a thinking block + // with no text. #isEmptyAssistantStop treats a non-whitespace signature as + // terminal (not empty), so such stops bypass the empty-stop path entirely. + // Match that predicate here — unsigned thinking-only stops stay with the + // empty-stop retry path (and its cap) rather than being re-handled here. + if (content.type === "thinking" && /\S/.test(content.thinking) && /\S/.test(content.thinkingSignature ?? "")) { + hasContent = true; } } - return hasText; + return hasContent; } export async function classifyUnexpectedStop( 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 8cb1c944f..a22da28a6 100644 --- a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts +++ b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts @@ -4,6 +4,7 @@ import * as openUtils from "../../utils/open"; export const DEFAULT_STATS_DASHBOARD_PORT = 3847; interface StatsDashboardServer { + hostname: string; port: number; stop: () => void; } @@ -64,7 +65,7 @@ export async function launchStatsDashboard(args: StatsDashboardArgs): Promise(); + const disabledProviders = new Set(settings.get("disabledProviders")); for (const pattern of modelPatterns) { const resolved = resolveModelOverride([pattern], modelRegistry, settings); if (!resolved.model) continue; + if (disabledProviders.has(resolved.model.provider)) continue; const selector = resolved.explicitThinkingLevel ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel) : formatModelStringWithRouting(resolved.model); @@ -179,7 +185,10 @@ function resolveSubagentRetryFallbackCandidates( return candidates; } -function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] | undefined { +function resolveSubagentDefaultRetryFallbackChain( + settings: Settings, + modelRegistry: ModelRegistry, +): string[] | undefined { const fallbackChain = settings.get("retry.fallbackChains")?.default; if ( !Array.isArray(fallbackChain) || @@ -188,7 +197,11 @@ function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] ) { return undefined; } - return fallbackChain; + const disabledProviders = new Set(settings.get("disabledProviders")); + return fallbackChain.filter(entry => { + const resolved = resolveModelOverride([entry], modelRegistry, settings); + return !resolved.model || !disabledProviders.has(resolved.model.provider); + }); } function installSubagentRetryFallbackChain(args: { @@ -333,6 +346,8 @@ export interface ExecutorOptions { */ detached?: boolean; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; /** * Active model selector of the parent session, used as an auth-aware fallback * if the resolved subagent model has no working credentials. See #985. @@ -455,6 +470,8 @@ export interface ExecutorOptions { * set this false so disposal unregisters them instead of leaving idle peers. */ keepAlive?: boolean; + /** Internal ownership handoff for cleanup that outlives the visible Task result. */ + onCleanupDeferred?: (completion: Promise) => void; } function parseStringifiedJson(value: unknown): unknown { @@ -877,6 +894,8 @@ interface RunMonitorArgs { /** Parent settings for tiny-model label generation. */ settings?: Settings; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; signal?: AbortSignal; onProgress?: (progress: AgentProgress) => void; eventBus?: EventBus; @@ -992,6 +1011,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { cost: 0, durationMs: 0, modelOverride: args.modelOverride, + modelRole: args.modelRole, }; const outputChunks: string[] = []; @@ -1964,9 +1984,7 @@ async function driveSessionToYield( // yield: the next iteration's ladder demands a fresh one. } - if (monitor.yieldCalled()) { - await session.waitForIdle(); - } else { + if (!monitor.yieldCalled()) { await awaitAbortable(session.waitForIdle()); } @@ -2044,6 +2062,8 @@ interface FinalizeRunArgs { task: string; assignment?: string; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; outputSchemaSource?: StructuredSubagentSchemaSource; @@ -2063,7 +2083,7 @@ interface FinalizeRunArgs { * event. */ async function finalizeRunResult(args: FinalizeRunArgs): Promise { - const { monitor, done, index, id, agent, task, assignment, signal, modelOverride } = args; + const { monitor, done, index, id, agent, task, assignment, signal, modelOverride, modelRole } = args; const progress = monitor.progress; let exitCode = done.exitCode; let stderr = done.error ?? ""; @@ -2140,14 +2160,17 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise { exitCode = 1; } const wasAborted = - runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false)); + runtimeLimitExceeded || Boolean(done.aborted) || abortedViaYield || (!hasYield && Boolean(signal?.aborted)); const finalAbortReason = wasAborted ? runtimeLimitExceeded ? monitor.resolveAbortReasonText() - : abortedViaYield - ? yieldAbortReason - : (done.abortReason ?? - (signal?.aborted ? monitor.resolveSignalAbortReason() : monitor.resolveAbortReasonText())) + : done.aborted + ? (done.abortReason ?? monitor.resolveAbortReasonText()) + : abortedViaYield + ? yieldAbortReason + : signal?.aborted + ? monitor.resolveSignalAbortReason() + : monitor.resolveAbortReasonText() : undefined; progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed"; monitor.scheduleProgress(true); @@ -2187,6 +2210,7 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise { contextTokens: progress.contextTokens, contextWindow: progress.contextWindow, modelOverride, + modelRole, resolvedModel: progress.resolvedModel, resolvedModelIsFallback: progress.resolvedModelIsFallback, error: exitCode !== 0 && stderr ? stderr : undefined, @@ -2208,6 +2232,8 @@ export interface IrcWakeTurnMonitorOptions { agent: AgentDefinition; description?: string; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; eventBus?: EventBus; parentToolCallId?: string; /** Fallback session file when the registry ref carries none. */ @@ -2252,6 +2278,7 @@ export function attachIrcWakeTurnMonitor(session: AgentSession, options: IrcWake task: ircTask, description: options.description, modelOverride: options.modelOverride, + modelRole: options.modelRole, eventBus: options.eventBus, parentToolCallId: options.parentToolCallId, detached: true, @@ -2309,6 +2336,7 @@ export function attachIrcWakeTurnMonitor(session: AgentSession, options: IrcWake agent, task: ircTask, modelOverride: options.modelOverride, + modelRole: options.modelRole, outputSchema: options.outputSchema, outputSchemaMode: options.outputSchemaMode, outputSchemaSource: options.outputSchemaSource, @@ -2346,15 +2374,27 @@ export async function finalizeSubagentLifecycle(args: { isolated: boolean; agentIdleTtlMs: number; reviveSession: AgentReviver | null; + cleanupDeadlineAt?: number; + onCleanupDeferred?: (completion: Promise) => void; }): Promise { const registry = AgentRegistry.global(); const ref = registry.get(args.id); const ownsRef = Boolean(ref && ref.session === args.session); + const cleanupDeadlineAt = args.cleanupDeadlineAt ?? Date.now() + 5000; const disposeSession = async (): Promise => { + const disposal = args.session.dispose(); + const remainingMs = Math.max(0, cleanupDeadlineAt - Date.now()); try { - await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose()); - } catch { - // Ignore cleanup errors + await untilAborted(AbortSignal.timeout(remainingMs), () => disposal); + } catch (error) { + if (Date.now() >= cleanupDeadlineAt) { + args.onCleanupDeferred?.(disposal); + return; + } + logger.warn("Subagent session cleanup failed", { + id: args.id, + error: error instanceof Error ? error.message : String(error), + }); } }; @@ -2365,14 +2405,20 @@ export async function finalizeSubagentLifecycle(args: { args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null; if (args.aborted && !resumableAbort) { if (ref && ownsRef) { - // Terminal hard kill: mark `aborted` and detach the session before - // disposing so the ref satisfies the AgentRef invariant (session null - // when aborted) — ensureLive/hub focus must treat it as terminal, never - // route into the disposed session. - registry.setStatus(args.id, "aborted", ref); - registry.detachSession(args.id, ref); + // Route hard kills through the lifecycle owner so the terminal + // decision is durable and a restart cannot rediscover the transcript + // as a revivable parked agent. + try { + await AgentLifecycleManager.global().release(args.id, ref, { tombstone: true }); + } catch (error) { + logger.warn("runSubagent: failed to persist kill tombstone", { id: args.id, error: String(error) }); + registry.setStatus(args.id, "aborted", ref); + registry.detachSession(args.id, ref); + await disposeSession(); + } + } else { + await disposeSession(); } - await disposeSession(); return; } @@ -2421,6 +2467,8 @@ export interface FollowUpTurnOptions { message: string; index?: number; description?: string; + /** Explicit pre-expansion model role alias retained from the original run. */ + modelRole?: string; /** Structured-output state retained from the original invocation. */ outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; @@ -2460,6 +2508,7 @@ export async function runSubagentFollowUpTurn(options: FollowUpTurnOptions): Pro agent, task: message, description: options.description, + modelRole: options.modelRole, signal, onProgress: options.onProgress, eventBus: options.eventBus, @@ -2509,6 +2558,7 @@ export async function runSubagentFollowUpTurn(options: FollowUpTurnOptions): Pro id, agent, task: message, + modelRole: options.modelRole, outputSchema: options.outputSchema, outputSchemaMode: options.outputSchemaMode, outputSchemaSource: options.outputSchemaSource, @@ -2535,6 +2585,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise[] = []; + let deferredSessionShutdown: Promise | undefined; + const deferCleanup = (completion: Promise): void => { + lateCleanups.push(completion); + exitCode = 1; + aborted = true; + abortReasonText = `cleanup exceeded ${TASK_ABORT_CLEANUP_GRACE_MS} ms`; + error ??= `Task aborted. Cleanup did not finish within ${TASK_ABORT_CLEANUP_GRACE_MS} ms. ${cleanupChangeStatus}`; + }; if (abortSignal.aborted) { aborted = monitor.isAbortedRun(); if (aborted) { @@ -3194,10 +3266,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise monitor.waitForActiveSessionAbort()); - } catch { - // Ignore abort cleanup timeouts/errors; terminal disposal below is still best-effort. + await untilAborted( + AbortSignal.timeout(Math.max(0, cleanupDeadlineAt - Date.now())), + () => activeSessionAbort, + ); + } catch (cleanupError) { + if (Date.now() >= cleanupDeadlineAt) { + deferCleanup(activeSessionAbort); + } else { + logger.warn("Subagent abort cleanup failed", { + id, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + } } if (unsubscribe) { try { @@ -3207,6 +3290,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + deferredSessionShutdown = completion; + deferCleanup(completion); + }, }); } - // Structured-concurrency reap: cancel and await ALL surviving owner - // jobs (abort paths; suppressed/watched jobs the model left behind) - // so isolation capture/cleanup never races a live process writing - // into the worktree. This never proceeds while an owner process is - // live: cancellation SIGKILL-escalates, so settlement is expected - // within one interval — an unkillable process blocks here visibly - // (with periodic warnings) instead of silently racing teardown. - const jobManager = AsyncJobManager.instance(); if (jobManager) { - jobManager.cancelAll({ ownerId: id }); - while (!(await jobManager.waitForOwnerJobs(id, { timeoutMs: 10_000 }))) { - logger.warn("Subagent async jobs still settling; delaying teardown until process exit", { id }); + if (deferredSessionShutdown) { + const finalReap = Promise.allSettled([deferredSessionShutdown]).then(async () => { + const reap = await jobManager.cancelAndReapOwnerJobs(id, Date.now()); + await reap.completion; + }); + lateCleanups.push(finalReap); + } else { + const reap = await jobManager.cancelAndReapOwnerJobs(id, cleanupDeadlineAt); + if (!reap.settled) { + deferCleanup(reap.completion); + logger.warn("Subagent async job cleanup exceeded its deadline after session shutdown", { + id, + pendingJobIds: reap.pendingJobIds, + }); + } } } + if (lateCleanups.length > 0) { + const completion = Promise.allSettled(lateCleanups).then(() => {}); + trackLateCleanup(completion, { id, resource: "subagent" }); + options.onCleanupDeferred?.(completion); + } } // Launch-latency breakdown (subagent invocation → first chat dispatch). @@ -3282,7 +3390,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise = new Set([ - "read", - "grep", - "glob", - "web_search", - "ast_grep", - "yield", - "hub", - "ask", - "todo", - "recall", - "reflect", - "retain", - "memory_edit", - "inspect_image", - "checkpoint", - "rewind", -]); - -export function isReadOnlyAgent(agent: AgentDefinition): boolean { - return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool)); -} - /** * Preview text for a child result. Falls back to "(no output)" — annotated * with the request count when the child actually did work, so the parent can @@ -860,6 +836,7 @@ export class TaskTool implements AgentTool { let handle: IsolationHandle | undefined; + let deferredCleanup: Promise | undefined; try { const taskBaseline = structuredClone(opts.context.baseline); handle = await ensureIsolation(opts.context.repoRoot, opts.agentId, opts.preferredBackend); @@ -155,7 +167,12 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise { + deferredCleanup = completion; + opts.baseOptions.onCleanupDeferred?.(completion); + }, }); + if (deferredCleanup) return result; if (opts.mergeMode === "branch" && result.exitCode === 0) { try { const commitResult = await commitToBranch( @@ -165,14 +182,14 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise cleanupIsolation(isolationHandle)), + { + agentId: opts.agentId, + resource: "isolation", + }, + ); + } else { + await cleanupIsolation(isolationHandle); + } } } } diff --git a/packages/coding-agent/src/task/persisted-revive.ts b/packages/coding-agent/src/task/persisted-revive.ts index a91b01a02..d55c9bb44 100644 --- a/packages/coding-agent/src/task/persisted-revive.ts +++ b/packages/coding-agent/src/task/persisted-revive.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; - import type { ModelRegistry } from "../config/model-registry"; +import { formatModelRoleAlias } from "../config/model-roles"; import type { Settings } from "../config/settings"; import { MCPManager } from "../mcp/manager"; import type { PersistedSubagentReviverFactory } from "../registry/agent-lifecycle"; @@ -79,6 +79,14 @@ export function createPersistedSubagentReviverFactory( taskDepth++; parentId = registry.get(parentId)?.parentId; } + const subagentSettings = createSubagentSettings( + ctx.settings, + init.readSummarize === false ? { "read.summarize.enabled": false } : undefined, + ); + const persistedModelPattern = + init.modelRole && init.modelRole !== "default" + ? [formatModelRoleAlias(init.modelRole), ...(init.resolvedModel ? [init.resolvedModel] : [])] + : init.resolvedModel; return async expectedRef => { // Re-open fresh on every revive: park closes the writer, so this takes // the single-writer lock cleanly and restores the full message history. @@ -96,10 +104,9 @@ export function createPersistedSubagentReviverFactory( cwd: ctx.session.sessionManager.getCwd(), authStorage: ctx.authStorage, modelRegistry: ctx.modelRegistry, - settings: createSubagentSettings( - ctx.settings, - init.readSummarize === false ? { "read.summarize.enabled": false } : undefined, - ), + ...(persistedModelPattern ? { modelPattern: persistedModelPattern } : {}), + modelPatternAuthFallback: init.resolvedModel, + settings: subagentSettings, sessionManager: reopened, agentId: ref.id, agentDisplayName: ref.displayName, diff --git a/packages/coding-agent/src/task/read-only-policy.ts b/packages/coding-agent/src/task/read-only-policy.ts new file mode 100644 index 000000000..2e5a3a018 --- /dev/null +++ b/packages/coding-agent/src/task/read-only-policy.ts @@ -0,0 +1,27 @@ +import type { AgentDefinition } from "./types"; + +// Built-in tools whose approval tier is "read" (see tool classes' `approval`). +// An agent is read-only iff its declared tools are a non-empty subset of this set. +// Fail-safe: any unknown tool makes the agent not read-only. +export const READ_ONLY_TOOL_NAMES: ReadonlySet = new Set([ + "read", + "grep", + "glob", + "web_search", + "ast_grep", + "yield", + "hub", + "ask", + "todo", + "recall", + "reflect", + "retain", + "memory_edit", + "inspect_image", + "checkpoint", + "rewind", +]); + +export function isReadOnlyAgent(agent: AgentDefinition): boolean { + return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool)); +} diff --git a/packages/coding-agent/src/task/structured-subagent.ts b/packages/coding-agent/src/task/structured-subagent.ts index 0726a102c..a1e29ffb3 100644 --- a/packages/coding-agent/src/task/structured-subagent.ts +++ b/packages/coding-agent/src/task/structured-subagent.ts @@ -8,7 +8,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import path from "node:path"; import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils"; -import { resolveAgentModelPatterns } from "../config/model-resolver"; +import { resolveAgentModelPatterns, resolveAgentModelSource, resolveExplicitModelRole } from "../config/model-resolver"; import type { LocalProtocolOptions } from "../internal-urls"; import { registerArtifactsDir } from "../internal-urls/registry-helpers"; import { MCPManager } from "../mcp/manager"; @@ -20,6 +20,7 @@ import type { TaskEffort } from "../thinking"; import type { ToolSession } from "../tools"; import { isIrcEnabled } from "../tools/hub"; import { buildOutputValidator } from "../tools/output-schema-validator"; +import { trackLateCleanup } from "../utils/late-cleanup"; import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery"; import { type ExecutorOptions, runSubprocess } from "./executor"; import { @@ -122,6 +123,8 @@ export interface EffectiveSubagentPolicy { agent: AgentDefinition; effectiveAgent: AgentDefinition; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; parentActiveModelPattern?: string; schema: StructuredSubagentSchemaResolution; planMode: boolean; @@ -277,13 +280,18 @@ export async function resolveEffectiveSubagentPolicy( } const agentModelOverrides = request.session.settings.get("task.agentModelOverrides"); const parentActiveModelPattern = request.session.getActiveModelString?.(); - const modelOverride = resolveAgentModelPatterns({ - settingsOverride: request.model ?? agentModelOverrides[agentName], + const modelResolution = { + requestModel: request.model, + settingsOverride: agentModelOverrides[agentName], agentModel: effectiveAgent.model, settings: request.session.settings, activeModelPattern: parentActiveModelPattern, fallbackModelPattern: request.session.getModelString?.(), - }); + }; + // Keep role identity from the same effective non-empty source that supplies + // model selection: caller request, settings override, then agent definition. + const modelRole = resolveExplicitModelRole(resolveAgentModelSource(modelResolution), request.session.settings); + const modelOverride = resolveAgentModelPatterns(modelResolution); const isolationMode = request.session.settings.get("task.isolation.mode"); const isIsolated = request.isolation?.requested === true; if (isIsolated && isolationMode === "none") { @@ -298,6 +306,7 @@ export async function resolveEffectiveSubagentPolicy( agent, effectiveAgent, modelOverride, + modelRole, parentActiveModelPattern, schema, planMode, @@ -393,6 +402,7 @@ function buildExecutorOptions( invokedAt: request.invokedAt, acquiredAt: request.acquiredAt, modelOverride: policy.modelOverride, + modelRole: policy.modelRole, parentActiveModelPattern: policy.parentActiveModelPattern, thinkingLevel: policy.effectiveAgent.thinkingLevel, effort: request.effort, @@ -475,6 +485,7 @@ function buildFailureResult( tokens: 0, requests: 0, modelOverride: policy.modelOverride, + modelRole: policy.modelRole, error: message, }; }; @@ -540,12 +551,16 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest): let mergeSummary = ""; let requiresRecoveryArtifacts = false; let completedSuccessfully = false; + let deferredCleanup: Promise | undefined; try { const id = await reserveStructuredSubagentId(request.session, { ...request.identity, label: request.identity?.label ?? (request.invocationKind === "eval" ? "EvalAgent" : undefined), }); const baseOptions = buildExecutorOptions(request, policy, lease, id); + baseOptions.onCleanupDeferred = completion => { + deferredCleanup = completion; + }; baseOptions.planReference = await loadPlanReference(request, policy); let isolationContext: IsolationContext | null = null; if (policy.isIsolated) { @@ -639,8 +654,18 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest): (policy.isIsolated && (!policy.applyChanges || changesApplied === false || requiresRecoveryArtifacts)); const shouldCleanup = lease.temporary && !shouldRetainArtifacts; if (shouldCleanup) { - await fs.rm(lease.artifactsDir, { recursive: true, force: true }); - lease.unregister?.(); + const cleanupArtifacts = async (): Promise => { + await fs.rm(lease.artifactsDir, { recursive: true, force: true }); + lease.unregister?.(); + }; + if (deferredCleanup) { + trackLateCleanup(deferredCleanup.then(cleanupArtifacts), { + resource: "artifacts", + artifactsDir: lease.artifactsDir, + }); + } else { + await cleanupArtifacts(); + } } } } diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index 3edc92696..efaa901ce 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -426,6 +426,8 @@ export interface AgentProgress { cost: number; durationMs: number; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; /** Resolved model display string in the form `/`, optionally suffixed with `:` when the level was set explicitly. Undefined when the model could not be resolved. */ resolvedModel?: string; /** True when {@link resolvedModel} is the target of an active retry fallback (not the originally configured model). Lets observer-only UIs (collab guests, Agent Hub rows with no live session) flag the fallback and keep the provider. */ @@ -495,6 +497,8 @@ export interface SingleResult { /** Model's context window in tokens, when known. */ contextWindow?: number; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; /** Resolved model display string in the form `/`, optionally suffixed with `:` when the level was set explicitly. Omitted from tool-result JSON when undefined to keep wire payloads small. */ resolvedModel?: string; /** True when {@link resolvedModel} is the target of an active retry fallback. Mirrors {@link AgentProgress.resolvedModelIsFallback} onto the settled result. */ diff --git a/packages/coding-agent/src/tiny/worker.ts b/packages/coding-agent/src/tiny/worker.ts index 0f60183ea..ca056baa6 100644 --- a/packages/coding-agent/src/tiny/worker.ts +++ b/packages/coding-agent/src/tiny/worker.ts @@ -94,7 +94,7 @@ function createStopOnTextCriteria( this.#text = text; } - _call(inputIds: number[][]): boolean[] { + override _call(inputIds: number[][]): boolean[] { return inputIds.map(ids => { const tail = ids.slice(-STOP_DECODE_WINDOW_TOKENS); const decoded = this.#tokenizer.decode(tail, { diff --git a/packages/coding-agent/src/tools/bash-interceptor.ts b/packages/coding-agent/src/tools/bash-interceptor.ts index 6d2e83613..baa6cfbdc 100644 --- a/packages/coding-agent/src/tools/bash-interceptor.ts +++ b/packages/coding-agent/src/tools/bash-interceptor.ts @@ -96,10 +96,14 @@ function withoutLeadingEnvironmentAssignments(command: string): string | null { function interceptionCandidates(command: string): string[] { const candidates = [command.trim()]; - const segments = extractFlatShellCommandSegments(command); - candidates.push(...segments.map(segment => segment.trim())); - for (const segment of segments) { - const withoutAssignments = withoutLeadingEnvironmentAssignments(segment); + for (const segment of extractFlatShellCommandSegments(command)) { + // A segment that consumes the previous stage's stdout via `|` reads piped + // stdin, which no path-based dedicated tool (read/grep/glob) — nor any + // other dedicated tool — can replace, so it is not an interception + // candidate. Standalone and first-stage commands still match. + if (segment.pipedStdin) continue; + candidates.push(segment.text); + const withoutAssignments = withoutLeadingEnvironmentAssignments(segment.text); if (withoutAssignments) candidates.push(withoutAssignments); } return candidates; diff --git a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts index 49e783d0a..e57bf97f1 100644 --- a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts +++ b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts @@ -8,7 +8,16 @@ import { resizeImage } from "../../../utils/image-resize"; import type { ToolSession } from "../../index"; import { resolveToCwd } from "../../path-utils"; import { formatScreenshot } from "../../render-utils"; -import { bindRunFacade, resolvePredicateTimeout, type WaitPredicateOptions, waitForRun } from "../../run-scope"; +import { + bindRunFacade, + isBrowserRunOwnedRejection, + markBrowserRunRejection, + observeBrowserRunPromise, + resolvePredicateTimeout, + type WaitPredicateOptions, + waitForRun, + withBrowserPromiseCombinatorTracking, +} from "../../run-scope"; import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors"; import { type AriaSnapshotOptions, assertSelectorString, buildAriaSnapshotScript } from "../aria/aria-snapshot"; import { DEFAULT_VIEWPORT } from "../launch"; @@ -1368,6 +1377,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi const signal = AbortSignal.any( opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal], ); + const runEndedError = postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")); const output = new RunOutput(); const screenshots: ScreenshotResult[] = []; const runId = crypto.randomUUID(); @@ -1382,6 +1392,28 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi // handler to this promise; keep its armed rejection from surfacing as an // unhandled rejection — the postmortem-fatal path this run guards against. cancelRejection.catch(() => {}); + const rejectionOwner = {}; + const { promise: floatingFailure, reject: rejectFloatingFailure } = Promise.withResolvers(); + floatingFailure.catch(() => {}); + let runActive = true; + let hasFloatingFailure = false; + const recordFloatingFailure = (reason: unknown): void => { + if (hasFloatingFailure || postmortem.isExpectedCleanupError(reason)) return; + const message = reason instanceof Error ? reason.message : String(reason); + if (!runActive) { + logger.warn("Unhandled rejection after browser run ended", { runId, error: message }); + return; + } + hasFloatingFailure = true; + const error = new Error(`Unhandled rejection (missing await?): ${message}`, { cause: reason }); + if (reason instanceof Error) error.name = reason.name; + rejectFloatingFailure(error); + }; + const uninstallRejectionInterceptor = postmortem.interceptUnhandledRejections(reason => { + if (!isBrowserRunOwnedRejection(reason, rejectionOwner, `cmux-run-${runId}.js`)) return false; + recordFloatingFailure(reason); + return true; + }); const onAbort = (): void => { if (timeoutSignal.aborted) { reject(new ToolError(`Browser code execution timed out after ${opts.timeoutMs}ms`)); @@ -1402,24 +1434,30 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi // Keep both inside try so a concurrent in-process eval/browser run surfaces as // a rejected promise the supervisor can report, never an unhandled rejection. runtime.setCwd(opts.snapshot.cwd); - const runTab = bindRunFacade(tab, signal); + const runTab = bindRunFacade(tab, signal, rejectionOwner, recordFloatingFailure); runtime.setRunScope({ - page: bindRunFacade(tab.page, signal), - browser: bindRunFacade(tab.browser, signal), + page: bindRunFacade(tab.page, signal, rejectionOwner, recordFloatingFailure), + browser: bindRunFacade(tab.browser, signal, rejectionOwner, recordFloatingFailure), tab: runTab, assert: (cond: unknown, text?: string): void => { if (!cond) throw new ToolError(text ?? "Assertion failed"); }, wait: (msOrPredicate: number | (() => unknown), waitOpts?: WaitPredicateOptions): Promise => - waitForRun( - msOrPredicate, - signal, - typeof msOrPredicate === "number" - ? waitOpts - : { - timeout: resolvePredicateTimeout(opts.timeoutMs, waitOpts?.timeout), - interval: waitOpts?.interval, - }, + observeBrowserRunPromise( + waitForRun( + msOrPredicate, + signal, + typeof msOrPredicate === "number" + ? waitOpts + : { + timeout: resolvePredicateTimeout(opts.timeoutMs, waitOpts?.timeout), + interval: waitOpts?.interval, + }, + ).catch(error => { + throw markBrowserRunRejection(error, rejectionOwner); + }), + rejectionOwner, + recordFloatingFailure, ), }); @@ -1444,16 +1482,24 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi let runError: unknown; let runFailed = false; try { - returnValue = await Promise.race([ - runtime.run(opts.code, filename, hooks, { runId, cwd: opts.snapshot.cwd }), - cancelRejection, - ]); + returnValue = await withBrowserPromiseCombinatorTracking( + rejectionOwner, + recordFloatingFailure, + async () => + await Promise.race([ + runtime.run(opts.code, filename, hooks, { runId, cwd: opts.snapshot.cwd }), + cancelRejection, + floatingFailure, + ]), + ); } catch (error) { runFailed = true; runError = error; } + runAc.abort(runEndedError); // Let rejection callbacks run while this run can still own guest-created promises. await Bun.sleep(0); + if (hasFloatingFailure && !runFailed) await floatingFailure; if (runFailed) { for (const reason of activeRun.floatingRejections) { logger.warn("Unhandled rejection accompanied a failed cmux browser run", { filename, error: reason }); @@ -1470,8 +1516,10 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi } return { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots }; } finally { + runActive = false; + uninstallRejectionInterceptor(); signal.removeEventListener("abort", onAbort); - runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"))); + runAc.abort(runEndedError); activeCmuxRuns.delete(filename); rememberCmuxRunFile(filename); tab.clearRunContext(); diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index c3bb19b05..c371e5eac 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -121,9 +121,9 @@ async function loadBrowsers(): Promise { } /** - * Resolve the Chromium executable puppeteer will launch, lazily downloading it - * on first use via @puppeteer/browsers. Skipped when a system Chromium (NixOS) - * or PUPPETEER_EXECUTABLE_PATH is set. The browser is cached under + * 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 @@ -131,10 +131,10 @@ async function loadBrowsers(): Promise { */ let chromiumExecutablePromise: Promise | undefined; export async function ensureChromiumExecutable(): Promise { - const sysChrome = resolveSystemChromium(); - if (sysChrome) return sysChrome; const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; if (envPath) return envPath; + const sysChrome = resolveSystemChromium(); + if (sysChrome) return sysChrome; if (chromiumExecutablePromise) return chromiumExecutablePromise; chromiumExecutablePromise = (async () => { @@ -199,10 +199,16 @@ function isExecutableFile(p: string): boolean { } } -function systemChromiumCandidates(): string[] { - const home = os.homedir(); +/** Flatpak application id published by the Ungoogled Chromium project. */ +const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; + +function systemChromiumCandidates( + platform: NodeJS.Platform = process.platform, + home = os.homedir(), + which: (name: string) => string | null | undefined = $which, +): string[] { const candidates: string[] = []; - switch (process.platform) { + switch (platform) { case "darwin": { for (const root of ["/Applications", path.join(home, "Applications")]) { candidates.push( @@ -219,7 +225,7 @@ function systemChromiumCandidates(): string[] { case "linux": { const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]; for (const name of names) { - const found = $which(name); + const found = which(name); if (found) candidates.push(found); } candidates.push( @@ -238,6 +244,19 @@ function systemChromiumCandidates(): string[] { if (onNixos) { candidates.push(path.join(home, ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium"); } + for (const name of ["ungoogled-chromium", "ungoogled-chromium-browser"]) { + const found = which(name); + if (found) candidates.push(found); + } + candidates.push( + // Ungoogled Chromium. Distro and AUR packages that keep the plain + // `chromium` name are already covered above; these are the paths + // unique to it, including the system and per-user Flatpak shims. + "/usr/bin/ungoogled-chromium", + "/usr/bin/ungoogled-chromium-browser", + `/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, + path.join(home, ".local/share/flatpak/exports/bin", UNGOOGLED_CHROMIUM_FLATPAK_ID), + ); break; } case "win32": { @@ -866,6 +885,15 @@ export async function applyStealthPatches( await injectStealthScripts(page); } +/** Exposes executable candidates for detection tests. */ +export function systemChromiumCandidatesForTest( + platform: NodeJS.Platform = process.platform, + home?: string, + which?: (name: string) => string | null | undefined, +): string[] { + return systemChromiumCandidates(platform, home, which); +} + export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] { return stealthIgnoreDefaultArgs(executablePath); } diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts index 6deea3931..767b48c64 100644 --- a/packages/coding-agent/src/tools/browser/tab-supervisor.ts +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -134,6 +134,7 @@ const tabs = new Map(); // awaits) cannot interleave and leak a worker + browser refCount. const acquireChains = new Map>(); const GRACE_MS = 750; +const WORKER_INIT_TIMEOUT_MS = 15_000; // Names of tabs the supervisor force-killed (timeout past grace, failed recycle), // mapped to the kill reason. Lets the next `run` on that name explain WHY the tab // vanished instead of a bare "not alive". Cleared when the name is opened again. @@ -272,7 +273,11 @@ async function acquireTabImpl( } let info: ReadyInfo; try { - info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS); + info = await initializeTabWorker( + worker, + initPayload, + Math.max(WORKER_INIT_TIMEOUT_MS, opts.timeoutMs + GRACE_MS), + ); } catch (error) { // `BuildMessage`-class failures arrive asynchronously via the worker's `error` event, // after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to @@ -287,7 +292,11 @@ async function acquireTabImpl( }); worker = await spawnInlineWorker(); try { - info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS); + info = await initializeTabWorker( + worker, + initPayload, + Math.max(WORKER_INIT_TIMEOUT_MS, opts.timeoutMs + GRACE_MS), + ); } catch (inlineError) { await worker.terminate().catch(() => undefined); if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false }); @@ -1014,7 +1023,7 @@ async function spawnInlineWorker(): Promise { close: () => {}, }; const { WorkerCore } = await import("./tab-worker"); - new WorkerCore(workerTransport); + new WorkerCore(workerTransport, false); return { mode: "inline", send: msg => diff --git a/packages/coding-agent/src/tools/browser/tab-worker-entry.ts b/packages/coding-agent/src/tools/browser/tab-worker-entry.ts index 38c0d1c7d..9c572c874 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker-entry.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker-entry.ts @@ -26,4 +26,4 @@ const transport: Transport = { }, }; -new WorkerCore(transport); +new WorkerCore(transport, true); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index 8f257feac..97eb8e497 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -23,10 +23,15 @@ import { formatScreenshot } from "../render-utils"; import { bindRunFacade, CELL_BUDGET_SLACK_MS, + installBrowserWorkerRejectionGuard, + isBrowserRunOwnedRejection, + markBrowserRunRejection, markHandled, + observeBrowserRunPromise, resolvePredicateTimeout, type WaitPredicateOptions, waitForRun, + withBrowserPromiseCombinatorTracking, } from "../run-scope"; import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors"; import { @@ -44,6 +49,7 @@ import { loadPuppeteerInWorker, } from "./launch"; import { extractReadableFromHtml, type ReadableFormat } from "./readable"; + import { cloneSafe, RunOutput } from "./run-output"; import type { Observation, @@ -701,6 +707,9 @@ interface ActiveRun { output: RunOutput; screenshots: ScreenshotResult[]; pendingTools: Map; + rejectionOwner: object; + floatingRejections: unknown[]; + floatingFailure: { promise: Promise; reject(reason?: unknown): void }; /** Helper invocations currently awaiting the page/network, keyed by op id. */ inflight: Map; opCounter: number; @@ -748,17 +757,75 @@ export class WorkerCore { #active: ActiveRun | null = null; #runtime: JsRuntime | null = null; #unsub: () => void; + #isolated: boolean; + #uninstallRejectionGuard: () => void; #mode?: WorkerInitPayload["mode"]; #activateForScreenshot = true; #dialogPolicy?: DialogPolicy; #dialogHandler?: (dialog: Dialog) => void; #openDialog?: OpenDialogInfo; - constructor(transport: Transport) { + constructor(transport: Transport, isolated: boolean) { this.#transport = transport; + this.#isolated = isolated; this.#unsub = this.#transport.onMessage(msg => { void this.#handleMessage(msg as WorkerInbound); }); + this.#uninstallRejectionGuard = this.#installRejectionGuard(); + } + + #installRejectionGuard(): () => void { + if (!this.#isolated) { + return postmortem.interceptUnhandledRejections(reason => this.#consumeUnhandledRejection(reason)); + } + return installBrowserWorkerRejectionGuard(reason => this.#consumeUnhandledRejection(reason)); + } + + #consumeUnhandledRejection(reason: unknown): boolean { + const active = this.#active; + if (!active) return false; + if (!isBrowserRunOwnedRejection(reason, active.rejectionOwner, `browser-run-${active.id}.js`)) return false; + this.#recordFloatingRejection(active, reason); + return true; + } + + #recordFloatingRejection(active: ActiveRun, reason: unknown): void { + if (postmortem.isExpectedCleanupError(reason)) return; + if (this.#active !== active) { + this.#log("warn", "Unhandled rejection after browser run ended", { + runId: active.id, + error: reason instanceof Error ? reason.message : String(reason), + }); + return; + } + const isFirst = active.floatingRejections.length === 0; + active.floatingRejections.push(reason); + if (isFirst) active.floatingFailure.reject(this.#floatingRejectionError(reason)); + } + + #floatingRejectionError(reason: unknown): Error { + const message = reason instanceof Error ? reason.message : String(reason); + const error = new Error(`Unhandled rejection (missing await?): ${message}`, { cause: reason }); + if (reason instanceof Error) error.name = reason.name; + return error; + } + + #foldFloatingRejections(active: ActiveRun, failure: { error: unknown } | undefined): { error: unknown } | undefined { + const rejections = active.floatingRejections; + if (rejections.length === 0) return failure; + let reported = rejections; + if (!failure) { + failure = { error: this.#floatingRejectionError(rejections[0]) }; + reported = rejections.slice(1); + } else if (failure.error instanceof Error && failure.error.cause === rejections[0]) { + reported = rejections.slice(1); + } + for (const reason of reported) { + this.#log("warn", "Additional unhandled browser-run rejection", { + error: reason instanceof Error ? reason.message : String(reason), + }); + } + return failure; } nextElementId(): number { @@ -973,6 +1040,7 @@ export class WorkerCore { const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]); const output = new RunOutput(); const screenshots: ScreenshotResult[] = []; + const floatingFailure = Promise.withResolvers(); const active: ActiveRun = { id: msg.id, ac, @@ -980,6 +1048,9 @@ export class WorkerCore { output, screenshots, pendingTools: new Map(), + rejectionOwner: {}, + floatingRejections: [], + floatingFailure, inflight: new Map(), opCounter: 0, }; @@ -995,10 +1066,11 @@ export class WorkerCore { const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, output, screenshots, active); const runtime = this.#ensureRuntime(msg.session); runtime.setCwd(msg.session.cwd); + const onFloatingRejection = (reason: unknown): void => this.#recordFloatingRejection(active, reason); runtime.setRunScope({ - page: bindRunFacade(runPage.page, signal), - browser: bindRunFacade(browser, signal), - tab: bindRunFacade(tabApi, signal), + page: bindRunFacade(runPage.page, signal, active.rejectionOwner, onFloatingRejection), + browser: bindRunFacade(browser, signal, active.rejectionOwner, onFloatingRejection), + tab: bindRunFacade(tabApi, signal, active.rejectionOwner, onFloatingRejection), assert: (cond: unknown, text?: string): void => { if (!cond) throw new ToolError(text ?? "Assertion failed"); }, @@ -1010,10 +1082,12 @@ export class WorkerCore { typeof msOrPredicate === "number" ? undefined : { timeout: resolvePredicateTimeout(msg.timeoutMs, opts?.timeout), interval: opts?.interval }; - return markHandled( + return observeBrowserRunPromise( this.#runOp(active, label, signal, Number.POSITIVE_INFINITY, sig => waitForRun(msOrPredicate, sig, resolved), ), + active.rejectionOwner, + onFloatingRejection, ); }, }); @@ -1051,10 +1125,19 @@ export class WorkerCore { try { const hooks = this.#hooksForActiveRun(); if (!hooks) throw new ToolError("Browser runtime started without an active run"); - returnValue = await Promise.race([ - runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { runId: msg.id, cwd: msg.session.cwd }), - cancelRejection, - ]); + returnValue = await withBrowserPromiseCombinatorTracking( + active.rejectionOwner, + onFloatingRejection, + async () => + await Promise.race([ + runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { + runId: msg.id, + cwd: msg.session.cwd, + }), + cancelRejection, + floatingFailure.promise, + ]), + ); completed = true; } finally { signal.removeEventListener("abort", onCancel); @@ -1063,11 +1146,13 @@ export class WorkerCore { failure = { error }; } finally { runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"))); + await Bun.sleep(0); try { await runPage?.cleanup(); } catch (error) { failure = { error }; } + failure = this.#foldFloatingRejections(active, failure); if (this.#active?.id === msg.id) this.#active = null; } if (failure) { @@ -1182,9 +1267,12 @@ export class WorkerCore { (opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError")) ) { const hint = selector ? await this.#selectorTimeoutHint(selector) : ""; - throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`); + throw markBrowserRunRejection( + new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`), + active.rejectionOwner, + ); } - throw err; + throw markBrowserRunRejection(err, active.rejectionOwner); } finally { earlyAc.abort(); active.inflight.delete(opId); @@ -1868,6 +1956,7 @@ export class WorkerCore { async #close(): Promise { this.#unsub(); + this.#uninstallRejectionGuard(); this.#clearElementCache(); const page = this.#page; if (this.#dialogHandler && page && !page.isClosed()) page.off("dialog", this.#dialogHandler); diff --git a/packages/coding-agent/src/tools/glob.ts b/packages/coding-agent/src/tools/glob.ts index 882205191..7a8f797ad 100644 --- a/packages/coding-agent/src/tools/glob.ts +++ b/packages/coding-agent/src/tools/glob.ts @@ -6,7 +6,7 @@ import type { ToolExample } from "@oh-my-pi/pi-ai"; import * as natives from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; +import { formatGroupedPaths, hasFsCode, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; import { splitMemoryGlobPattern } from "../internal-urls/memory-protocol"; @@ -415,7 +415,9 @@ export class GlobTool implements AgentTool { try { stat = await fs.promises.stat(target.searchPath); } catch (err) { - if (isEnoent(err)) { + // ENAMETOOLONG can never name a real target; surface a clean + // "Path not found" instead of leaking the raw errno (issue #7597). + if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) { if (isSingle) throw new ToolError(`Path not found: ${scopePath}`); return []; } diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index aea44a068..a69bfb2e2 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import * as url from "node:url"; import { glob } from "@oh-my-pi/pi-natives"; -import { isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils"; +import { hasFsCode, isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils"; import type { Skill } from "../extensibility/skills"; import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls"; import { ToolAbortError, ToolError } from "./tool-errors"; @@ -337,6 +337,13 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string } * plus selector `1-2` (issue #4618). `lstat` inspects the entry itself, so a * dangling symlink is still detected as present; ambiguous errors resolve to * `"unknown"` so callers keep the raw path instead of guessing. + * + * `ENAMETOOLONG` resolves to `"missing"` rather than `"unknown"`: a path whose + * component or whole length exceeds the OS limit can never name a real single + * entry, so it is strictly stronger evidence of non-existence than `ENOENT`. + * Without this, a semicolon-joined `path` list long enough to trip the limit + * (bare filenames past `NAME_MAX`, or a total past `PATH_MAX`) was read as one + * literal path and the delimited split was suppressed (issue #7597). */ export async function probeLiteralPathExists(filePath: string, cwd: string): Promise<"exists" | "missing" | "unknown"> { const resolved = resolveReadPath(filePath, cwd); @@ -344,7 +351,7 @@ export async function probeLiteralPathExists(filePath: string, cwd: string): Pro await fs.promises.lstat(resolved); return "exists"; } catch (err) { - if (isEnoent(err) || isEnotdir(err)) return "missing"; + if (isEnoent(err) || isEnotdir(err) || hasFsCode(err, "ENAMETOOLONG")) return "missing"; return "unknown"; } } @@ -762,7 +769,10 @@ async function delimitedPathPartResolves(entry: string, cwd: string, splitter: P await fs.promises.stat(absoluteBasePath); return true; } catch (err) { - if (isEnoent(err)) return false; + // ENOENT and ENAMETOOLONG both mean this string cannot name an existing + // path, so the whole entry does not resolve and the delimited split may + // proceed (issue #7597). Other errors (EACCES, transient I/O) stay fatal. + if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) return false; throw err; } } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index c2d98dbff..1a9140064 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -737,6 +737,8 @@ export interface ReadToolDetails { meta?: OutputMeta; /** Full on-disk byte size recorded before applying a file range. */ fileSize?: number; + /** Full source line count when the read reached EOF and the count is exact. */ + totalLines?: number; /** Raw text + start line for user-visible TUI rendering, set when content is text-like. * Mirrors the same lines the model receives but without hashline/line-number prefixes, * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */ @@ -1397,6 +1399,7 @@ export class ReadTool implements AgentTool { const details = options.details ?? {}; const allLines = text.split("\n"); const totalLines = allLines.length; + details.totalLines = totalLines; // User-requested 0-indexed range start. Lines BEFORE this are leading // context (added below if offset is explicit). const requestedStart = offset ? Math.max(0, offset - 1) : 0; @@ -1592,6 +1595,7 @@ export class ReadTool implements AgentTool { const details = options.details ?? {}; const allLines = text.split("\n"); const totalLines = allLines.length; + details.totalLines = totalLines; const shouldAddHashLines = displayMode.hashLines; const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; const hashContext = @@ -2908,6 +2912,7 @@ export class ReadTool implements AgentTool { details = {}; sourcePath = absolutePath; } + if (reachedEof) details.totalLines = totalFileLines; if (hashContext?.tag) { recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText); @@ -3239,6 +3244,7 @@ export class ReadTool implements AgentTool { if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) { outputText += `\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`; } + if (reachedEof) details.totalLines = totalFileLines; if (displayContent) details.displayContent = displayContent; if (truncationInfo) details.truncation = truncationInfo.result; const resultBuilder = toolResult(details) diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index 742c8daf8..184cd367a 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -1,6 +1,285 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { untilAborted } from "@oh-my-pi/pi-utils/abortable"; +import * as postmortem from "@oh-my-pi/pi-utils/postmortem"; import { ToolError, throwIfAborted } from "./tool-errors"; +const browserRunRejections = new WeakMap(); + +/** Associates a browser operation failure with its owning evaluated run. */ +export function markBrowserRunRejection(reason: T, owner: object): T { + if (reason !== null && (typeof reason === "object" || typeof reason === "function")) { + browserRunRejections.set(reason, owner); + } + return reason; +} + +/** Returns whether a rejection was marked for the specified evaluated run. */ +export function isBrowserRunRejection(reason: unknown, owner: object): boolean { + return ( + reason !== null && + (typeof reason === "object" || typeof reason === "function") && + browserRunRejections.get(reason) === owner + ); +} + +/** Returns whether a rejection belongs to the marked browser run or its evaluated source file. */ +export function isBrowserRunOwnedRejection(reason: unknown, owner: object, filename: string): boolean { + if (isBrowserRunRejection(reason, owner)) return true; + return reason instanceof Error && typeof reason.stack === "string" && reason.stack.includes(filename); +} + +type FloatingRejectionHandler = (reason: unknown) => void; + +interface ObservedPromiseState { + handled: boolean; + userContinuationFailed: boolean; +} + +const observedBrowserPromises = new WeakMap, ObservedPromiseState>(); +const observedPromiseConstructor = { [Symbol.species]: Promise }; + +type PromiseCombinatorName = "all" | "race"; +type PromiseCombinator = (this: PromiseConstructor, values: Iterable) => Promise; + +interface PromiseCombinatorTrackingContext { + owner: object; + onFloatingRejection: FloatingRejectionHandler; +} + +const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race"]; +const NativePromise = Promise; +const nativePromiseCombinators: Record = { + all: Promise.all, + race: Promise.race, +}; +const promiseCombinatorTracking = new AsyncLocalStorage(); +let previousPromiseDescriptor: PropertyDescriptor | undefined; +let promiseCombinatorTrackingScopes = 0; + +/** + * Observes native promise-combinator results derived from browser promises for + * the duration of one evaluated run. Native `await` remains unchanged; dropped + * user continuations from `Promise.all` and `Promise.race` are routed to the + * owning run. + */ +export async function withBrowserPromiseCombinatorTracking( + owner: object, + onFloatingRejection: FloatingRejectionHandler, + run: () => Promise, +): Promise { + installPromiseCombinatorTracking(); + try { + return await promiseCombinatorTracking.run({ owner, onFloatingRejection }, run); + } finally { + restorePromiseCombinatorTracking(); + } +} + +function installPromiseCombinatorTracking(): void { + if (promiseCombinatorTrackingScopes > 0) { + promiseCombinatorTrackingScopes++; + return; + } + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Promise"); + if (!descriptor) throw new Error("Global Promise descriptor is unavailable"); + const trackedPromise = createTrackedPromiseConstructor(); + Object.defineProperty(globalThis, "Promise", { ...descriptor, value: trackedPromise }); + previousPromiseDescriptor = descriptor; + promiseCombinatorTrackingScopes = 1; +} + +function restorePromiseCombinatorTracking(): void { + if (promiseCombinatorTrackingScopes > 1) { + promiseCombinatorTrackingScopes--; + return; + } + const descriptor = previousPromiseDescriptor; + try { + if (!descriptor) throw new Error("Global Promise tracking scope is not installed"); + Object.defineProperty(globalThis, "Promise", descriptor); + } finally { + previousPromiseDescriptor = undefined; + promiseCombinatorTrackingScopes = 0; + } +} + +function createTrackedPromiseConstructor(): PromiseConstructor { + class TrackedPromise extends NativePromise {} + for (const name of PROMISE_COMBINATORS) { + const original = nativePromiseCombinators[name]; + Object.defineProperty(TrackedPromise, name, { + configurable: true, + writable: true, + value(this: PromiseConstructor, values: Iterable): Promise { + let hasObservedInput = false; + const result = Reflect.apply(original, this, [ + tapObservedBrowserPromises(values, () => { + hasObservedInput = true; + }), + ]) as Promise; + const context = promiseCombinatorTracking.getStore(); + return hasObservedInput && context + ? observeBrowserRunPromise(result, context.owner, context.onFloatingRejection) + : result; + }, + }); + } + return TrackedPromise; +} + +function* tapObservedBrowserPromises( + values: Iterable, + onObserved: () => void, +): Generator { + for (const value of values) { + if (observedBrowserPromises.has(value as Promise)) onObserved(); + yield value; + } +} + +/** + * Observes every explicit continuation of a browser promise without replacing + * the native promise. Browser failures remain contained; an unhandled error + * created by user continuation code is reported to the owning run. + */ +export function observeBrowserRunPromise( + promise: Promise, + owner: object, + onFloatingRejection: FloatingRejectionHandler, +): Promise { + return observeBrowserRunPromiseWithState(promise, owner, onFloatingRejection, { + handled: false, + userContinuationFailed: false, + }); +} + +function observeBrowserRunPromiseWithState( + promise: Promise, + owner: object, + onFloatingRejection: FloatingRejectionHandler, + state: ObservedPromiseState, +): Promise { + if (observedBrowserPromises.has(promise)) return promise; + observedBrowserPromises.set(promise, state); + const originalThen = promise.then.bind(promise); + const originalFinally = promise.finally.bind(promise); + void originalThen(undefined, reason => { + setTimeout(() => { + if (!state.handled && (state.userContinuationFailed || !isBrowserRunRejection(reason, owner))) { + onFloatingRejection(reason); + } + }, 0); + }); + Object.defineProperties(promise, { + constructor: { configurable: true, value: observedPromiseConstructor }, + // biome-ignore lint/suspicious/noThenProperty: native Promise continuations must remain thenable. + then: { + configurable: true, + value: ( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise => { + state.handled = true; + const childState = createContinuationState(); + return observeBrowserRunPromiseWithState( + originalThen( + recordContinuationFailure(onFulfilled, childState), + recordContinuationFailure(onRejected, childState), + ), + owner, + onFloatingRejection, + childState, + ); + }, + }, + catch: { + configurable: true, + value: ( + onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): Promise => { + state.handled = true; + const childState = createContinuationState(); + return observeBrowserRunPromiseWithState( + originalThen(undefined, recordContinuationFailure(onRejected, childState)), + owner, + onFloatingRejection, + childState, + ); + }, + }, + finally: { + configurable: true, + value: (onFinally?: (() => void) | null): Promise => { + state.handled = true; + const childState = createContinuationState(); + return observeBrowserRunPromiseWithState( + originalFinally(recordContinuationFailure(onFinally, childState)), + owner, + onFloatingRejection, + childState, + ); + }, + }, + }); + return promise; +} + +function createContinuationState(): ObservedPromiseState { + return { handled: false, userContinuationFailed: false }; +} + +function recordContinuationFailure( + continuation: ((...args: TArgs) => TResult | PromiseLike) | null | undefined, + state: ObservedPromiseState, +): ((...args: TArgs) => TResult | PromiseLike) | null | undefined { + if (!continuation) return continuation; + return (...args) => { + try { + const result = continuation(...args); + if (!isThenable(result)) return result; + return Promise.resolve(result).catch(reason => { + state.userContinuationFailed = true; + throw reason; + }) as PromiseLike; + } catch (reason) { + state.userContinuationFailed = true; + throw reason; + } + }; +} + +function isThenable(value: unknown): value is PromiseLike { + if (value === null || (typeof value !== "object" && typeof value !== "function")) return false; + return typeof Reflect.get(value, "then") === "function"; +} + +function trackBrowserRunPromise( + promise: Promise, + owner?: object, + onFloatingRejection?: FloatingRejectionHandler, +): Promise { + if (!owner) return markHandled(promise); + const tracked = promise.catch(error => { + throw markBrowserRunRejection(error, owner); + }); + return onFloatingRejection ? observeBrowserRunPromise(tracked, owner, onFloatingRejection) : tracked; +} + +/** + * Installs worker-realm rejection routing. Consumed browser-run failures stay in + * the worker; unrelated failures retain the default fatal worker behavior. + */ +export function installBrowserWorkerRejectionGuard(consume: (reason: unknown) => boolean): () => void { + const onRejection = (reason: unknown): void => { + if (postmortem.isExpectedCleanupError(reason) || consume(reason)) return; + setTimeout(() => { + throw reason; + }, 0); + }; + process.on("unhandledRejection", onRejection); + return () => process.off("unhandledRejection", onRejection); +} + /** * Marks a run-scoped promise as observed without changing its behavior for awaited callers. * @@ -83,11 +362,16 @@ export function waitForRun( await untilAborted(signal, async () => await Bun.sleep(interval)); } })(); - return markHandled(promise); + return trackBrowserRunPromise(promise); } /** Binds a long-lived scope facade (page/tab/desktop objects) to one evaluated run's abort signal. */ -export function bindRunFacade(target: T, signal: AbortSignal): T { +export function bindRunFacade( + target: T, + signal: AbortSignal, + rejectionOwner?: object, + onFloatingRejection?: FloatingRejectionHandler, +): T { const cache = new Map(); return new Proxy(target, { get(current, prop) { @@ -102,11 +386,13 @@ export function bindRunFacade(target: T, signal: AbortSignal): if (result && typeof result === "object") { const then = Reflect.get(result, "then"); if (typeof then === "function") { - return markHandled( + return trackBrowserRunPromise( Promise.resolve(result).then(resolved => { throwIfAborted(signal); return resolved; }), + rejectionOwner, + onFloatingRejection, ); } } @@ -121,7 +407,7 @@ export function bindRunFacade(target: T, signal: AbortSignal): // brand-check internal slots that a Proxy cannot forward, and reading a // signal needs no abort gating anyway. if (value instanceof AbortSignal) return value; - const wrapped = bindRunFacade(value, signal); + const wrapped = bindRunFacade(value, signal, rejectionOwner, onFloatingRejection); cache.set(prop, wrapped); return wrapped; } diff --git a/packages/coding-agent/src/tools/shell-tokenize.ts b/packages/coding-agent/src/tools/shell-tokenize.ts index faa50bb72..f93f3e276 100644 --- a/packages/coding-agent/src/tools/shell-tokenize.ts +++ b/packages/coding-agent/src/tools/shell-tokenize.ts @@ -83,25 +83,47 @@ export function tokenizeShellSegments(command: string): string[][] { } /** - * Returns the original text of flat shell command segments. Unlike + * A flat shell command segment with the context needed to decide interception. + * + * @see extractFlatShellCommandSegments + */ +export interface FlatShellCommandSegment { + /** Original segment text with quoting and escaping preserved. */ + text: string; + /** + * True when this segment consumes the previous stage's stdout via an + * unquoted `|` or `|&`. Blank and comment-only continuation lines preserve + * the pending pipe state. Such a stage reads piped stdin, so path-based + * dedicated tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, and + * `&&` start an independent command and leave this false. + */ + pipedStdin: boolean; +} + +/** + * Returns the flat shell command segments with the original text of each. Unlike * `tokenizeShellSegments`, this preserves quoting and escaping so the results - * are safe to match against user-configured regular expressions. + * are safe to match against user-configured regular expressions, and flags + * segments that receive piped stdin. * * The extractor deliberately declines to split syntax whose execution context * cannot be determined with this small scanner (heredocs, command substitution, * backticks, grouping, and malformed quoting). Callers must still check the * complete input in that case. */ -export function extractFlatShellCommandSegments(command: string): string[] { - const segments: string[] = []; +export function extractFlatShellCommandSegments(command: string): FlatShellCommandSegment[] { + const segments: FlatShellCommandSegment[] = []; let segmentStart = 0; let inSingle = false; let inDouble = false; let atWordStart = true; + let currentPiped = false; - const pushSegment = (end: number) => { + const pushSegment = (end: number): boolean => { const segment = command.slice(segmentStart, end).trim(); - if (segment.length > 0) segments.push(segment); + if (segment.length === 0) return false; + segments.push({ text: segment, pipedStdin: currentPiped }); + return true; }; for (let i = 0; i < command.length; i++) { @@ -154,12 +176,14 @@ export function extractFlatShellCommandSegments(command: string): string[] { return []; } if (ch === "#" && atWordStart) { - pushSegment(i); + const pushed = pushSegment(i); const newline = command.indexOf("\n", i + 1); if (newline === -1) return segments; i = newline; segmentStart = newline + 1; atWordStart = true; + // Preserve a pending pipe through a comment-only continuation. + if (pushed) currentPiped = false; continue; } const isRedirectionOperatorCharacter = @@ -169,8 +193,13 @@ export function extractFlatShellCommandSegments(command: string): string[] { ? command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">" : false; if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) { - pushSegment(i); - if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++; + const pushed = pushSegment(i); + const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; + const pipeStderr = ch === "|" && command[i + 1] === "&"; + if (doubled || pipeStderr) i++; + // `|` and `|&` pipe into the next segment. Blank continuation + // lines preserve that pending state; all other operators reset it. + if (pushed || ch !== "\n") currentPiped = ch === "|" && !doubled; segmentStart = i + 1; atWordStart = true; continue; diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index 456fc23f2..b64431dae 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -198,9 +198,10 @@ function parseChangelogContent(content: string): ChangelogEntry[] { } /** - * Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 + * Compare changelog entries by their parsed version parts. + * Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 */ -export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number { +export function compareChangelogEntries(v1: ChangelogEntry, v2: ChangelogEntry): number { if (v1.major !== v2.major) return v1.major - v2.major; if (v1.minor !== v2.minor) return v1.minor - v2.minor; return v1.patch - v2.patch; @@ -232,7 +233,7 @@ export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): C return []; } - return entries.filter(entry => compareVersions(entry, parsedLastVersion) > 0); + return entries.filter(entry => compareChangelogEntries(entry, parsedLastVersion) > 0); } /** @@ -328,7 +329,7 @@ export async function resolveStartupChangelogForDisplay(options: { } if (options.mode === "hidden") { const currentVersion = parseChangelogVersion(options.currentVersion); - if (currentVersion && compareVersions(currentVersion, parsedLastVersion) > 0) { + if (currentVersion && compareChangelogEntries(currentVersion, parsedLastVersion) > 0) { await writeLastChangelogVersion(options.currentVersion, options.agentDir); } return undefined; diff --git a/packages/coding-agent/src/utils/late-cleanup.ts b/packages/coding-agent/src/utils/late-cleanup.ts new file mode 100644 index 000000000..7d9ae2d8d --- /dev/null +++ b/packages/coding-agent/src/utils/late-cleanup.ts @@ -0,0 +1,17 @@ +import { logger } from "@oh-my-pi/pi-utils"; + +const pendingCleanups = new Set>(); + +/** Keep timed-out cleanup reachable until its resources really settle. */ +export function trackLateCleanup(work: Promise, context: Record): void { + let tracked: Promise; + tracked = work + .catch(error => { + logger.warn("Deferred cleanup failed", { + ...context, + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => pendingCleanups.delete(tracked)); + pendingCleanups.add(tracked); +} diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index e77c9ef8a..1da18d510 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -25,7 +25,6 @@ import { MCPManager } from "../mcp/manager"; import vibeTurnResultTemplate from "../prompts/tools/vibe-turn-result.md" with { type: "text" }; import { AgentLifecycleManager } from "../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; -import type { SessionEntry } from "../session/session-entries"; import { SessionManager, SessionPersistenceIndeterminateError } from "../session/session-manager"; import { getBundledAgent } from "../task/agents"; import { type ExecutorOptions, runSubagentFollowUpTurn, runSubprocess } from "../task/executor"; @@ -70,8 +69,8 @@ const TRACE_LINE_MAX = 120; const DEFAULT_WAIT_TIMEOUT_MS = 30_000; /** Response text cap inside a delivered turn result; full output stays at agent://. */ const RESPONSE_PREVIEW_MAX = 6000; -/** Grace period for abort-aware turns before teardown detaches a stuck provider/tool call. */ -const CANCELLED_TURN_SETTLE_GRACE_MS = 250; +/** Grace period for Vibe cancellation/release cleanup before teardown detaches (ms). */ +const VIBE_TEARDOWN_GRACE_MS = 5_000; const VIBE_LIFECYCLE_CUSTOM_TYPE = "vibe-session-lifecycle"; const VIBE_LIFECYCLE_VERSION = 1; @@ -252,6 +251,48 @@ export interface VibeWaitOutcome { timedOut: boolean; } +type VibeTeardownStatus = "pending" | "settled" | "failed"; + +interface TrackedVibeTeardown { + promise: Promise; + status: () => VibeTeardownStatus; +} + +/** Observe cleanup without propagating a detached late rejection. */ +function trackVibeTeardown(promise: Promise, onError: (error: unknown) => void): TrackedVibeTeardown { + let status: VibeTeardownStatus = "pending"; + return { + promise: promise.then( + () => { + status = "settled"; + }, + error => { + status = "failed"; + onError(error); + }, + ), + status: () => status, + }; +} + +/** Wait for cleanup only until the caller's shared absolute deadline. */ +async function waitForVibeTeardown(tasks: readonly TrackedVibeTeardown[], deadline: number): Promise { + if (tasks.length === 0 || tasks.every(task => task.status() !== "pending")) return true; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return false; + const timeout = Promise.withResolvers(); + const timer = setTimeout(timeout.resolve, remainingMs); + timer.unref?.(); + try { + return await Promise.race([ + Promise.allSettled(tasks.map(task => task.promise)).then(() => true), + timeout.promise.then(() => false), + ]); + } finally { + clearTimeout(timer); + } +} + /** Normalize a text fragment to one bounded roster/trace line. */ function firstLine(text: string, max = 100): string { return oneLineLabel(text, max); @@ -321,11 +362,12 @@ function parseLifecycleEvent(value: unknown): VibeLifecycleEvent | undefined { return undefined; } -/** Child ids claimed by any valid Vibe spawn event, independent of current parent scope. */ -export function persistedVibeChildIds(entries: Iterable): Set { +/** Child ids claimed by valid Vibe spawn records from untrusted persisted JSON. */ +export function persistedVibeChildIds(entries: Iterable): Set { const ids = new Set(); - for (const entry of entries) { - if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue; + for (const value of entries) { + const entry = objectRecord(value); + if (entry?.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue; const event = parseLifecycleEvent(entry.data); if ( event?.action === "spawn" && @@ -354,24 +396,6 @@ function mergeTrace(turn: VibeTurn, progress: AgentProgress): void { /** Thrown from a turn job body so the job manager marks the job failed while carrying the formatted result. */ export class VibeTurnError extends Error {} -async function awaitCancelledTurnJobs(jobs: ReadonlySet): Promise { - if (jobs.size === 0) return; - const settled = Promise.allSettled([...jobs].map(job => job.promise)).then(() => true); - const timeout = Promise.withResolvers(); - const timer = setTimeout(() => timeout.resolve(false), CANCELLED_TURN_SETTLE_GRACE_MS); - timer.unref(); - try { - if (!(await Promise.race([settled, timeout.promise]))) { - logger.warn("vibe: detached cancelled turn that did not settle within teardown grace period", { - jobCount: jobs.size, - graceMs: CANCELLED_TURN_SETTLE_GRACE_MS, - }); - } - } finally { - clearTimeout(timer); - } -} - /** * Process-global registry of vibe worker sessions, scoped by both owner agent * id and stable parent session id. Persisted lifecycle events rebuild idle @@ -419,6 +443,12 @@ export class VibeSessionRegistry { readonly #records = new Map(); readonly #terminationTails = new Map>(); readonly #terminatedScopes = new Set(); + #teardownGraceMs = VIBE_TEARDOWN_GRACE_MS; + + /** Override the teardown grace period for deterministic lifecycle tests. */ + setTeardownGraceForTesting(timeoutMs: number): void { + this.#teardownGraceMs = Math.max(1, timeoutMs); + } ownerScope(session: VibeParentSession): VibeOwnerScope { const parentSessionId = session.getSessionId?.(); @@ -709,11 +739,50 @@ export class VibeSessionRegistry { } } + #trackAgentRelease(id: string, ref: AgentRef, action: "detach" | "release"): TrackedVibeTeardown { + return trackVibeTeardown(AgentLifecycleManager.global().release(id, ref), error => { + logger.warn(`vibe: failed to ${action} worker session`, { + id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + #finishAgentRelease(id: string, ref: AgentRef, task: TrackedVibeTeardown, action: "detach" | "release"): void { + if (task.status() === "settled") return; + if (task.status() === "pending") { + logger.warn(`vibe: timed out waiting to ${action} worker session; detaching registry ref`, { id }); + } + AgentRegistry.global().unregister(id, ref); + } + + async #releaseRefWithinDeadline( + id: string, + ref: AgentRef, + deadline: number, + action: "detach" | "release", + ): Promise { + const task = this.#trackAgentRelease(id, ref, action); + await waitForVibeTeardown([task], deadline); + this.#finishAgentRelease(id, ref, task, action); + } + + #trackJobSettlement(record: VibeRecord, job: AsyncJob): TrackedVibeTeardown { + return trackVibeTeardown(job.promise, error => { + logger.warn("vibe: cancelled worker turn cleanup failed", { + id: record.id, + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + async #markTerminalRef( id: string, ownerId: string, childSessionFile: string, expected?: AgentRef | null, + teardownDeadline?: number, ): Promise { const registry = AgentRegistry.global(); const existing = registry.get(id); @@ -726,7 +795,9 @@ export class VibeSessionRegistry { } if (existing?.status === "aborted" && !existing.session) return; if (existing && !registry.setStatus(id, "aborted", existing)) return; - if (existing && AgentLifecycleManager.global().has(id, existing)) { + if (existing && teardownDeadline !== undefined) { + await this.#releaseRefWithinDeadline(id, existing, teardownDeadline, "release"); + } else if (existing && AgentLifecycleManager.global().has(id, existing)) { await AgentLifecycleManager.global().release(id, existing); } else if (existing?.session) { await existing.session.dispose(); @@ -1086,19 +1157,48 @@ export class VibeSessionRegistry { this.#records.delete(scopeKey(scope, record.id)); if (record.turn && manager) manager.cancel(record.turn.jobId, { ownerId: record.ownerId }); } - for (const { record, ref } of teardown) { - if (!ref) continue; - try { - await AgentLifecycleManager.global().release(record.id, ref); - } catch (error) { - logger.warn("vibe: failed to detach worker session", { + const deadline = Date.now() + this.#teardownGraceMs; + const cleanup = teardown.map(entry => ({ + ...entry, + releaseTask: entry.ref ? this.#trackAgentRelease(entry.record.id, entry.ref, "detach") : undefined, + jobTask: entry.job ? this.#trackJobSettlement(entry.record, entry.job) : undefined, + })); + await waitForVibeTeardown( + cleanup.flatMap(entry => [entry.releaseTask, entry.jobTask].filter(task => task !== undefined)), + deadline, + ); + for (const { record, ref, releaseTask, job, jobTask } of cleanup) { + if (ref && releaseTask) this.#finishAgentRelease(record.id, ref, releaseTask, "detach"); + if (job && jobTask?.status() === "pending") { + logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", { + id: record.id, + jobId: job.id, + }); + this.#continueSuspendedCleanup(scope, record, jobTask); + } + if (this.#records.has(scopeKey(scope, record.id))) continue; + const lateRef = this.#registeredAgent(record); + if (lateRef && lateRef !== ref) { + await this.#releaseRefWithinDeadline(record.id, lateRef, deadline, "detach"); + } + } + return records.length; + } + + #continueSuspendedCleanup(scope: VibeOwnerScope, record: VibeRecord, jobTask: TrackedVibeTeardown): void { + void jobTask.promise + .then(async () => { + if (this.#records.has(scopeKey(scope, record.id))) return; + const lateRef = this.#registeredAgent(record); + if (!lateRef) return; + await this.#releaseRefWithinDeadline(record.id, lateRef, Date.now() + this.#teardownGraceMs, "detach"); + }) + .catch(error => { + logger.warn("vibe: failed to finish suspended worker cleanup", { id: record.id, error: error instanceof Error ? error.message : String(error), }); - } - } - await awaitCancelledTurnJobs(new Set(teardown.flatMap(entry => (entry.job ? [entry.job] : [])))); - return records.length; + }); } /** Terminate one worker; a tombstone failure still tears it down before reconciliation and error delivery. */ @@ -1144,15 +1244,21 @@ export class VibeSessionRegistry { await this.#persistModeExit(session, scope, records); } catch (error) { if (error instanceof SessionPersistenceIndeterminateError) { - for (const record of records) { - await this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false); - } + const teardownDeadline = Date.now() + this.#teardownGraceMs; + await Promise.all( + records.map(record => + this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false, teardownDeadline), + ), + ); } throw error; } - for (const record of records) { - await this.#killRecord(record, session.asyncJobManager, session, "mode-exit"); - } + const teardownDeadline = Date.now() + this.#teardownGraceMs; + await Promise.all( + records.map(record => + this.#killRecord(record, session.asyncJobManager, session, "mode-exit", true, teardownDeadline), + ), + ); return records.length; } @@ -1162,6 +1268,7 @@ export class VibeSessionRegistry { session: VibeParentSession, reason: VibeTombstoneReason, persistTerminal = true, + teardownDeadline?: number, ): Promise { const registered = this.#registeredAgent(record); const settlingJobs = new Set(); @@ -1198,26 +1305,29 @@ export class VibeSessionRegistry { record.state = "dead"; record.lastActivityAt = Date.now(); record.lastActivity = "killed"; - if (registered) { - try { - await AgentLifecycleManager.global().release(record.id, registered); - } catch (error) { - logger.warn("vibe: failed to release worker session", { - id: record.id, - error: error instanceof Error ? error.message : String(error), - }); - } + const deadline = teardownDeadline ?? Date.now() + this.#teardownGraceMs; + const releaseTask = registered ? this.#trackAgentRelease(record.id, registered, "release") : undefined; + const jobCleanup = [...settlingJobs].map(job => ({ job, task: this.#trackJobSettlement(record, job) })); + await waitForVibeTeardown( + [releaseTask, ...jobCleanup.map(entry => entry.task)].filter(task => task !== undefined), + deadline, + ); + if (registered && releaseTask) this.#finishAgentRelease(record.id, registered, releaseTask, "release"); + const pendingJobs = jobCleanup.filter(entry => entry.task.status() === "pending"); + for (const { job } of pendingJobs) { + logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", { + id: record.id, + jobId: job.id, + }); } - await awaitCancelledTurnJobs(settlingJobs); const terminalRef = registered ?? this.#registeredAgent(record) ?? null; - if (record.childSessionFile) { - try { - const persisted = await SessionManager.peekSessionInit(record.childSessionFile); - if (persisted?.init) - await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, terminalRef); - } catch { - // A turn killed before session initialization has no readable history to retain. - } + await this.#markTerminalRecord(record, terminalRef, deadline); + if (pendingJobs.length > 0) { + this.#continueKilledCleanup( + record, + pendingJobs.map(entry => entry.task), + registered, + ); } if (persistenceError) { let finalPersistenceError = persistenceError; @@ -1246,6 +1356,40 @@ export class VibeSessionRegistry { return { id: record.id, cancelledTurn }; } + async #markTerminalRecord( + record: VibeRecord, + expected: AgentRef | null | undefined, + teardownDeadline: number, + ): Promise { + if (!record.childSessionFile) return; + try { + const persisted = await SessionManager.peekSessionInit(record.childSessionFile); + if (persisted?.init) { + await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, expected, teardownDeadline); + } + } catch (error) { + logger.warn("vibe: failed to retain terminal worker transcript", { + id: record.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + #continueKilledCleanup( + record: VibeRecord, + jobTasks: readonly TrackedVibeTeardown[], + expected: AgentRef | undefined, + ): void { + void Promise.allSettled(jobTasks.map(task => task.promise)) + .then(() => this.#markTerminalRecord(record, expected, Date.now() + this.#teardownGraceMs)) + .catch(error => { + logger.warn("vibe: failed to finish killed worker cleanup", { + id: record.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + /** Build the ExecutorOptions for a first spawn, mirroring the `task`/eval-bridge plumbing. */ async #buildSpawnOptions( session: ToolSession, diff --git a/packages/coding-agent/src/web/scrapers/hackage.ts b/packages/coding-agent/src/web/scrapers/hackage.ts index 997e90a69..339c2178a 100644 --- a/packages/coding-agent/src/web/scrapers/hackage.ts +++ b/packages/coding-agent/src/web/scrapers/hackage.ts @@ -1,4 +1,4 @@ -import { tryParseJson } from "@oh-my-pi/pi-utils"; +import { compareVersions, tryParseJson } from "@oh-my-pi/pi-utils"; import type { RenderResult, SpecialHandler } from "./types"; import { buildResult, loadPage } from "./types"; @@ -20,17 +20,6 @@ interface ParsedCabal { stability?: string; } -function compareVersions(a: string, b: string): number { - const aParts = a.split(".").map(part => Number.parseInt(part, 10) || 0); - const bParts = b.split(".").map(part => Number.parseInt(part, 10) || 0); - const max = Math.max(aParts.length, bParts.length); - for (let i = 0; i < max; i++) { - const delta = (aParts[i] || 0) - (bParts[i] || 0); - if (delta !== 0) return delta; - } - return 0; -} - function extractCabalField(content: string, fieldName: string): string | undefined { const pattern = new RegExp(`^${fieldName}:\\s*(.*)$`, "im"); const match = content.match(pattern); diff --git a/packages/coding-agent/src/web/search/providers/duckduckgo.ts b/packages/coding-agent/src/web/search/providers/duckduckgo.ts index 8116dbc75..54cb8d2ad 100644 --- a/packages/coding-agent/src/web/search/providers/duckduckgo.ts +++ b/packages/coding-agent/src/web/search/providers/duckduckgo.ts @@ -372,7 +372,7 @@ export class DuckDuckGoProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/ecosia.ts b/packages/coding-agent/src/web/search/providers/ecosia.ts index 562effa71..374c575ff 100644 --- a/packages/coding-agent/src/web/search/providers/ecosia.ts +++ b/packages/coding-agent/src/web/search/providers/ecosia.ts @@ -173,7 +173,7 @@ export class EcosiaProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index e745f8ab9..9c81d5317 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -458,7 +458,7 @@ export class ExaProvider extends SearchProvider { * still uses {@link isAvailable} so an unrelated configured provider * keeps priority over the public fallback. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return this.#settingsAllowSearch(); } diff --git a/packages/coding-agent/src/web/search/providers/firecrawl.ts b/packages/coding-agent/src/web/search/providers/firecrawl.ts index 3f701b02c..e3bb15071 100644 --- a/packages/coding-agent/src/web/search/providers/firecrawl.ts +++ b/packages/coding-agent/src/web/search/providers/firecrawl.ts @@ -203,7 +203,7 @@ export class FirecrawlProvider extends SearchProvider { * Firecrawl supports keyless mode, so an explicit user selection * (`webSearch: firecrawl`) works without any credential configured. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/mojeek.ts b/packages/coding-agent/src/web/search/providers/mojeek.ts index 3fe6ee71d..1272c3f75 100644 --- a/packages/coding-agent/src/web/search/providers/mojeek.ts +++ b/packages/coding-agent/src/web/search/providers/mojeek.ts @@ -210,7 +210,7 @@ export class MojeekProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index b0742b97d..1839a52d4 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -973,7 +973,7 @@ export class PerplexityProvider extends SearchProvider { * configured provider keeps priority over the anonymous/OpenRouter * fallbacks. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/public.ts b/packages/coding-agent/src/web/search/providers/public.ts index c68c1cf28..5e21ae36b 100644 --- a/packages/coding-agent/src/web/search/providers/public.ts +++ b/packages/coding-agent/src/web/search/providers/public.ts @@ -189,7 +189,7 @@ export class PublicWebProvider extends SearchProvider { return false; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/startpage.ts b/packages/coding-agent/src/web/search/providers/startpage.ts index 58f192b58..0fd8c60b4 100644 --- a/packages/coding-agent/src/web/search/providers/startpage.ts +++ b/packages/coding-agent/src/web/search/providers/startpage.ts @@ -215,7 +215,7 @@ export class StartpageProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 0175d9c19..bdde6e85b 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -15,7 +15,9 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { visitEntriesFromFileStream } from "@oh-my-pi/pi-coding-agent/session/session-loader"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { getBundledAgent } from "@oh-my-pi/pi-coding-agent/task/agents"; import { TempDir } from "@oh-my-pi/pi-utils"; const AGENT_ID = "Worker"; @@ -36,6 +38,7 @@ function makeHub(focusAgent: (id: string) => Promise) { const done = Promise.withResolvers(); const renderRequested = Promise.withResolvers(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => { @@ -50,6 +53,30 @@ function makeHub(focusAgent: (id: string) => Promise) { return { hub, doneCalls: () => doneCalls, done: done.promise, renderRequested: renderRequested.promise }; } +const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?:(?:│ {3}| {4})*)(?:├── |└── ))?(\S+)/u; + +function renderedRosterEntry(hub: AgentHubOverlayComponent, id: string, width: number): string { + const cells = hub.render(width).map(raw => { + const line = Bun.stripANSI(raw); + if (!line.startsWith("│ ")) return undefined; + const divider = line.indexOf("│", Math.max(2, Math.floor(line.length / 3))); + return divider < 0 ? undefined : line.slice(2, Math.max(2, divider - 1)); + }); + const start = cells.findIndex(cell => { + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; + return match?.[3] === id; + }); + expect(start).toBeGreaterThanOrEqual(0); + const entry: string[] = []; + for (let i = start; i < cells.length; i++) { + const cell = cells[i]; + if (cell === undefined || cell.trim().length === 0) break; + if (i > start && ROSTER_ENTRY_PATTERN.test(cell)) break; + entry.push(cell.trimEnd()); + } + return entry.join("\n"); +} + describe("Agent hub Enter activation", () => { beforeAll(() => { initTheme(); @@ -99,6 +126,7 @@ describe("Agent hub Enter activation", () => { await Bun.write(workerSessionFile, ""); const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -110,13 +138,226 @@ describe("Agent hub Enter activation", () => { }); await hub.persistedSubagentsReady; - const rendered = Bun.stripANSI(hub.render(120).join("\n")); - expect(rendered).toContain("Worker"); - expect(rendered).toContain("parked"); + const workerEntry = renderedRosterEntry(hub, "Worker", 120); + expect(workerEntry).toContain("○ Worker"); expect(agents.get("Worker")?.sessionFile).toBe(workerSessionFile); hub.dispose(); }); + it("stops persisted discovery when the Hub is disposed", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-disposed-scan-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + await Bun.write(sessionFile, ""); + await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), ""); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + + hub.dispose(); + await hub.persistedSubagentsReady; + + expect(agents.get("Worker")).toBeUndefined(); + }); + it("restores nested parent lineage after restart", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-persisted-tree-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + 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, ""); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + await hub.persistedSubagentsReady; + + expect(agents.get("Parent")?.parentId).toBe("Main"); + expect(agents.get("Child")?.parentId).toBe("Parent"); + hub.handleInput("t"); + expect(Bun.stripANSI(renderedRosterEntry(hub, "Child", 120))).toContain("└── Child"); + hub.dispose(); + }); + + it("restores saved task metadata and timestamps for completed agents", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-persisted-metadata-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); + const createdAt = "2026-07-30T01:13:37.835Z"; + const lastActivity = new Date("2026-07-30T01:15:00.000Z"); + await Bun.write(sessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id: "worker-session", timestamp: createdAt, cwd: TEST_CWD }), + JSON.stringify({ + type: "session_init", + id: "init", + parentId: null, + timestamp: createdAt, + systemPrompt: "system", + task: "Complete the assignment below, thoroughly:\n\n# Target\nInspect dependency boundaries and report unsafe coupling.\n\n# Change\nRead the implementation.", + tools: ["read"], + }), + ].join("\n"), + ); + await fs.utimes(workerSessionFile, lastActivity, lastActivity); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + await hub.persistedSubagentsReady; + + expect(agents.get("Worker")).toMatchObject({ + activity: "Inspect dependency boundaries and report unsafe coupling.", + createdAt: Date.parse(createdAt), + lastActivity: lastActivity.getTime(), + status: "parked", + }); + const workerEntry = renderedRosterEntry(hub, "Worker", 120); + expect(workerEntry).toContain("Inspect dependency boundaries and report unsafe coupling."); + expect(workerEntry.replace(/\s+/g, " ")).toContain("usage —"); + expect(workerEntry).not.toContain("$0.000"); + hub.dispose(); + }); + + it("restores persisted model role, usage, spend, and tool totals", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-persisted-usage-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); + const createdAt = "2026-07-30T01:13:30.000Z"; + const lastActivity = new Date("2026-07-30T01:15:00.000Z"); + await Bun.write(sessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id: "worker-session", timestamp: createdAt, cwd: TEST_CWD }), + JSON.stringify({ + type: "model_change", + id: "model", + parentId: null, + timestamp: createdAt, + model: "openai-codex/gpt-5.6-luna", + // Historical concrete overrides did not persist a model-role field. + }), + JSON.stringify({ + type: "session_init", + id: "init", + parentId: "model", + timestamp: createdAt, + systemPrompt: `base prompt\n\nROLE\n====\n${getBundledAgent("scout")?.systemPrompt}`, + task: "Inspect persisted telemetry.", + tools: ["read", "grep"], + }), + JSON.stringify({ + type: "message", + id: "assistant", + parentId: "init", + timestamp: lastActivity.toISOString(), + message: { + role: "assistant", + timestamp: lastActivity.getTime(), + content: [ + { type: "toolCall", id: "read-call", name: "read", arguments: { path: "src/a.ts" } }, + { type: "toolCall", id: "grep-call", name: "grep", arguments: { pattern: "needle" } }, + ], + usage: { + input: 100, + output: 25, + cacheRead: 200, + cacheWrite: 10, + totalTokens: 335, + cost: { input: 0.01, output: 0.1, cacheRead: 0.01, cacheWrite: 0.003, total: 0.123 }, + }, + }, + }), + ].join("\n"), + ); + await fs.utimes(workerSessionFile, lastActivity, lastActivity); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + await hub.persistedSubagentsReady; + + const workerEntry = renderedRosterEntry(hub, "Worker", 120).replace(/\s+/g, " "); + expect(workerEntry).toContain("SMOL"); + expect(workerEntry).toContain("$0.123"); + expect(workerEntry).toContain("1m30s"); + expect(workerEntry).toContain("1 req"); + expect(workerEntry).toContain("2 tools"); + expect(workerEntry).toContain("135 tok"); + expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("Read-only · 0 LoC"); + hub.dispose(); + }); + it("yields to a macrotask while streaming a large session", async () => { + vi.useFakeTimers(); + using tempDir = TempDir.createSync("@omp-agent-hub-responsive-"); + const sessionFile = path.join(tempDir.path(), "session.jsonl"); + const entry = JSON.stringify({ + type: "message", + id: "entry", + parentId: null, + timestamp: "2026-07-30T01:13:30.000Z", + message: { role: "user", content: [{ type: "text", text: "small" }] }, + }); + await Bun.write(sessionFile, `${entry}\n`.repeat(8_193)); + let complete = false; + let yieldedBeforeComplete = false; + let visited = 0; + const visit = visitEntriesFromFileStream( + sessionFile, + () => { + visited++; + if (visited !== 8_192) return; + setTimeout(() => { + if (!complete) yieldedBeforeComplete = true; + }, 0); + }, + { yieldEveryBytes: 0, yieldEveryEntries: 8_192 }, + ).finally(() => { + complete = true; + }); + try { + for (let i = 0; i < 20_000 && visited < 8_192 && !complete; i++) await Promise.resolve(); + expect(visited).toBeGreaterThanOrEqual(8_192); + vi.runOnlyPendingTimers(); + await visit; + expect(yieldedBeforeComplete).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it("does not generically revive active or tombstoned Vibe children copied by a post-exit fork", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-vibe-fork-"); const manager = SessionManager.create(tempDir.path(), tempDir.path()); @@ -160,6 +401,7 @@ describe("Agent hub Enter activation", () => { const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -191,21 +433,22 @@ describe("Agent hub Enter activation", () => { const editor = {}; let capturedHub: AgentHubOverlayComponent | undefined; - let editorRestoredCount = 0; const focusedIds: string[] = []; const focusResolved = Promise.withResolvers(); const editorFocused = Promise.withResolvers(); const focusTargets: unknown[] = []; const editorContainer = { + children: [editor], clear: () => {}, - addChild: (child: unknown) => { - if (child === editor) editorRestoredCount++; - else capturedHub = child as AgentHubOverlayComponent; - }, + addChild: () => {}, }; const ctx = { keybindings: { getKeys: () => [] }, ui: { + showOverlay: (component: AgentHubOverlayComponent) => { + capturedHub = component; + return { hide: () => {} }; + }, setFocus: (target: unknown) => { focusTargets.push(target); if (target === editor) editorFocused.resolve(); @@ -235,7 +478,6 @@ describe("Agent hub Enter activation", () => { await editorFocused.promise; expect(focusedIds).toEqual([AGENT_ID]); - expect(editorRestoredCount).toBe(1); expect(focusTargets.at(-1)).toBe(editor); capturedHub!.dispose(); }); @@ -252,12 +494,19 @@ describe("Agent hub double-← gating", () => { function setup(agents: AgentRegistry, sessionFile: string | null = null) { let shown: AgentHubOverlayComponent | undefined; + let overlayOptions: Record | undefined; const shownReady = Promise.withResolvers(); const editor = {}; const focusTargets: unknown[] = []; const ctx = { keybindings: { getKeys: () => [] }, ui: { + showOverlay: (component: AgentHubOverlayComponent, options: Record) => { + shown = component; + overlayOptions = options; + shownReady.resolve(component); + return { hide: () => {} }; + }, setFocus: (target: unknown) => { focusTargets.push(target); }, @@ -265,13 +514,9 @@ describe("Agent hub double-← gating", () => { }, editor, editorContainer: { + children: [editor], clear: () => {}, - addChild: (child: unknown) => { - if (child !== editor) { - shown = child as AgentHubOverlayComponent; - shownReady.resolve(shown); - } - }, + addChild: () => {}, }, collabGuest: { agentRegistry: agents, hubRemote: undefined }, focusAgentSession: async () => {}, @@ -285,6 +530,7 @@ describe("Agent hub double-← gating", () => { editor, shown: () => shown, shownReady: shownReady.promise, + overlayOptions: () => overlayOptions, focusTargets, }; } @@ -347,14 +593,24 @@ describe("Agent hub double-← gating", () => { shownHub!.dispose(); }); - it("the explicit hub key opens the empty roster even with no subagents", () => { + it("the explicit hub opens fullscreen before persisted subagents load", async () => { + 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"), ""); const agents = new AgentRegistry(); - const { controller, shown } = setup(agents); + const { controller, shown, overlayOptions } = setup(agents, sessionFile); controller.showAgentHub(new SessionObserverRegistry()); - expect(shown()).toBeDefined(); - shown()!.dispose(); + const hub = shown(); + expect(hub).toBeDefined(); + expect(overlayOptions()).toMatchObject({ width: "100%", maxHeight: "100%", margin: 0, fullscreen: true }); + expect(agents.get("Worker")).toBeUndefined(); + expect(Bun.stripANSI(hub!.render(120).join("\n"))).toContain("Loading saved agents"); + await hub!.persistedSubagentsReady; + expect(agents.get("Worker")?.status).toBe("parked"); + hub!.dispose(); }); it("armCloseTap lets a single ← dismiss the hub the opening ←← raised", () => { @@ -404,6 +660,7 @@ describe("Agent hub data refresh coalescing", () => { const observers = new SessionObserverRegistry(); const requestRender = vi.fn(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers, hubKeys: [], onDone: () => {}, @@ -447,4 +704,120 @@ describe("Agent hub data refresh coalescing", () => { vi.useRealTimers(); } }); + + it("refreshes direct-session fallback stats on the age cadence, not paints or heartbeats", async () => { + vi.useFakeTimers(); + const agents = new AgentRegistry(); + const observers = new SessionObserverRegistry(); + const requestRender = vi.fn(); + let inputTokens = 100; + let assistantMessages = 1; + const getSessionStats = vi.fn(() => ({ + sessionFile: undefined, + sessionId: "sdk-agent", + userMessages: 1, + assistantMessages, + toolCalls: 2, + toolResults: 2, + totalMessages: 6, + tokens: { + input: inputTokens, + output: 50, + reasoning: 0, + cacheRead: 20, + cacheWrite: 0, + total: inputTokens + 70, + }, + premiumRequests: 0, + cost: 0.1, + })); + agents.register({ + id: "SdkAgent", + displayName: "SDK agent", + kind: "sub", + parentId: "Main", + session: { getSessionStats, subscribe: () => () => {} } as unknown as AgentSession, + status: "running", + }); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers, + hubKeys: [], + onDone: () => {}, + requestRender, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + await hub.persistedSubagentsReady; + expect(getSessionStats).toHaveBeenCalledTimes(1); + for (let i = 0; i < 4; i++) hub.render(120); + expect(getSessionStats).toHaveBeenCalledTimes(1); + expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("150 tok"); + + inputTokens = 400; + assistantMessages = 2; + agents.setActivity("SdkAgent", "heartbeat"); + vi.advanceTimersByTime(100); + expect(getSessionStats).toHaveBeenCalledTimes(1); + expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("150 tok"); + + vi.advanceTimersByTime(4_899); + expect(getSessionStats).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(getSessionStats).toHaveBeenCalledTimes(2); + const refreshed = Bun.stripANSI(hub.render(120).join("\n")); + expect(refreshed).toContain("450 tok"); + expect(refreshed).toContain("2 req"); + expect(refreshed).toContain("1/1"); + expect(refreshed).toContain("measured"); + hub.render(120); + expect(getSessionStats).toHaveBeenCalledTimes(2); + } finally { + hub.dispose(); + vi.useRealTimers(); + } + }); + + it("counts shared fallback session usage once across parent and descendant rows", () => { + const agents = new AgentRegistry(); + const getSessionStats = vi.fn(() => ({ + tokens: { input: 100, output: 40, cacheRead: 10, cacheWrite: 10, total: 160 }, + assistantMessages: 1, + toolCalls: 2, + cost: 0.1, + contextUsage: undefined, + })); + const session = { getSessionStats } as unknown as AgentSession; + agents.register({ id: "Parent", displayName: "Parent", kind: "sub", session, status: "idle" }); + agents.register({ + id: "Child", + displayName: "Child", + kind: "sub", + parentId: "Parent", + session, + status: "idle", + }); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + try { + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("150 tok"); + expect(rendered).toContain("1/2"); + expect(rendered).toContain("measured"); + expect(getSessionStats).toHaveBeenCalledTimes(1); + } finally { + hub.dispose(); + } + }); }); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 2588dd3eb..5f72f089a 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -6,10 +6,12 @@ * agents that appear while the hub is open are appended at the end. */ import { afterEach, beforeAll, describe, expect, it, setSystemTime, vi } from "bun:test"; +import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus"; -import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { type AgentHubDeps, AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; import { SessionObserverRegistry } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; -import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; @@ -40,8 +42,9 @@ function stubStdoutGeometry(cols: number): GeometryStub { }; } -function makeHub(agents: AgentRegistry) { +function makeHub(agents: AgentRegistry, overrides: Partial = {}) { return new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -49,18 +52,78 @@ function makeHub(agents: AgentRegistry) { registry: agents, irc: new IrcBus(agents), focusAgent: async () => {}, + ...overrides, }); } -function renderedAgentIds(hub: AgentHubOverlayComponent): string[] { - // Entry first lines are ` …`; task lines are - // indented deeper and chrome lines never carry the cursor slot. - const ids: string[] = []; - for (const raw of hub.render(120)) { - const match = /^ (?:❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw)); - if (match) ids.push(match[2]!); +interface RenderedAgentRow { + id: string; + selected: boolean; +} + +const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?:(?:│ {3}| {4})*)(?:├── |└── ))?(\S+)/u; + +function rosterCell(raw: string): string | undefined { + const line = Bun.stripANSI(raw); + if (!line.startsWith("│ ")) return undefined; + const divider = line.indexOf("│", Math.max(2, Math.floor(line.length / 3))); + if (divider < 0) return undefined; + return line.slice(2, Math.max(2, divider - 1)); +} + +function renderedAgentRows(hub: AgentHubOverlayComponent, width = 120): RenderedAgentRow[] { + // Roster entry first cells are + // ` [tree-prefix] …`; task cells are + // indented deeper and never match the cursor/status slots. + const rows: RenderedAgentRow[] = []; + for (const raw of hub.render(width)) { + const cell = rosterCell(raw); + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; + if (match) rows.push({ id: match[3]!, selected: match[1] === "❯" }); } - return ids; + return rows; +} + +function renderedAgentIds(hub: AgentHubOverlayComponent): string[] { + return renderedAgentRows(hub).map(row => row.id); +} + +function selectedAgentId(hub: AgentHubOverlayComponent): string | undefined { + return renderedAgentRows(hub).find(row => row.selected)?.id; +} + +function renderedRosterEntry(hub: AgentHubOverlayComponent, id: string, width: number): string { + const cells = hub.render(width).map(rosterCell); + const start = cells.findIndex(cell => { + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; + return match?.[3] === id; + }); + expect(start).toBeGreaterThanOrEqual(0); + const entry: string[] = []; + for (let i = start; i < cells.length; i++) { + const cell = cells[i]; + if (cell === undefined || cell.trim().length === 0) break; + if (i > start && ROSTER_ENTRY_PATTERN.test(cell)) break; + entry.push(cell.trimEnd()); + } + return entry.join("\n"); +} +function renderedRosterHeaderLineRaw(hub: AgentHubOverlayComponent, id: string, width: number): string { + const line = hub.render(width).find(raw => { + const cell = rosterCell(raw); + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; + return match?.[3] === id; + }); + if (!line) throw new Error(`No rendered roster header for ${id}`); + return line; +} + +function leftClick(row1Based: number): string { + return `\x1b[<0;4;${row1Based}M`; +} + +function wheel(direction: "up" | "down"): string { + return `\x1b[<${direction === "down" ? 65 : 64};4;4M`; } describe("Agent hub row ordering", () => { @@ -79,6 +142,20 @@ describe("Agent hub row ordering", () => { AgentRegistry.resetGlobalForTests(); }); + it("renders a useful empty state before any task agents exist", () => { + geometry = stubStdoutGeometry(120); + const hub = makeHub(new AgentRegistry()); + + try { + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("No agents in this session"); + expect(rendered).toContain("Finished, parked, and killed subagents remain with the session"); + expect(rendered).toContain("Resume that session with omp-dev --continue, or spawn a task here."); + } finally { + hub.dispose(); + } + }); + it("freezes the initial lastActivity order while the hub is open", () => { vi.useFakeTimers(); let hub: AgentHubOverlayComponent | undefined; @@ -99,17 +176,18 @@ describe("Agent hub row ordering", () => { hub = makeHub(agents); expect(renderedAgentIds(hub)).toEqual(["C", "B", "A"]); - - // Bump A's lastActivity far ahead of the others. The hub is already open, - // so the captured order must not change. + // Bump A's lastActivity far ahead of the others; captured order wins. setSystemTime(4000); agents.setActivity("A", "still running"); - // Registering a new agent schedules a coalesced row refresh; the - // existing rows must stay put once the scheduled refresh runs. + // Status changes must not reorder the captured roster either. + agents.setStatus("B", "idle"); + + // Registering a new agent schedules a coalesced row refresh; even a + // different status is appended after all rows captured on open. setSystemTime(5000); const sessionD = {} as AgentSession; - agents.register({ id: "D", displayName: "Delta", kind: "sub", session: sessionD }); + agents.register({ id: "D", displayName: "Delta", kind: "sub", session: sessionD, status: "parked" }); expect(renderedAgentIds(hub)).toEqual(["C", "B", "A"]); vi.advanceTimersByTime(100); @@ -121,13 +199,116 @@ describe("Agent hub row ordering", () => { } }); + it("bounds observer lookups and entry rendering to the viewport on large rosters", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 10_000; i++) { + const id = `Agent-${i.toString().padStart(5, "0")}`; + agents.register({ id, displayName: id, kind: "sub", session: null, status: "parked" }); + } + + const observers = new SessionObserverRegistry(); + const getSessions = vi.spyOn(observers, "getSessions"); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + getSessions.mockClear(); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // rows=12 → line budget 5; unknown usage is an explicit second line, + // so two complete entries fit while rendering remains viewport-bounded. + expect(visibleIds).toHaveLength(2); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + expect(getSession.mock.calls.length).toBeGreaterThan(0); + + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("10000 parked"); + expect(text).toMatch(/… \d+ more/); + + // Moving selection re-renders only the new viewport, not the whole roster. + getSessions.mockClear(); + getSession.mockClear(); + hub.handleInput("j"); + const afterMove = renderedAgentIds(hub); + expect(afterMove.length).toBeGreaterThan(0); + expect(afterMove.length).toBeLessThanOrEqual(2); + expect(afterMove).toContain(visibleIds[1]!); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + } finally { + hub.dispose(); + } + }); + + it("sizes the lazy viewport by real entry height when rows have a task line", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 100; i++) { + const id = `TaskAgent-${i.toString().padStart(3, "0")}`; + agents.register({ + id, + displayName: id, + kind: "sub", + session: null, + status: "parked", + }); + } + + const observers = new SessionObserverRegistry(); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + // Force a second task line via observer metadata so entry height is 2. + getSession.mockImplementation((id: string) => ({ + id, + kind: "subagent", + label: "Subagent", + status: "active", + description: `task for ${id}`, + lastUpdate: Date.now(), + })); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // Each entry is 2 lines; budget 5 → at most 2 full entries + probes. + expect(visibleIds.length).toBeGreaterThan(0); + expect(visibleIds.length).toBeLessThanOrEqual(3); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(6); + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("task for"); + expect(text).toContain(visibleIds[0]!); + } finally { + hub.dispose(); + } + }); + it("truncates lines and sanitizes newlines to prevent terminal wrapping", () => { geometry = stubStdoutGeometry(80); const agents = new AgentRegistry(); const sessionA = {} as AgentSession; agents.register({ id: "RevAgentStream", - displayName: "Agent runtime + compaction reviewer", + displayName: "Agent runtime + compaction reviewer\u0007", kind: "sub", session: sessionA, }); @@ -141,30 +322,96 @@ describe("Agent hub row ordering", () => { status: "active", description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks", lastUpdate: Date.now(), + progress: { + currentTool: "bash", + currentToolArgs: "\x1b[2Jdangerous args", + } as never, }, ]); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, - }); + const hub = makeHub(agents, { observers }); const lines = hub.render(80); + expect(lines.join("\n")).not.toContain("\u0007"); for (const line of lines) { const cleanLine = Bun.stripANSI(line); expect(cleanLine.includes("\n")).toBe(false); expect(cleanLine.includes("\r")).toBe(false); const width = visibleWidth(line); - expect(width).toBeLessThanOrEqual(78); + expect(width).toBeLessThanOrEqual(80); } + hub.handleInput("\t"); + const details = hub.render(80).join("\n"); + expect(details).not.toContain("\x1b[2J"); + expect(details).toContain("dangerous args"); hub.dispose(); }); + it("fits the fullscreen table to short terminals and windows large registries", () => { + geometry = stubStdoutGeometry(80); + geometry.setRows(10); + const agents = new AgentRegistry(); + for (let i = 0; i < 50; i++) { + agents.register({ + id: `Agent${i}`, + displayName: `Agent ${i}`, + kind: "sub", + session: {} as AgentSession, + }); + } + + const observers = new SessionObserverRegistry(); + const sessions = vi.spyOn(observers, "getSessions").mockReturnValue([]); + const hub = makeHub(agents, { observers }); + + try { + const lines = hub.render(80); + expect(lines.length).toBe(10); + expect(sessions.mock.calls.length).toBeLessThan(agents.list().length); + expect(Bun.stripANSI(lines.join("\n"))).toContain("…"); + } finally { + hub.dispose(); + } + }); + it("matches fullscreen menu mouse selection, wheel, and activation", async () => { + geometry = stubStdoutGeometry(120); + const agents = new AgentRegistry(); + setSystemTime(1_000); + agents.register({ id: "Alpha", displayName: "Alpha", kind: "sub", session: {} as AgentSession }); + setSystemTime(2_000); + agents.register({ id: "Beta", displayName: "Beta", kind: "sub", session: {} as AgentSession }); + setSystemTime(3_000); + agents.register({ id: "Gamma", displayName: "Gamma", kind: "sub", session: {} as AgentSession }); + + const focused: string[] = []; + const done = vi.fn(); + const hub = makeHub(agents, { + onDone: done, + focusAgent: async id => { + focused.push(id); + }, + }); + + try { + expect(selectedAgentId(hub)).toBe("Gamma"); + hub.handleInput(wheel("down")); + expect(selectedAgentId(hub)).toBe("Beta"); + + const frame = hub.render(120); + const alphaRow = frame.findIndex(line => /^│ {3}\S+ Alpha/u.test(Bun.stripANSI(line))); + expect(alphaRow).toBeGreaterThanOrEqual(0); + hub.handleInput(`\x1b[<0;110;${alphaRow + 1}M`); + expect(selectedAgentId(hub)).toBe("Beta"); + expect(focused).toEqual([]); + hub.handleInput(leftClick(alphaRow + 1)); + await Promise.resolve(); + expect(selectedAgentId(hub)).toBe("Alpha"); + expect(focused).toEqual(["Alpha"]); + expect(done).toHaveBeenCalledTimes(1); + } finally { + hub.dispose(); + } + }); it("flags a fallback badge for observer-only rows with no live session", () => { geometry = stubStdoutGeometry(120); @@ -174,30 +421,20 @@ describe("Agent hub row ordering", () => { agents.register({ id: "GuestAgent", displayName: "Guest Agent", kind: "sub", session: null }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "GuestAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "openai/gpt-4o", - resolvedModelIsFallback: true, - } as never, - }, - ]); - - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "GuestAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "openai/gpt-4o", + resolvedModelIsFallback: true, + } as never, }); + const hub = makeHub(agents, { observers }); + try { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("fallback → openai/gpt-4o"); } finally { @@ -216,34 +453,507 @@ describe("Agent hub row ordering", () => { agents.register({ id: "FastAgent", displayName: "Fast Agent", kind: "sub", session }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "FastAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "fireworks/kimi-k2", - resolvedModelIsFallback: true, - } as never, - }, - ]); - - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "FastAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "fireworks/kimi-k2", + resolvedModelIsFallback: true, + } as never, }); + const hub = makeHub(agents, { observers }); + try { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("fallback → fireworks/kimi-k2"); } finally { hub.dispose(); } }); + + it("retains the live thinking level unless progress has an explicit suffix", () => { + geometry = stubStdoutGeometry(140); + const agents = new AgentRegistry(); + const inheritedSession = { thinkingLevel: ThinkingLevel.High } as unknown as AgentSession; + const explicitSession = { thinkingLevel: ThinkingLevel.High } as unknown as AgentSession; + agents.register({ + id: "InheritedLevel", + displayName: "Inherited level", + kind: "sub", + session: inheritedSession, + }); + agents.register({ + id: "ExplicitLevel", + displayName: "Explicit level", + kind: "sub", + session: explicitSession, + }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "InheritedLevel", + kind: "subagent", + label: "Inherited level", + status: "active", + lastUpdate: Date.now(), + progress: { resolvedModel: "openai/gpt-5.4" } as never, + }, + { + id: "ExplicitLevel", + kind: "subagent", + label: "Explicit level", + status: "active", + lastUpdate: Date.now(), + progress: { resolvedModel: "openai/gpt-5.4:low" } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const inherited = renderedRosterEntry(hub, "InheritedLevel", 140); + expect(inherited).toContain("gpt-5.4"); + expect(inherited).toContain(theme.thinking.high); + + const explicit = renderedRosterEntry(hub, "ExplicitLevel", 140); + expect(explicit).toContain("gpt-5.4"); + expect(explicit).toContain(theme.thinking.low); + expect(explicit).not.toContain(theme.thinking.high); + } finally { + hub.dispose(); + } + }); + + it("renders aggregate usage and a selected-agent inspector without inventing change attribution", () => { + geometry = stubStdoutGeometry(140); + geometry.setRows(28); + const agents = new AgentRegistry(); + agents.register({ + id: "Reviewer", + displayName: "Security Reviewer", + kind: "sub", + parentId: "Main", + session: null, + history: { + outputPath: "/tmp/Reviewer.md", + patchPath: "/tmp/Reviewer.patch", + branchName: "omp/task/Reviewer", + }, + }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "Reviewer", + kind: "subagent", + label: "Reviewer", + description: "Review the session lifecycle and produce actionable findings", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "Reviewer", + index: 0, + agent: "reviewer", + agentSource: "bundled", + status: "running", + task: "Review the session lifecycle", + currentTool: "read", + currentToolArgs: "src/session/agent-session.ts", + recentTools: [], + recentOutput: [], + toolCount: 27, + requests: 12, + tokens: 18_400, + contextTokens: 31_000, + contextWindow: 128_000, + cost: 0.2134, + durationMs: 134_000, + resolvedModel: "openai/gpt-5.4:high", + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const rendered = Bun.stripANSI(hub.render(140).join("\n")); + expect(rendered).toContain("1 running"); + expect(rendered).toContain("Flat"); + expect(rendered).toContain("By parent"); + expect(rendered).toContain("$0.213 · 2m14s active · 12 req · 27 tools · 18K tok"); + expect(rendered).toContain("Security Reviewer"); + expect(rendered).toContain("read · src/session/agent-session.ts"); + expect(rendered).toContain("31K/128K 24%"); + expect(rendered).toContain("Registered "); + expect(rendered).toContain("Shared workspace · per-agent LoC not attributable"); + expect(rendered).toContain("Output /tmp/Reviewer.md"); + expect(rendered).toContain("Patch /tmp/Reviewer.patch"); + hub.handleInput("\x1b[6~"); + expect(Bun.stripANSI(hub.render(140).join("\n"))).toContain("Worktree branch omp/task/Reviewer"); + } finally { + hub.dispose(); + } + }); + it("shows dense measured usage for running and completed progress with aggregate coverage", () => { + geometry = stubStdoutGeometry(160); + geometry.setRows(32); + const agents = new AgentRegistry(); + agents.register({ id: "Running", displayName: "Running", kind: "sub", session: null, status: "running" }); + agents.register({ id: "Completed", displayName: "Completed", kind: "sub", session: null, status: "idle" }); + agents.register({ + id: "Historical", + displayName: "Historical", + kind: "sub", + session: null, + status: "parked", + activity: "Restored task", + }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "Running", + kind: "subagent", + label: "Running", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "Running", + index: 0, + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Run checks", + recentTools: [], + recentOutput: [], + toolCount: 4, + requests: 3, + tokens: 1_200, + cost: 0.1234, + durationMs: 6_500, + } as never, + }, + { + id: "Completed", + kind: "subagent", + label: "Completed", + status: "completed", + lastUpdate: Date.now(), + progress: { + id: "Completed", + index: 1, + agent: "worker", + agentSource: "bundled", + status: "completed", + task: "Finish checks", + recentTools: [], + recentOutput: [], + toolCount: 8, + requests: 5, + tokens: 2_500, + cost: 0.4567, + durationMs: 125_000, + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const rendered = Bun.stripANSI(hub.render(160).join("\n")); + expect(rendered).toContain("2/3 measured"); + expect(rendered).toContain("$0.580"); + expect(rendered).toContain("3.7K tok"); + expect(rendered).toContain("8 req"); + expect(rendered).toContain("12 tools"); + expect(rendered).toContain("2m11s active agent time"); + + const running = renderedRosterEntry(hub, "Running", 160); + expect(running).toContain("$0.123"); + expect(running).toContain("6.5s"); + expect(running).toContain("3 req"); + expect(running).toContain("4 tools"); + expect(running).toContain("1.2K tok"); + + const completed = renderedRosterEntry(hub, "Completed", 160); + expect(completed).toContain("$0.457"); + expect(completed).toContain("2m5s"); + expect(completed).toContain("5 req"); + expect(completed).toContain("8 tools"); + expect(completed).toContain("2.5K tok"); + + const historical = renderedRosterEntry(hub, "Historical", 160); + expect(historical).toContain("Restored task"); + expect(historical).toContain("usage —"); + expect(historical).not.toContain("$0.000"); + } finally { + hub.dispose(); + } + }); + it("treats incomplete and non-finite progress usage as unknown", () => { + geometry = stubStdoutGeometry(160); + const agents = new AgentRegistry(); + const getSessionStats = vi.fn(() => ({ + sessionFile: undefined, + sessionId: "incomplete", + userMessages: 1, + assistantMessages: 9, + toolCalls: 4, + toolResults: 4, + totalMessages: 18, + tokens: { input: 100, output: 50, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 150 }, + premiumRequests: 0, + cost: 0.25, + })); + agents.register({ + id: "Incomplete", + displayName: "Incomplete", + kind: "sub", + session: { getSessionStats } as unknown as AgentSession, + }); + agents.register({ id: "NonFinite", displayName: "Non-finite", kind: "sub", session: null }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "Incomplete", + kind: "subagent", + label: "Incomplete", + status: "active", + lastUpdate: Date.now(), + progress: { + tokens: 100, + toolCount: 2, + cost: 0.1, + durationMs: 1_000, + } as never, + }, + { + id: "NonFinite", + kind: "subagent", + label: "Non-finite", + status: "active", + lastUpdate: Date.now(), + progress: { + tokens: Number.NaN, + requests: 2, + toolCount: 2, + cost: 0.1, + durationMs: 1_000, + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const rendered = Bun.stripANSI(hub.render(160).join("\n")); + expect(rendered).toContain("0/2 measured"); + expect(renderedRosterEntry(hub, "Incomplete", 160)).toContain("usage —"); + expect(renderedRosterEntry(hub, "NonFinite", 160)).toContain("usage —"); + expect(getSessionStats).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + }); + it("shows configured role text beside a resolved model but not for an explicit selector", () => { + geometry = stubStdoutGeometry(160); + const agents = new AgentRegistry(); + agents.register({ id: "RoleAgent", displayName: "Role Agent", kind: "sub", session: null }); + agents.register({ id: "ExplicitAgent", displayName: "Explicit Agent", kind: "sub", session: null }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "RoleAgent", + kind: "subagent", + label: "Role Agent", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "RoleAgent", + index: 0, + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Run with the configured role", + recentTools: [], + recentOutput: [], + toolCount: 0, + requests: 1, + tokens: 10, + cost: 0, + durationMs: 100, + modelRole: "rapid", + resolvedModel: "openai/gpt-4o", + } as never, + }, + { + id: "ExplicitAgent", + kind: "subagent", + label: "Explicit Agent", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "ExplicitAgent", + index: 1, + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Run with an explicit selector", + recentTools: [], + recentOutput: [], + toolCount: 0, + requests: 1, + tokens: 10, + cost: 0, + durationMs: 100, + resolvedModel: "openai/gpt-4o", + } as never, + }, + ]); + const hub = makeHub(agents, { + observers, + settings: Settings.isolated({ + modelRoles: { rapid: "openai/gpt-4o" }, + modelTags: { rapid: { name: "Quick", color: "warning" } }, + }), + }); + + try { + const roleBlock = renderedRosterEntry(hub, "RoleAgent", 160); + expect(roleBlock).toContain("Quick"); + expect(roleBlock).toContain("gpt-4o"); + expect(roleBlock.indexOf("Quick")).toBeLessThan(roleBlock.indexOf("gpt-4o")); + + const explicitBlock = renderedRosterEntry(hub, "ExplicitAgent", 160); + expect(explicitBlock).toContain("gpt-4o"); + expect(explicitBlock).not.toContain("Quick"); + } finally { + hub.dispose(); + } + }); + it("switches between inline Flat and By parent projections with selection preserved", () => { + vi.useFakeTimers(); + geometry = stubStdoutGeometry(120); + const agents = new AgentRegistry(); + setSystemTime(1_000); + agents.register({ id: "Parent", displayName: "Parent", kind: "sub", parentId: "Main", session: null }); + setSystemTime(2_000); + agents.register({ id: "Peer", displayName: "Peer", kind: "sub", parentId: "Main", session: null }); + setSystemTime(3_000); + agents.register({ id: "Child", displayName: "Child", kind: "sub", parentId: "Parent", session: null }); + const hub = makeHub(agents); + + try { + expect(renderedAgentIds(hub)).toEqual(["Child", "Peer", "Parent"]); + expect(selectedAgentId(hub)).toBe("Child"); + const flat = Bun.stripANSI(hub.render(120).join("\n")); + expect(flat).toContain("Flat"); + expect(flat).toContain("By parent"); + + hub.setHoverIndex(0); + expect(renderedRosterHeaderLineRaw(hub, "Child", 120)).toContain(theme.getBgAnsi("selectedBg")); + hub.handleInput("t"); + const byParentIds = renderedAgentIds(hub); + expect(byParentIds).toEqual(["Parent", "Child", "Peer"]); + expect(selectedAgentId(hub)).toBe("Child"); + const byParent = Bun.stripANSI(hub.render(120).join("\n")); + expect(byParent).toContain("Flat"); + expect(byParent).toContain("By parent"); + expect(byParentIds.indexOf("Parent")).toBeLessThan(byParentIds.indexOf("Child")); + expect(renderedRosterHeaderLineRaw(hub, "Parent", 120)).not.toContain(theme.getBgAnsi("selectedBg")); + expect(renderedRosterHeaderLineRaw(hub, "Child", 120)).not.toContain(theme.getBgAnsi("selectedBg")); + + hub.handleInput("t"); + expect(renderedAgentIds(hub)).toEqual(["Child", "Peer", "Parent"]); + expect(selectedAgentId(hub)).toBe("Child"); + } finally { + hub.dispose(); + vi.useRealTimers(); + setSystemTime(); + } + }); + + it("renders parent lineage with bash-style tree connectors", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(32); + const agents = new AgentRegistry(); + agents.register({ id: "Parent", displayName: "Parent", kind: "sub", parentId: "Main", session: null }); + agents.register({ id: "First", displayName: "First", kind: "sub", parentId: "Parent", session: null }); + agents.register({ id: "Grandchild", displayName: "Grandchild", kind: "sub", parentId: "First", session: null }); + agents.register({ id: "Last", displayName: "Last", kind: "sub", parentId: "Parent", session: null }); + const hub = makeHub(agents); + + try { + hub.handleInput("t"); + expect(Bun.stripANSI(renderedRosterHeaderLineRaw(hub, "First", 120))).toContain("├── First"); + expect(Bun.stripANSI(renderedRosterHeaderLineRaw(hub, "Grandchild", 120))).toContain("│ └── Grandchild"); + expect(Bun.stripANSI(renderedRosterHeaderLineRaw(hub, "Last", 120))).toContain("└── Last"); + } finally { + hub.dispose(); + } + }); + + it("keeps cyclic parent links renderable in tree mode", () => { + const agents = new AgentRegistry(); + agents.register({ id: "CycleA", displayName: "Cycle A", kind: "sub", parentId: "CycleB", session: null }); + agents.register({ id: "CycleB", displayName: "Cycle B", kind: "sub", parentId: "CycleA", session: null }); + const hub = makeHub(agents); + + try { + hub.handleInput("t"); + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("CycleA"); + expect(rendered).toContain("CycleB"); + } finally { + hub.dispose(); + } + }); + + it("opens the selected-agent inspector as a narrow-terminal fallback", () => { + geometry = stubStdoutGeometry(80); + geometry.setRows(12); + const agents = new AgentRegistry(); + agents.register({ id: "NarrowAgent", displayName: "Narrow Agent", kind: "sub", session: null }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "NarrowAgent", + kind: "subagent", + label: "Narrow Agent", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "NarrowAgent", + status: "running", + task: "Inspect responsive behavior", + recentTools: [], + recentOutput: [], + toolCount: 3, + requests: 2, + tokens: 900, + cost: 0, + durationMs: 2_000, + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const roster = Bun.stripANSI(hub.render(80).join("\n")); + expect(roster).toContain("Tab:details"); + expect(roster).not.toContain("Registered "); + + hub.handleInput("\t"); + const details = Bun.stripANSI(hub.render(80).join("\n")); + expect(details).toContain("Agent Hub · NarrowAgent"); + expect(details).toContain("Usage"); + expect(details).toContain("$0.0000 · 2.0s active · 2 req · 3 tools · 900 tok"); + expect(details).toContain("Tab:roster"); + hub.handleInput("\x1b[6~"); + expect(Bun.stripANSI(hub.render(80).join("\n"))).toContain("Changes"); + for (const line of hub.render(80)) expect(visibleWidth(line)).toBeLessThanOrEqual(80); + + hub.handleInput("\x1b"); + expect(Bun.stripANSI(hub.render(80).join("\n"))).toContain("Roster"); + } finally { + hub.dispose(); + } + }); }); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 5bb139086..c630dc93d 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -850,30 +850,6 @@ describe("AgentSession handoff", () => { expect(promptSpy).toHaveBeenCalledTimes(1); }); - it("does not switch providers after provider-native auto-compaction fails", async () => { - session.settings.set("compaction.strategy", "context-full"); - session.settings.set("compaction.thresholdTokens", 50); - session.settings.set("compaction.keepRecentTokens", 1); - session.settings.set("contextPromotion.enabled", false); - - const attemptedCandidates: string[] = []; - vi.spyOn(compactionModule, "compact").mockImplementation(async (_preparation, candidate) => { - attemptedCandidates.push(`${candidate.provider}/${candidate.id}`); - throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); - }); - - await session.prompt("pending prompt ".repeat(120)); - await waitFor(() => - events.some( - event => - event.type === "auto_compaction_end" && - event.errorMessage?.includes("native compaction transport failed") === true, - ), - ); - - expect(attemptedCandidates.length).toBeGreaterThan(0); - expect(new Set(attemptedCandidates.map(candidate => candidate.split("/", 1)[0]))).toHaveLength(1); - }); it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); 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 fa70f6f87..6b2f9ea65 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -1164,29 +1164,14 @@ describe("AgentSession retry delay cap", () => { if (streamCalls === 1) { const thinking = { type: "thinking" as const, thinking: "partial thought" }; - // No visible text: a committed text block makes the failed turn - // replay-unsafe (turn-recovery #hasReplayUnsafeOutput), which would - // correctly suppress this retry. The delay-cap contract under test - // needs a replay-safe partial turn, so only thinking plus an - // incomplete (never toolcall_end'd) tool call is emitted. - const toolCall: ToolCall = { - type: "toolCall", - id: "tc-incomplete", - name: "bash", - arguments: { command: "bun probe-archive3.ts" }, - }; - partial.content.push(thinking, toolCall); + const text = { type: "text" as const, text: "partial buffered answer" }; + partial.content.push(thinking, text); stream.push({ type: "start", partial }); stream.push({ type: "thinking_start", contentIndex: 0, partial }); stream.push({ type: "thinking_delta", contentIndex: 0, delta: thinking.thinking, partial }); stream.push({ type: "thinking_end", contentIndex: 0, content: thinking.thinking, partial }); - stream.push({ type: "toolcall_start", contentIndex: 1, partial }); - stream.push({ - type: "toolcall_delta", - contentIndex: 1, - delta: JSON.stringify(toolCall.arguments), - partial, - }); + stream.push({ type: "text_start", contentIndex: 1, partial }); + stream.push({ type: "text_delta", contentIndex: 1, delta: text.text, partial }); stream.push({ type: "error", reason: "error", @@ -1243,6 +1228,7 @@ describe("AgentSession retry delay cap", () => { if (event.type === "auto_retry_end") retryEndEvents.push(event); }); + session.setTextOutputCommitted(false); await session.prompt("Trigger partial socket close"); await session.waitForIdle(); 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 f0d428ce4..b15931c60 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1,13 +1,14 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; -import { Agent } from "@oh-my-pi/pi-agent-core"; +import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type AssistantMessage, Effort, type Model, type ModelUsageHealth, type ProviderSessionState, + z, } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; @@ -96,6 +97,7 @@ describe("AgentSession retry fallback", () => { authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key"); authStorage.setRuntimeApiKey("openai", "openai-test-key"); + authStorage.setRuntimeApiKey("fireworks", "fireworks-test-key"); authStorage.setRuntimeApiKey("google", "google-test-key"); authStorage.setRuntimeApiKey("google-vertex", "google-vertex-test-key"); authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key"); @@ -290,9 +292,15 @@ describe("AgentSession retry fallback", () => { { credentialId: 1, credentialType: "oauth", + state: "reserve", + remainingFraction: 0.08, + }, + { + credentialId: 2, + credentialType: "oauth", selected: true, state: "reserve", - remainingFraction: 0.05, + remainingFraction: 0.02, }, ], } @@ -308,15 +316,72 @@ describe("AgentSession retry fallback", () => { session.setUsageFallbackConfirmer(confirmFallback); await session.prompt("Keep working on the same task"); await session.waitForIdle(); - expect(confirmFallback).toHaveBeenCalledWith({ - from: `${primaryModel.provider}/${primaryModel.id}`, - to: `${fallbackModel.provider}/${fallbackModel.id}`, - remainingPercent: 5, - }); + expect(confirmFallback).toHaveBeenCalledWith( + { + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + remainingPercent: 2, + }, + expect.any(AbortSignal), + ); expect(requestedModels).toEqual([`${fallbackModel.provider}/${fallbackModel.id}`]); expect(session.messages.some(message => message.role === "user")).toBe(true); }); + it("honors a live fail-closed policy after reserve spending was approved", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled reserve policy models"); + const mock = createMockModel({ responses: [{ content: ["stayed on primary"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: mock.stream, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "confirm", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmFallback = vi.fn(async () => false); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(confirmFallback); + + await session.prompt("Stay on the primary"); + await session.waitForIdle(); + settings.override("retry.usageReservePolicy", "fail-closed"); + expect(settings.get("retry.usageReservePolicy")).toBe("fail-closed"); + + await expect(session.prompt("Do not spend reserve")).rejects.toThrow("reserve policy is fail-closed"); + expect(confirmFallback).toHaveBeenCalledTimes(1); + expect(usageHealth).toHaveBeenCalledTimes(3); + }); it("reselects a healthy same-provider account before considering a model fallback", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); @@ -485,6 +550,647 @@ describe("AgentSession retry fallback", () => { expect(requestedModels).toEqual([]); }); + it("cancels a pending reserve confirmation without dispatching the prompt", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled confirmation cancellation models"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmationStarted = Promise.withResolvers(); + const pendingConfirmation = Promise.withResolvers(); + const confirmationAborted = Promise.withResolvers(); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(async (_confirmation, signal) => { + confirmationStarted.resolve(); + signal.addEventListener("abort", () => confirmationAborted.resolve(), { once: true }); + return pendingConfirmation.promise; + }); + + const prompt = session.prompt("Do not send after confirmation cancellation"); + await confirmationStarted.promise; + await session.abort(); + await confirmationAborted.promise; + await prompt; + + expect(requestedModels).toEqual([]); + }); + + it("defers usage fallback for a queued steer until the active stream finishes", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued fallback models"); + const requestedModels: string[] = []; + const streamStarted = Promise.withResolvers(); + const firstResponse = Promise.withResolvers<{ content: string[] }>(); + const mock = createMockModel({ + responses: [ + async () => { + streamStarted.resolve(); + return firstResponse.promise; + }, + { content: ["queued steer completed"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "auto", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + let useReserve = false; + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async provider => + provider === primaryModel.provider + ? useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const firstPrompt = session.prompt("Keep the primary stream active"); + await streamStarted.promise; + useReserve = true; + await session.sendUserMessage("Queue this steer", { deliverAs: "steer" }); + + expect(usageHealth).toHaveBeenCalledTimes(1); + expect(session.model?.id).toBe(primaryModel.id); + + firstResponse.resolve({ content: ["primary stream completed"] }); + await firstPrompt; + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + }); + + it("cancels queued-turn usage confirmation when post-prompt work is disposed", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued cancellation models"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmationStarted = Promise.withResolvers(); + const pendingConfirmation = Promise.withResolvers(); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(async () => { + confirmationStarted.resolve(); + return pendingConfirmation.promise; + }); + + await session.sendUserMessage("Queue this turn", { deliverAs: "steer" }); + await confirmationStarted.promise; + await session.dispose(); + session = undefined; + + expect(requestedModels).toEqual([]); + }); + + it("does not reschedule a queued drain after a dequeue hook rejects", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled queued-drain model"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ "compaction.enabled": false }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const hookRan = Promise.withResolvers(); + let attempts = 0; + const failingHook = vi.fn(() => { + hookRan.resolve(); + if (++attempts === 1) throw new Error("blocked before dequeue"); + }); + agent.addBeforeQueuedMessageDequeueHook(failingHook); + + await session.sendUserMessage("Keep this queued", { deliverAs: "steer" }); + await hookRan.promise; + await session.waitForIdle(); + + expect(failingHook).toHaveBeenCalledTimes(1); + expect(agent.hasQueuedMessages()).toBe(true); + expect(requestedModels).toEqual([]); + }); + + it("enforces fail-closed usage health when model fallback is disabled", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled fail-closed model"); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: createMockModel().stream, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.modelFallback": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockResolvedValue({ + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await expect(session.prompt("Do not spend reserve")).rejects.toThrow("reserve policy is fail-closed"); + }); + + it("does not degrade Fireworks Fast or retry a chain after queued fail-closed preflight", async () => { + const primaryModel = getBundledModel("fireworks", "kimi-k2.6-fast"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued fail-closed models"); + const requestedModels: string[] = []; + const streamStarted = Promise.withResolvers(); + const firstResponse = Promise.withResolvers<{ content: string[] }>(); + const mock = createMockModel({ + responses: [ + async () => { + streamStarted.resolve(); + return firstResponse.promise; + }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + let useReserve = false; + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const firstPrompt = session.prompt("Keep the primary stream active"); + await streamStarted.promise; + useReserve = true; + await session.sendUserMessage("Queue blocked work", { deliverAs: "steer" }); + firstResponse.resolve({ content: ["primary stream completed"] }); + await firstPrompt; + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + expect(session.model?.id).toBe(primaryModel.id); + expect(agent.hasQueuedMessages()).toBe(true); + }); + + it("rechecks fail-closed usage health before an internally scheduled continuation", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled scheduled continuation model"); + const requestedModels: string[] = []; + let useReserve = false; + const mock = createMockModel({ + responses: [ + async () => { + useReserve = true; + return { content: [], stopReason: "stop" }; + }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await session.prompt("Retry this empty response"); + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + }); + + it("rechecks fail-closed usage health before a same-turn tool continuation", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled tool-continuation model"); + const requestedModels: string[] = []; + let useReserve = false; + const toolSchema = z.object({ value: z.string() }); + const tool: AgentTool = { + name: "consume", + label: "Consume", + description: "Consume plan quota", + parameters: toolSchema, + async execute(_toolCallId, params) { + useReserve = true; + return { content: [{ type: "text", text: params.value }], details: params }; + }, + }; + const mock = createMockModel({ + responses: [ + { content: [{ type: "toolCall", id: "tool-1", name: "consume", arguments: { value: "done" } }] }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [tool], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await session.prompt("Use the tool"); + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + }); + it("rechecks fail-closed usage health when prompt setup changes the model", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const setupTarget = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!primaryModel || !setupTarget) throw new Error("Expected bundled setup-handoff models"); + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const mock = createMockModel({ responses: [{ content: ["must not run"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + const reserve = options.modelId === setupTarget.id; + return { + state: reserve ? "reserve" : "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: reserve ? "reserve" : "healthy", + remainingFraction: reserve ? 0.05 : 0.8, + }, + ], + }; + }); + const extensionRunner = { + emit: vi.fn().mockResolvedValue(undefined), + hasHandlers: vi.fn().mockReturnValue(false), + emitBeforeAgentStart: vi.fn(async () => { + if (!session) throw new Error("Expected active session"); + await session.setModelTemporary(setupTarget, undefined, { ephemeral: true }); + return undefined; + }), + } as unknown as ExtensionRunner; + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + extensionRunner, + }); + + await session.prompt("Change models during setup"); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(usageChecks).toEqual([primaryModel.id, setupTarget.id]); + expect(requestedModels).toEqual([]); + }); + + it("restarts usage preflight when the model changes during a health request", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const selectedModel = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!primaryModel || !selectedModel) throw new Error("Expected bundled preflight race models"); + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const healthStarted = Promise.withResolvers(); + const releaseHealth = Promise.withResolvers(); + const mock = createMockModel({ responses: [{ content: ["must not run"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + if (options.modelId === primaryModel.id) { + healthStarted.resolve(); + await releaseHealth.promise; + } + const reserve = options.modelId === selectedModel.id; + return { + state: reserve ? "reserve" : "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: reserve ? "reserve" : "healthy", + remainingFraction: reserve ? 0.05 : 0.8, + }, + ], + }; + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const prompting = session.prompt("Change models during preflight"); + await healthStarted.promise; + await session.setModelTemporary(selectedModel, undefined, { ephemeral: true }); + releaseHealth.resolve(); + await expect(prompting).rejects.toThrow(`reserve reached for ${selectedModel.provider}/${selectedModel.id}`); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(usageChecks).toEqual([primaryModel.id, selectedModel.id]); + expect(session.model?.id).toBe(selectedModel.id); + expect(requestedModels).toEqual([]); + }); + + it("finishes usage preflight when no model is selected", async () => { + const agent = new Agent({ + initialState: { model: undefined, systemPrompt: ["Test"], tools: [], messages: [] }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await expect(session.prompt("No model configured")).rejects.toThrow("No model selected"); + expect(agent.state.isStreaming).toBe(false); + }); + it("continues a startup-owned role fallback chain from the active fallback", async () => { const firstFallback = getBundledModel("openai", "gpt-4o-mini"); const secondFallback = getBundledModel("openai", "gpt-4o"); @@ -2870,6 +3576,54 @@ describe("AgentSession retry fallback", () => { expect(session.thinkingLevel).toBe(Effort.Low); }); + it("skips usage fallbacks whose effort floor exceeds the session ceiling", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const incompatibleFallback = getBundledModel("fireworks", "deepseek-v4-pro"); + const compatibleFallback = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !incompatibleFallback || !compatibleFallback) { + throw new Error("Expected bundled usage fallback effort models"); + } + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "auto", + "retry.fallbackChains": { + default: [ + `${incompatibleFallback.provider}/${incompatibleFallback.id}`, + `${compatibleFallback.provider}/${compatibleFallback.id}`, + ], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + return options.modelId === primaryModel.id + ? { + state: "depleted", + accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }], + } + : { state: "healthy", accounts: [] }; + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + thinkingLevel: Effort.Low, + thinkingLevelCeiling: Effort.Low, + }); + + await session.prompt("Use an effort-compatible fallback"); + await session.waitForIdle(); + + expect(usageChecks).toEqual([primaryModel.id, compatibleFallback.id]); + expect(requestedModels).toEqual([`${compatibleFallback.provider}/${compatibleFallback.id}`]); + expect(session.model?.id).toBe(compatibleFallback.id); + }); + it("accepts cached Ollama Cloud fallback selectors during startup validation", () => { const primaryModel = getBundledModel("openai", "gpt-4o-mini"); if (!primaryModel) { diff --git a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts index b059004c1..67c2d2781 100644 --- a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts @@ -50,6 +50,13 @@ function unexpectedStop(text: string): MockResponse { }; } +function thinkingOnlyStop(thinking: string): MockResponse { + return { + content: [{ type: "thinking", thinking, thinkingSignature: "reasoning_content" }], + stopReason: "stop", + }; +} + async function createHarness( responses: MockResponse[], settingsOverrides: SettingsOverrides = {}, @@ -170,6 +177,30 @@ describe("AgentSession unexpected stop guard", () => { expect(reminderMessages(session.agent.state.messages)).toHaveLength(1); }); + it("classifies a thinking-only stop on its thinking text and continues", async () => { + let calls = 0; + const spy = vi.spyOn(unexpectedStopClassifier, "classifyUnexpectedStop").mockImplementation(async () => { + calls++; + return calls === 1; + }); + const { session, mock } = await createHarness( + [thinkingOnlyStop(" 响应"), { content: ["done now"], stopReason: "stop" }], + { + "features.unexpectedStopDetection": true, + "providers.unexpectedStopModel": "online", + }, + ); + + await session.prompt("do the thing"); + await session.waitForIdle(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[0]?.[0]).toContain("响应"); + expect(mock.calls).toHaveLength(2); + expect(assistantText(session.agent.state.messages)).toContain("done now"); + expect(reminderMessages(session.agent.state.messages)).toHaveLength(1); + }); + it("does not continue when the classifier returns false", async () => { const spy = vi.spyOn(unexpectedStopClassifier, "classifyUnexpectedStop").mockResolvedValue(false); const { session, mock } = await createHarness( diff --git a/packages/coding-agent/test/async-job-manager.test.ts b/packages/coding-agent/test/async-job-manager.test.ts index 1a336456a..21fe7529a 100644 --- a/packages/coding-agent/test/async-job-manager.test.ts +++ b/packages/coding-agent/test/async-job-manager.test.ts @@ -113,6 +113,30 @@ describe("AsyncJobManager", () => { expect(completions).toHaveLength(0); }); + test("bounds owner-job reap while preserving late settlement", async () => { + const manager = new AsyncJobManager({ onJobComplete: async () => {} }); + const release = Promise.withResolvers(); + const jobId = manager.register( + "task", + "ignores abort", + async () => { + await release.promise; + return "late result"; + }, + { ownerId: "owner" }, + ); + + const reap = await manager.cancelAndReapOwnerJobs("owner", Date.now()); + + expect(reap.settled).toBe(false); + expect(reap.pendingJobIds).toEqual([jobId]); + expect(manager.getJob(jobId)?.status).toBe("cancelled"); + + release.resolve(); + await reap.completion; + expect(manager.getJob(jobId)?.resultText).toBe("late result"); + }); + test("enforces maxRunningJobs cap", () => { const manager = new AsyncJobManager({ maxRunningJobs: 1, diff --git a/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts new file mode 100644 index 000000000..6595c45c3 --- /dev/null +++ b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts @@ -0,0 +1,146 @@ +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 MCPServer, mcpCapability } from "@oh-my-pi/pi-coding-agent/capability/mcp"; +import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { removeWithRetries } from "@oh-my-pi/pi-utils"; + +async function loadMcp(cwd: string, provider: string): Promise { + const result = await loadCapability(mcpCapability.id, { + cwd, + providers: [provider], + }); + return result.items; +} + +interface Fixture { + /** Discovery provider id passed to `loadCapability`. */ + provider: string; + /** Project-relative config file the importer reads. */ + file: string; + /** File body carrying a single server with `enabled: false`. */ + content: string; +} + +// Project-scoped config for each translated importer that previously dropped the +// per-server `enabled` flag (issue #7652). Codex/OpenCode/native already +// propagate it and are covered elsewhere. +const FIXTURES: Fixture[] = [ + { + provider: "claude", + file: ".claude/.mcp.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "cursor", + file: ".cursor/mcp.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "gemini", + file: ".gemini/settings.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "windsurf", + file: ".windsurf/mcp_config.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "vscode", + file: ".vscode/mcp.json", + content: JSON.stringify({ + mcp: { servers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } } }, + }), + }, +]; + +interface CompoundFixture { + /** Discovery provider id passed to `loadCapability`. */ + provider: string; + /** User-scope config file, relative to the temp HOME. */ + userFile: string; + /** Project-scope config file, relative to the temp cwd. */ + projectFile: string; +} + +// Providers exposing both a user and a project MCP scope. A project +// `enabled: false` must claim the dedupe key ahead of the same-named user +// server so the disable actually suppresses it (#7654). VS Code MCP is +// project-only, so it has no user/project compound case. +const COMPOUND_FIXTURES: CompoundFixture[] = [ + { provider: "claude", userFile: ".claude.json", projectFile: ".claude/.mcp.json" }, + { provider: "cursor", userFile: ".cursor/mcp.json", projectFile: ".cursor/mcp.json" }, + { provider: "gemini", userFile: ".gemini/settings.json", projectFile: ".gemini/settings.json" }, + { provider: "windsurf", userFile: ".codeium/windsurf/mcp_config.json", projectFile: ".windsurf/mcp_config.json" }, +]; + +function mcpServersJson(enabled: boolean, command: string): string { + return JSON.stringify({ + mcpServers: { markitdown: { command, args: ["markitdown-mcp"], type: "stdio", enabled } }, + }); +} + +describe("translated MCP importers propagate enabled: false", () => { + let tempCwd = ""; + let tempHome = ""; + let originalHome: string | undefined; + + beforeEach(async () => { + originalHome = process.env.HOME; + tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-cwd-")); + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-home-")); + process.env.HOME = tempHome; + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await removeWithRetries(tempCwd); + await removeWithRetries(tempHome); + }); + + for (const { provider, file, content } of FIXTURES) { + test(`${provider} carries enabled: false`, async () => { + const filePath = path.join(tempCwd, file); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + + const servers = await loadMcp(tempCwd, provider); + const server = servers.find(item => item.name === "markitdown"); + + expect(server).toBeDefined(); + expect(server?.enabled).toBe(false); + }); + } + + for (const { provider, userFile, projectFile } of COMPOUND_FIXTURES) { + test(`${provider} project enabled: false suppresses a same-named user server`, async () => { + const userPath = path.join(tempHome, userFile); + const projectPath = path.join(tempCwd, projectFile); + await fs.mkdir(path.dirname(userPath), { recursive: true }); + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + await fs.writeFile(userPath, mcpServersJson(true, "user-markitdown")); + await fs.writeFile(projectPath, mcpServersJson(false, "project-markitdown")); + + const result = await loadCapability(mcpCapability.id, { + cwd: tempCwd, + providers: [provider], + suppress: server => server.enabled === false, + }); + + expect(result.items.find(server => server.name === "markitdown")).toBeUndefined(); + }); + } +}); diff --git a/packages/coding-agent/test/eval/agent-bridge-policy.test.ts b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts index fb290ced8..95036176c 100644 --- a/packages/coding-agent/test/eval/agent-bridge-policy.test.ts +++ b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts @@ -290,10 +290,7 @@ describe("runEvalAgent", () => { }), }); - await runEvalAgent( - { prompt: " hello ", label: "My Agent", model: "p/override", schema }, - { session, signal: abortController.signal }, - ); + await runEvalAgent({ prompt: " hello ", label: "My Agent", schema }, { session, signal: abortController.signal }); await runEvalAgent({ prompt: "plain" }, { session }); const firstOptions = runSpy.mock.calls[0]?.[0]; @@ -306,10 +303,26 @@ describe("runEvalAgent", () => { expect(firstOptions.outputSchemaOverridesAgent).toBe(true); expect(firstOptions.assignment).toBe("hello"); expect(firstOptions.description).toBe("My Agent"); - expect(firstOptions.modelOverride).toEqual(["p/override"]); + // No per-call override: the agent's own frontmatter model applies. + expect(firstOptions.modelOverride).toEqual(["p/current"]); expect(secondOptions.outputSchema).toBeUndefined(); expect(secondOptions.outputSchemaOverridesAgent).toBeUndefined(); }); + + it("drops a per-call model argument on agent() (removed, issue #6438)", async () => { + mockAgents(); + const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options)); + + // The schema strips unknown keys; a legacy `model` argument is silently + // discarded so resolution is identical to omitting it — the agent's own + // frontmatter model applies (issue #6438). + await runEvalAgent({ prompt: "work", model: "default" }, { session: makeSession() }); + await runEvalAgent({ prompt: "work" }, { session: makeSession() }); + + const withModel = runSpy.mock.calls[0]?.[0]; + const withoutModel = runSpy.mock.calls[1]?.[0]; + expect(withModel?.modelOverride).toEqual(withoutModel?.modelOverride); + }); it("returns host-parsed data for caller, agent, and inherited schemas", async () => { const agentSchema = { type: "object" }; const sessionSchema = { type: "object" }; diff --git a/packages/coding-agent/test/eval/prelude-agent.test.ts b/packages/coding-agent/test/eval/prelude-agent.test.ts index 4aa4019aa..e74aa34f5 100644 --- a/packages/coding-agent/test/eval/prelude-agent.test.ts +++ b/packages/coding-agent/test/eval/prelude-agent.test.ts @@ -66,12 +66,11 @@ describe("eval js agent() handle", () => { ) => Promise; const schema = { type: "object", properties: { ok: { type: "boolean" } } }; - await positionalAgent("scout", "reviewer", "p/model", "Legacy", schema, true, false, true, "strict"); + await positionalAgent("scout", "reviewer", "Legacy", schema, true, false, true, "strict"); expect(seenArgs).toEqual({ prompt: "scout", agent: "reviewer", - model: "p/model", label: "Legacy", schema, isolated: true, diff --git a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts new file mode 100644 index 000000000..653f0e849 --- /dev/null +++ b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { vocalizer } from "@oh-my-pi/pi-coding-agent/tts/vocalizer"; +import type { TUI } from "@oh-my-pi/pi-tui"; + +function zeroUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function assistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + usage: zeroUsage(), + stopReason: undefined, + createdAt: new Date(0), + } as unknown as AssistantMessage; +} + +function messageUpdate(text: string): Extract { + return { + type: "message_update", + message: assistantMessage(text), + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: text, partial: assistantMessage(text) }, + } as unknown as Extract; +} + +function createStreamingFixture() { + const listeners: Array<(event: AgentSessionEvent) => void> = []; + const ui = { + requestRender: vi.fn(), + requestComponentRender: vi.fn(), + } as unknown as TUI; + const viewSession = { isStreaming: true, getToolByName: () => undefined }; + const ctx = { + isInitialized: true, + init: vi.fn(async () => {}), + ui, + settings, + chatContainer: { addChild: vi.fn(), children: [] }, + pendingTools: new Map(), + transcriptMessageComponents: new WeakMap(), + streamingComponent: { + setHideThinkingBlock: vi.fn(), + markTranscriptBlockFinalized: vi.fn(), + updateContent: vi.fn(), + }, + noteDisplayableThinkingContent: vi.fn(() => false), + ensureLoadingAnimation: vi.fn(), + statusLine: { invalidate: vi.fn() }, + updateEditorTopBorder: vi.fn(), + setWorkingMessage: vi.fn(), + viewSession, + session: { + subscribe: (listener: (event: AgentSessionEvent) => void) => { + listeners.push(listener); + return () => {}; + }, + } as unknown as InteractiveModeContext["session"], + } as unknown as InteractiveModeContext; + const controller = new EventController(ctx); + controller.subscribeToAgent(); + const emit = (event: AgentSessionEvent) => { + for (const listener of listeners) void listener(event); + }; + return { controller, ctx, ui, emit }; +} +async function flushMicrotasks(): Promise { + for (let i = 0; i < 12; i++) await Promise.resolve(); +} + +describe("EventController message_update coalescing", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { "display.smoothStreaming": false } }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + resetSettingsForTest(); + }); + + it("runs the streaming rebuild once per window instead of once per delta, applying the latest snapshot", async () => { + const { ctx, ui, emit } = createStreamingFixture(); + + emit(messageUpdate("tok1")); + emit(messageUpdate("tok1 tok2")); + emit(messageUpdate("tok1 tok2 tok3")); + emit(messageUpdate("tok1 tok2 tok3 tok4")); + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5")); + + vi.advanceTimersByTime(32); + expect(ui.requestRender).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + await flushMicrotasks(); + + expect(ui.requestRender).toHaveBeenCalledTimes(1); + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2 tok3 tok4 tok5" }, + ]); + + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5 tok6")); + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5 tok6 tok7")); + vi.advanceTimersByTime(33); + await flushMicrotasks(); + + expect(ui.requestRender).toHaveBeenCalledTimes(2); + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2 tok3 tok4 tok5 tok6 tok7" }, + ]); + }); + + it("flushes the pending snapshot before a subsequent non-update event", async () => { + const { ctx, emit } = createStreamingFixture(); + + emit(messageUpdate("tok1")); + emit(messageUpdate("tok1 tok2")); + emit({ type: "message_end", message: assistantMessage("tok1 tok2") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2" }, + ]); + }); + + it("speaks every delta exactly once even when intermediate snapshots are coalesced away", async () => { + const { emit } = createStreamingFixture(); + const pushDelta = vi.spyOn(vocalizer, "pushDelta"); + settings.set("speech.enabled", true); + settings.set("speech.mode", "assistant"); + + emit(messageUpdate("one ")); + emit(messageUpdate("one two ")); + emit(messageUpdate("one two three ")); + + vi.advanceTimersByTime(33); + await flushMicrotasks(); + + expect(pushDelta).toHaveBeenCalledTimes(3); + expect(pushDelta).toHaveBeenNthCalledWith(1, "one "); + expect(pushDelta).toHaveBeenNthCalledWith(2, "one two "); + expect(pushDelta).toHaveBeenNthCalledWith(3, "one two three "); + }); + + it("serializes a tail event behind an in-flight window flush", async () => { + // The coalesced flush fires from a 33ms timer, NOT from the listener + // path, so AgentSession's fire-and-forget dispatch cannot serialize it: + // a message_end landing mid-flush used to run its handler concurrently, + // both calling init while the flush was suspended. The dispatch chain + // must hold the tail event until the window flush completed. + const { ctx, emit } = createStreamingFixture(); + ctx.isInitialized = false; + const initGate = Promise.withResolvers(); + let initCalls = 0; + ctx.init = vi.fn(async () => { + initCalls += 1; + if (initCalls === 1) await initGate.promise; + }); + + emit(messageUpdate("tok1 tok2")); + vi.advanceTimersByTime(33); // window fires; flush suspends on init (call 1) + + emit({ type: "message_end", message: assistantMessage("tok1 tok2") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + await flushMicrotasks(); + + // The end handler must be queued behind the suspended flush, not + // running alongside it (which would double-init). + expect(initCalls).toBe(1); + initGate.resolve(); + await flushMicrotasks(); + + // Flush completed, then the end handler ran to completion. + expect(initCalls).toBe(2); + }); + + it("does not run two events queued in the same window concurrently", async () => { + // A burst that lands while a run is in flight must dispatch strictly + // one after the other: each waiter is chained onto the current tail, + // so two events sharing one suspended handler cannot both resume into + // parallel dispatch after the gate opens (regression: the shared + // `await this.#dispatchTail` let every queued callback start its own + // run once the tail settled). + const { ctx, emit } = createStreamingFixture(); + ctx.isInitialized = false; + const gates = [Promise.withResolvers(), Promise.withResolvers()]; + let initCalls = 0; + ctx.init = vi.fn(async () => { + initCalls += 1; + if (initCalls <= 2) await gates[initCalls - 1]!.promise; // first two runs each suspend on their own gate + }); + + emit(messageUpdate("tok1")); + vi.advanceTimersByTime(33); // window fires; flush run 1 suspends on gate 1 + + // Two non-update events land while the flush is still suspended. + emit({ type: "message_end", message: assistantMessage("tok1") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + emit({ type: "message_end", message: assistantMessage("tok1") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + await flushMicrotasks(); + + // Neither queued handler has started yet — both are chained behind + // the suspended flush. + expect(initCalls).toBe(1); + + // Release run 1: run 2 starts and suspends on gate 2; run 3 is queued. + gates[0]!.resolve(); + await flushMicrotasks(); + expect(initCalls).toBe(2); + + // Release run 2: run 3 finally runs to completion. + gates[1]!.resolve(); + await flushMicrotasks(); + expect(initCalls).toBe(3); + }); +}); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts new file mode 100644 index 000000000..010569014 --- /dev/null +++ b/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { convertToPng } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-pi-coding-agent-shim"; + +const RED_1X1_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +describe("legacy shim image conversion", () => { + it("preserves PNG attachments without re-encoding", async () => { + await expect(convertToPng(RED_1X1_PNG_BASE64, "image/png")).resolves.toEqual({ + data: RED_1X1_PNG_BASE64, + mimeType: "image/png", + }); + }); + + it("converts decodable image attachments to PNG", async () => { + const jpeg = await new Bun.Image(Buffer.from(RED_1X1_PNG_BASE64, "base64")).jpeg({ quality: 90 }).toBase64(); + const converted = await convertToPng(jpeg, "image/jpeg"); + + expect(converted?.mimeType).toBe("image/png"); + expect(Buffer.from(converted?.data ?? "", "base64").subarray(0, PNG_SIGNATURE.byteLength)).toEqual(PNG_SIGNATURE); + }); + + it("returns null when image decoding fails", async () => { + await expect(convertToPng("not-base64-image-data", "image/jpeg")).resolves.toBeNull(); + }); +}); diff --git a/packages/coding-agent/test/fixtures/browser-executable-probe.ts b/packages/coding-agent/test/fixtures/browser-executable-probe.ts new file mode 100644 index 000000000..d0f5641e7 --- /dev/null +++ b/packages/coding-agent/test/fixtures/browser-executable-probe.ts @@ -0,0 +1,7 @@ +import { ensureChromiumExecutable } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; + +const platform = process.env.OMP_BROWSER_PROBE_PLATFORM; +if (platform) Object.defineProperty(process, "platform", { value: platform }); + +const executable = await ensureChromiumExecutable(); +process.stdout.write(executable ?? ""); diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index 969f06fa6..a7415e3ed 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -338,6 +338,42 @@ describe("hindsightBackend first-turn injection", () => { expect(session.getHindsightSessionState()?.lastRecallSnippet).toBe(block); }); + it("does not let agent_start preempt first-turn recall injection", async () => { + const settings = Settings.isolated({ + "memory.backend": "hindsight", + "hindsight.apiUrl": "http://localhost:8888", + }); + const session = makeFakeSession({ + sessionId: "s-race", + entries: [{ role: "user", text: "What is the canary phrase?" }], + }); + await hindsightBackend.start({ + session: session as never, + settings, + modelRegistry: {} as never, + agentDir: "/tmp", + taskDepth: 0, + }); + + vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({ + results: [{ id: "1", text: "The canary phrase is PURPLE-OTTER-9931." }], + } as never); + + // The agent loop fires agent_start once the turn begins. This must NOT run + // its own recall: doing so consumed the shared first-turn flag and left + // injection to a racing background prompt rebuild that a fast turn outran, + // dropping recalled memory from the model's prompt (#7568). + session.emit({ type: "agent_start" }); + for (let i = 0; i < 50; i++) await Promise.resolve(); + + expect(session.getHindsightSessionState()?.hasRecalledForFirstTurn).toBe(false); + + // beforeAgentStartPrompt is the sole, awaited injection path. + const block = await hindsightBackend.beforeAgentStartPrompt?.(session as never, "What is the canary phrase?"); + expect(block).toContain("PURPLE-OTTER-9931"); + expect(session.getHindsightSessionState()?.hasRecalledForFirstTurn).toBe(true); + }); + it("keeps the wrapper in buildDeveloperInstructions", async () => { const settings = Settings.isolated({ "memory.backend": "hindsight", diff --git a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts index 6bd8f71c8..8fec71f8f 100644 --- a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts @@ -524,3 +524,62 @@ describe("MemoryProtocolHandler — mnemopi bridge (issue #4443)", () => { }); }); }); + +/** + * Register a live session simulating memory.backend=hindsight: it exposes a + * Hindsight state but no mnemopi state, so the handler must treat memory:// + * as unaddressable and return a corrective pointer (issue #7587). + */ +function withHindsightSession(fn: () => Promise): Promise { + const session = { + getHindsightSessionState: () => ({ bankId: "test-bank" }), + } as unknown as AgentSession; + AgentRegistry.global().register({ + id: "test-hindsight", + displayName: "test-hindsight", + kind: "main", + session, + sessionFile: null, + }); + return fn(); +} + +describe("MemoryProtocolHandler — hindsight (issue #7587)", () => { + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + afterEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + it("returns a corrective error for memory:// when hindsight is active", async () => { + await withHindsightSession(async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://a1b2c3d4e5f6")).rejects.toThrow( + /Hindsight memories are not addressable via memory:\/\/.*use `recall`.*`reflect`/s, + ); + }); + }); + + it("uses the calling session backend when hindsight and mnemopi sessions coexist", async () => { + await withMnemopiSession(async () => { + await withHindsightSession(async () => { + const router = InternalUrlRouter.instance(); + const settings = Settings.isolated({ "memory.backend": "hindsight" }); + await expect(router.resolve("memory://a1b2c3d4e5f6", { settings })).rejects.toThrow( + /Hindsight memories are not addressable via memory:\/\//, + ); + }); + }); + }); + + it("keeps the generic namespace error when no memory backend is active", async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://a1b2c3d4e5f6")).rejects.toThrow( + /Unknown memory namespace: a1b2c3d4e5f6\. Supported: root/, + ); + }); +}); diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index 2b740a3c7..a252926df 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -2172,6 +2172,80 @@ providers: expect(unknown?.reasoning).toBe(false); }); + test("openai-models-list discovery reads server-advertised input modalities for ids absent from the catalog", async () => { + writeRawModelsJson({ + "openai-test": { + baseUrl: "http://127.0.0.1:9996", + api: "openai-completions", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:9996/v1/models") { + // Custom virtual tier ids that are absent from the bundled + // catalog: their vision support can only come from the server row. + return new Response( + JSON.stringify({ + data: [ + { id: "high", object: "model", input: ["text", "image"] }, + { id: "leftover", object: "model", architecture: { input_modalities: ["text", "image"] } }, + { id: "synthetic-tier", object: "model", input_modalities: ["text", "image"] }, + { id: "low", object: "model", input: ["text"] }, + { id: "medium", object: "model" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + // Direct `input`, top-level `input_modalities`, and OpenRouter-style + // `architecture.input_modalities` all surface vision support. + expect(registry.find("openai-test", "high")?.input).toEqual(["text", "image"]); + expect(registry.find("openai-test", "leftover")?.input).toEqual(["text", "image"]); + expect(registry.find("openai-test", "synthetic-tier")?.input).toEqual(["text", "image"]); + // Server explicitly reports text-only; no image support invented. + expect(registry.find("openai-test", "low")?.input).toEqual(["text"]); + // Silent server → default text-only fallback. + expect(registry.find("openai-test", "medium")?.input).toEqual(["text"]); + }); + + test("lm-studio discovery keeps native VLM modalities over a thin OpenAI row", async () => { + writeRawModelsJson({ + "lm-studio-test": { + baseUrl: "http://127.0.0.1:9995", + api: "openai-completions", + auth: "none", + discovery: { type: "lm-studio" }, + }, + }); + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:9995/v1/models") { + return new Response(JSON.stringify({ data: [{ id: "local-vlm", object: "model", input: ["text"] }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:9995/api/v0/models") { + return new Response( + JSON.stringify({ + data: [{ id: "local-vlm", type: "vlm", capabilities: ["vision"], state: "loaded" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + expect(registry.find("lm-studio-test", "local-vlm")?.input).toEqual(["text", "image"]); + }); + test("proxy discovery honors API-reported context_length and endpoint routing", async () => { writeRawModelsJson({ "proxy-test": { diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 87f1f7247..591c3cb53 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1876,6 +1876,7 @@ describe("ModelRegistry", () => { let vertexStale: ModelRegistry; let litellmStaleNamespaceCache: ModelRegistry; let litellmCurrentNamespaceCache: ModelRegistry; + let openaiModelsListStaleNamespaceCache: ModelRegistry; const vertexProjectModel = () => buildModel({ id: "zai-org/glm-4.7-maas", @@ -2101,7 +2102,7 @@ describe("ModelRegistry", () => { { seedCache: dbPath => writeModelCache( - "cached-compact-proxy:openai-models-list-context-v2", + "cached-compact-proxy:openai-models-list-context-v3", Date.now(), [ buildModel({ @@ -2171,6 +2172,45 @@ describe("ModelRegistry", () => { dbPath, ), }); + openaiModelsListStaleNamespaceCache = readonlyRegistry( + { + providers: { + "stale-openai-proxy": { + baseUrl: "https://stale-proxy.example.com/v1", + apiKey: "TEST_KEY", + api: "openai-completions", + discovery: { type: "openai-models-list" }, + models: [], + }, + }, + }, + { + // Row under the retired pre-modality namespace; the context-v3 + // bump must orphan it instead of serving the stale text-only row. + seedCache: dbPath => + writeModelCache( + "stale-openai-proxy:openai-models-list-context-v2", + Date.now(), + [ + buildModel({ + id: "stale-vlm", + name: "Stale VLM", + api: "openai-completions", + provider: "stale-openai-proxy", + baseUrl: "https://stale-proxy.example.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }), + ], + true, + "", + dbPath, + ), + }, + ); }); test("legacy cached discovery sentinels are ignored after nullable limit cutover", () => { @@ -2223,6 +2263,13 @@ describe("ModelRegistry", () => { expect(model?.provider).toBe("litellm-proxy"); }); + test("ignores openai-models-list rows cached under the retired context-v2 namespace", () => { + // PR #7584 added server-advertised input-modality parsing; warm v2 rows + // pinned vision-capable ids at text-only and must not load. + expect(openaiModelsListStaleNamespaceCache.find("stale-openai-proxy", "stale-vlm")).toBeUndefined(); + expect(getModelsForProvider(openaiModelsListStaleNamespaceCache, "stale-openai-proxy")).toHaveLength(0); + }); + test("replaces bundled google-vertex models with authoritative Vertex project discovery", () => { const vertexModels = getModelsForProvider(vertexAuthoritative, "google-vertex"); expect(vertexModels.map(model => model.id)).toEqual(["zai-org/glm-4.7-maas"]); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 19f1d8d84..b64a0cc3e 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -10,9 +10,11 @@ import { parseModelString, pickDefaultAvailableModel, resolveAgentModelPatterns, + resolveAgentModelSource, resolveAgentPrewalkPattern, resolveAllowedModels, resolveCliModel, + resolveExplicitModelRole, resolveModelFromString, resolveModelOverride, resolveModelRoleValue, @@ -844,6 +846,43 @@ describe("resolveAgentPrewalkPattern", () => { }); }); describe("resolveAgentModelPatterns", () => { + test("selects the first non-empty source and skips aliases with no patterns", () => { + const settings = Settings.isolated({ + modelRoles: { + empty: "", + override: "openai/gpt-4o", + definition: "anthropic/claude-sonnet-4-5", + }, + }); + + const emptyRequest = { + requestModel: "", + settingsOverride: "@override", + agentModel: ["@definition"], + settings, + }; + expect(resolveAgentModelPatterns(emptyRequest)).toEqual(["openai/gpt-4o"]); + expect(resolveAgentModelSource(emptyRequest)).toBe("@override"); + + const emptyAlias = { + requestModel: "@empty", + settingsOverride: ",,", + agentModel: ["@definition"], + settings, + }; + expect(resolveAgentModelPatterns(emptyAlias)).toEqual(["anthropic/claude-sonnet-4-5"]); + expect(resolveAgentModelSource(emptyAlias)).toEqual(["@definition"]); + + const concreteRequest = { + requestModel: "openai/gpt-4o", + settingsOverride: "@override", + agentModel: ["@definition"], + settings, + }; + expect(resolveAgentModelSource(concreteRequest)).toBe("openai/gpt-4o"); + expect(resolveExplicitModelRole(resolveAgentModelSource(concreteRequest), settings)).toBeUndefined(); + }); + test("falls back to the active session model when @task is unset", () => { const settings = Settings.isolated({ modelRoles: { default: "anthropic/claude-sonnet-4-5" }, @@ -1699,6 +1738,29 @@ describe("resolveModelFromString", () => { }); }); +describe("resolveExplicitModelRole", () => { + test("extracts built-in, custom, legacy, default, and thinking-suffixed aliases before expansion", () => { + const settings = Settings.isolated({ + modelRoles: { + reviewer: "openai/gpt-4o", + }, + }); + + expect(resolveExplicitModelRole("@task", settings)).toBe("task"); + expect(resolveExplicitModelRole("pi/reviewer:high", settings)).toBe("reviewer"); + expect(resolveExplicitModelRole("@reviewer:xhigh", settings)).toBe("reviewer"); + expect(resolveExplicitModelRole("*:low", settings)).toBe("default"); + }); + + test("does not infer a role from an explicit model selector", () => { + const settings = Settings.isolated({ modelRoles: { reviewer: "openai/gpt-4o" } }); + expect(resolveExplicitModelRole("openai/gpt-4o", settings)).toBeUndefined(); + expect(resolveExplicitModelRole("openai/gpt-4o:high", settings)).toBeUndefined(); + expect(resolveExplicitModelRole("openai/gpt-4o:max", settings)).toBeUndefined(); + expect(resolveExplicitModelRole(["openai/gpt-4o", "@reviewer:high"], settings)).toBe("reviewer"); + }); +}); + describe("expandRoleAlias", () => { test("expands @vision to configured vision role", () => { const settings = Settings.isolated(); diff --git a/packages/coding-agent/test/modes/noninteractive-dispose.test.ts b/packages/coding-agent/test/modes/noninteractive-dispose.test.ts index 918ecb201..b6b5b12e1 100644 --- a/packages/coding-agent/test/modes/noninteractive-dispose.test.ts +++ b/packages/coding-agent/test/modes/noninteractive-dispose.test.ts @@ -40,6 +40,7 @@ describe("print-mode error exit disposes the session before exit", () => { state: { messages: [errorMsg] }, getLastAssistantMessage: () => errorMsg, prepareForHeadlessAdvisorDrain: () => {}, + setTextOutputCommitted: () => {}, waitForAdvisorCatchup: async () => { order.push("catchup"); return true; diff --git a/packages/coding-agent/test/pi-scope-aliases.test.ts b/packages/coding-agent/test/pi-scope-aliases.test.ts index 2449fb8b5..b7e3b7aba 100644 --- a/packages/coding-agent/test/pi-scope-aliases.test.ts +++ b/packages/coding-agent/test/pi-scope-aliases.test.ts @@ -8,7 +8,6 @@ * * Reported failures the test covers: * - `@juicesharp/rpiv-ask-user-question` ⇒ `@earendil-works/pi-tui` - * - `@oh-my-pi/swarm-extension` ⇒ `@oh-my-pi/pi-utils` * - `@plannotator/pi-extension` ⇒ `@oh-my-pi/pi-agent-core` * - `@runfusion/fusion` ⇒ `@oh-my-pi/pi-coding-agent/...` * diff --git a/packages/coding-agent/test/print-mode-json-flush.test.ts b/packages/coding-agent/test/print-mode-json-flush.test.ts new file mode 100644 index 000000000..0e5d4d398 --- /dev/null +++ b/packages/coding-agent/test/print-mode-json-flush.test.ts @@ -0,0 +1,150 @@ +/** + * Regression (#7635): `--mode json` must not exit until the final record's + * stdout write has fully drained. + * + * The JSON path emitted each event with a fire-and-forget `process.stdout.write` + * and relied on an empty-write "flush barrier" before dispose/exit. The barrier + * awaited its own callback, not the preceding large write, so a big final + * `agent_end` (multi-MB) could be truncated when the process exited before the + * pipe drained — while still exiting 0. The fix serializes every print-mode + * stdout write on its own completion callback and blocks shutdown on the tail. + * + * Contract: `runPrintMode` stays pending until the final record's write callback + * fires (so `process.exit` can't discard it), and the full record is delivered. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { runPrintMode } from "@oh-my-pi/pi-coding-agent/modes/print-mode"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; + +interface FlushHarness { + session: AgentSession; + promptStarted: Promise; + resolvePrompt: () => void; + emit: (event: AgentSessionEvent) => void; + disposed: () => boolean; +} + +function createFlushHarness(): FlushHarness { + const { promise: promptStarted, resolve: markPromptStarted } = Promise.withResolvers(); + const { promise: promptReleased, resolve: resolvePrompt } = Promise.withResolvers(); + let subscriber: ((event: AgentSessionEvent) => void) | undefined; + let disposed = false; + let advisorDrainPrepared = false; + + const session = { + sessionManager: { + getHeader: () => undefined, + buildSessionContext: () => ({ messages: [] }), + getEntries: () => [], + }, + settings: { get: () => false }, + extensionRunner: undefined, + subscribe: (listener: (event: AgentSessionEvent) => void) => { + subscriber = listener; + return () => {}; + }, + prompt: async () => { + markPromptStarted(); + await promptReleased; + return true; + }, + prepareForHeadlessAdvisorDrain: () => { + advisorDrainPrepared = true; + }, + waitForAdvisorCatchup: async () => { + if (!advisorDrainPrepared) throw new Error("advisor catch-up started before headless delivery was armed"); + }, + dispose: async () => { + disposed = true; + }, + } as unknown as AgentSession; + + return { + session, + promptStarted, + resolvePrompt, + emit: event => subscriber?.(event), + disposed: () => disposed, + }; +} + +function makeLargeAgentEnd(payload: string): AgentSessionEvent { + return { + type: "agent_end", + messages: [ + { + role: "assistant", + content: [{ type: "text", text: payload }], + stopReason: "aborted", + errorMessage: "Deadline exceeded", + timestamp: Date.now(), + }, + ], + } as unknown as AgentSessionEvent; +} + +describe("print-mode JSON flush (#7635)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("blocks exit until the final agent_end write drains, then delivers it in full", async () => { + const writes: string[] = []; + let releaseAgentEnd: (() => void) | undefined; + const { promise: agentEndWriteIssued, resolve: markAgentEndWriteIssued } = Promise.withResolvers(); + vi.spyOn(process.stdout, "write").mockImplementation((...args: unknown[]) => { + const chunk = args[0]; + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString(); + writes.push(text); + const cb = args[args.length - 1]; + const invoke = typeof cb === "function" ? (cb as (err?: Error | null) => void) : undefined; + // Defer the large agent_end record's completion callback to emulate a + // backpressured pipe; every other write completes synchronously. + if (text.includes('"type":"agent_end"')) { + releaseAgentEnd = () => invoke?.(null); + markAgentEndWriteIssued(); + } else { + invoke?.(null); + } + return true; + }); + + const payload = "x".repeat(1_500_000); + const harness = createFlushHarness(); + + const run = runPrintMode(harness.session, { mode: "json", initialMessage: "hello" }); + let settled = false; + void run.then(() => { + settled = true; + }); + + await harness.promptStarted; + harness.emit(makeLargeAgentEnd(payload)); + harness.resolvePrompt(); + + // Drain to quiescence: every step runPrintMode can complete without the + // deferred write is microtask-driven, so one macrotask boundary flushes + // them all. The pre-fix fire-and-forget path settles and disposes here; + // the fix must still be blocked on the undrained agent_end write. + await agentEndWriteIssued; + const { promise: nextTask, resolve: resolveNextTask } = Promise.withResolvers(); + setImmediate(resolveNextTask); + await nextTask; + expect(releaseAgentEnd).toBeDefined(); + expect(settled).toBe(false); + expect(harness.disposed()).toBe(false); + + releaseAgentEnd?.(); + await run; + + expect(settled).toBe(true); + expect(harness.disposed()).toBe(true); + + const agentEndLine = writes.find(line => line.includes('"type":"agent_end"')); + expect(agentEndLine).toBeDefined(); + expect(agentEndLine?.endsWith("\n")).toBe(true); + // The complete payload survives — not a pipe-buffer-sized prefix. + expect(agentEndLine).toContain(payload); + expect(JSON.parse(agentEndLine as string)).toMatchObject({ type: "agent_end" }); + }); +}); diff --git a/packages/coding-agent/test/print-mode-working-indicator.test.ts b/packages/coding-agent/test/print-mode-working-indicator.test.ts index fc86976f4..3db6b93ef 100644 --- a/packages/coding-agent/test/print-mode-working-indicator.test.ts +++ b/packages/coding-agent/test/print-mode-working-indicator.test.ts @@ -36,6 +36,7 @@ interface DelayedSession { promptStarted: Promise; resolvePrompt: () => void; getPlanModeAtPrompt: () => PlanModeState | undefined; + getTextOutputCommitted: () => boolean; getModeChanges: () => Array<{ mode: string; data?: Record }>; getPlanProposalHandler: () => PlanProposalHandler | undefined; getCurrentPlanMode: () => PlanModeState | undefined; @@ -57,6 +58,7 @@ function createDelayedSession( const modeChanges: Array<{ mode: string; data?: Record }> = []; let planProposalHandler: PlanProposalHandler | undefined; let subscriber: ((event: AgentSessionEvent) => void) | undefined; + let textOutputCommitted = true; let abortCalls = 0; const session = { @@ -105,6 +107,9 @@ function createDelayedSession( abort: async () => { abortCalls++; }, + setTextOutputCommitted: (committed: boolean) => { + textOutputCommitted = committed; + }, subscribe: (listener: (event: AgentSessionEvent) => void) => { subscriber = listener; return () => {}; @@ -133,6 +138,7 @@ function createDelayedSession( getPlanModeAtPrompt: () => planModeAtPrompt, getModeChanges: () => modeChanges, getPlanProposalHandler: () => planProposalHandler, + getTextOutputCommitted: () => textOutputCommitted, getCurrentPlanMode: () => planModeState, emit: event => subscriber?.(event), getAbortCalls: () => abortCalls, @@ -226,12 +232,14 @@ describe("print mode working indicator", () => { try { expect(stderrOutput.join("")).toContain("Working"); expect(stdoutOutput.join("")).toBe(""); + expect(delayed.getTextOutputCommitted()).toBe(false); } finally { delayed.resolvePrompt(); await run; } expect(stdoutOutput.join("")).toBe("final answer\n"); + expect(delayed.getTextOutputCommitted()).toBe(true); }); it("does not write the text-mode working indicator in JSON mode while the prompt is pending", async () => { @@ -241,6 +249,7 @@ describe("print mode working indicator", () => { await delayed.promptStarted; try { expect(stderrOutput.join("")).toBe(""); + expect(delayed.getTextOutputCommitted()).toBe(true); } finally { delayed.resolvePrompt(); await run; @@ -351,6 +360,7 @@ describe("print mode working indicator", () => { messages.push(message); return true; }, + setTextOutputCommitted: () => {}, prepareForHeadlessAdvisorDrain: () => {}, waitForAdvisorCatchup: async (timeoutMs: number) => { catchupTimeoutMs = timeoutMs; diff --git a/packages/coding-agent/test/registry/agent-lifecycle.test.ts b/packages/coding-agent/test/registry/agent-lifecycle.test.ts index 6c3356f66..9f61c6de4 100644 --- a/packages/coding-agent/test/registry/agent-lifecycle.test.ts +++ b/packages/coding-agent/test/registry/agent-lifecycle.test.ts @@ -314,6 +314,23 @@ describe("AgentLifecycleManager", () => { expect(registry.get("6-Sub")).toBeUndefined(); }); + it("does not let one stuck adopted agent block sibling disposal", async () => { + const gate = deferred(); + const stuck = makeSessionStub(() => gate.promise); + const sibling = makeSessionStub(); + registerIdleSub("stuck-Sub", stuck.session); + registerIdleSub("sibling-Sub", sibling.session); + lifecycle.adopt("stuck-Sub", { idleTtlMs: TTL }); + lifecycle.adopt("sibling-Sub", { idleTtlMs: TTL }); + + await lifecycle.dispose(Date.now()); + + expect(stuck.disposeCalls()).toBe(1); + expect(sibling.disposeCalls()).toBe(1); + gate.resolve(); + await flushAsync(); + }); + it("a delayed release cannot remove or mutate a replacement ref with the same id", async () => { const gate = deferred(); const oldSession = makeSessionStub(() => gate.promise); @@ -597,10 +614,11 @@ describe("AgentLifecycleManager", () => { // session (the ref carries session === null), it treats it as unrevivable. await expect(lifecycle.ensureLive(workerId)).rejects.toThrow(/aborted/); - // Reopening the Agent Hub rescans on-disk transcripts. The surviving - // `.jsonl` must not be re-adopted as a fresh `parked` row, because the - // id is still present in the registry. - await registerPersistedSubagents(registry, rootSessionFile); - expect(registry.get(workerId)?.status).toBe("aborted"); + // Reopening after the original registry is gone must preserve the terminal + // decision from the sidecar, not infer a fresh parked agent from the JSONL. + expect(await Bun.file(`${workerSessionFile}.tombstone`).exists()).toBe(true); + const restoredRegistry = new AgentRegistry(); + await registerPersistedSubagents(restoredRegistry, rootSessionFile); + expect(restoredRegistry.get(workerId)?.status).toBe("aborted"); }); }); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 87ea06ffd..14f8fa2ab 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -512,6 +512,33 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + test("rejects a depleted terminal fallback after startup skips the primary", async () => { + const settings = Settings.isolated({ + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "confirm", + }); + settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); + const options = await buildSessionOptions("task"); + const usageHealth = vi.spyOn(options.authStorage, "getModelUsageHealth").mockResolvedValue({ + state: "depleted", + accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }], + }); + + const { session, modelFallbackMessage } = await createAgentSession({ + ...options, + modelPatternFallbackRole: "subagent:usage-aware-terminal", + settings, + hasUI: false, + }); + try { + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(session.model).toBeUndefined(); + expect(modelFallbackMessage).toContain("not found"); + } finally { + await session.dispose(); + } + }); + test("defers ACP reserve fallback until prompt-time capabilities are configured", async () => { const settings = Settings.isolated({ "retry.usageAwareFallback": true, diff --git a/packages/coding-agent/test/session-loader-stream.test.ts b/packages/coding-agent/test/session-loader-stream.test.ts index 61484c5d8..a0900f904 100644 --- a/packages/coding-agent/test/session-loader-stream.test.ts +++ b/packages/coding-agent/test/session-loader-stream.test.ts @@ -86,7 +86,9 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => { ].join("\n"); const file = await writeTemp(content); const visited: FileEntry[] = []; - const titleSlot = await sessionLoader.visitEntriesFromFileStream(file, entry => visited.push(entry)); + const titleSlot = await sessionLoader.visitEntriesFromFileStream(file, entry => { + visited.push(entry); + }); expect(titleSlot?.title).toBe("Visitor"); expect(entryIds(visited)).toEqual(["s1", "m1", "m2"]); @@ -101,11 +103,35 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => { const file = await writeTemp(content); const visited: FileEntry[] = []; - await sessionLoader.visitEntriesFromFileStream(file, entry => visited.push(entry)); + await sessionLoader.visitEntriesFromFileStream(file, entry => { + visited.push(entry); + }); expect(entryIds(visited)).toEqual(["s1", "m1", "m2"]); }); + it("bounds visitor scans by physical records, including malformed lines", async () => { + const content = [ + JSON.stringify(HEADER), + "{ malformed one", + "{ malformed two", + "{ malformed three", + JSON.stringify(msg("after-bad", "s1", "must not be visited")), + ].join("\n"); + const file = await writeTemp(content); + const visited: FileEntry[] = []; + + await sessionLoader.visitEntriesFromFileStream( + file, + entry => { + visited.push(entry); + }, + { maxRecords: 2 }, + ); + + expect(entryIds(visited)).toEqual(["s1"]); + }); + it("propagates ENOENT errors thrown by the visitor", async () => { const file = await writeTemp(`${JSON.stringify(HEADER)}\n`); const failure = Object.assign(new Error("visitor failed"), { code: "ENOENT" }); diff --git a/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts b/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts index a82c94e9e..6a4d5729b 100644 --- a/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts +++ b/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts @@ -27,7 +27,10 @@ class DetachingRewriteStorage extends MemorySessionStorage { guardRejections = 0; readonly #writers = new Set(); - openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter { + override openWriter( + path: string, + options?: { flags?: "a" | "w"; onError?: (err: Error) => void }, + ): SessionStorageWriter { const inner = super.openWriter(path, options); const writers = this.#writers; const detachedLines = this.detachedLines; diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index e5fc9ebc2..7c3abe61f 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -6,14 +6,7 @@ import type { FileEntry, SessionHeader } from "@oh-my-pi/pi-coding-agent/session import { findMostRecentSession, resolveResumableSession } from "@oh-my-pi/pi-coding-agent/session/session-listing"; import { loadEntriesFromFile } from "@oh-my-pi/pi-coding-agent/session/session-loader"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { - getConfigRootDir, - getSessionsDir, - removeSyncWithRetries, - resolveEquivalentPath, - Snowflake, - setAgentDir, -} from "@oh-my-pi/pi-utils"; +import { getConfigRootDir, getSessionsDir, removeSyncWithRetries, Snowflake, setAgentDir } from "@oh-my-pi/pi-utils"; describe("loadEntriesFromFile", () => { let tempDir: string; @@ -170,9 +163,7 @@ describe("SessionManager temp cwd session dirs", () => { const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); function expectedTempSessionDirName(tempCwd: string): string { - const normalized = resolveEquivalentPath(tempCwd).replaceAll("\\", "/"); - const digest = Bun.SHA256.hash(normalized, "hex"); - return `tmp-${path.basename(tempCwd)}-${digest}`; + return `-tmp-${path.relative(os.tmpdir(), path.resolve(tempCwd)).replace(/[/\\:]/g, "-")}`; } function toLegacyAbsoluteSessionDirName(cwd: string): string { @@ -197,7 +188,7 @@ describe("SessionManager temp cwd session dirs", () => { removeSyncWithRetries(testAgentDir); }); - it("stores temp-root cwd sessions under safe hashed directories", () => { + it("stores temp-root cwd sessions under -tmp-prefixed directories", () => { const tempCwd = path.join(testAgentDir, `temp-cwd-${Snowflake.next()}`); fs.mkdirSync(tempCwd, { recursive: true }); @@ -208,7 +199,7 @@ describe("SessionManager temp cwd session dirs", () => { expect(path.dirname(sessionFile)).toBe(path.join(getSessionsDir(), expectedTempSessionDirName(tempCwd))); }); - it("migrates legacy temp-root absolute session dirs to safe names", () => { + it("migrates legacy temp-root absolute session dirs to -tmp prefixes", () => { const tempCwd = path.join(testAgentDir, `legacy-cwd-${Snowflake.next()}`); fs.mkdirSync(tempCwd, { recursive: true }); @@ -226,41 +217,6 @@ describe("SessionManager temp cwd session dirs", () => { expect(path.dirname(sessionFile)).toBe(expectedDir); expect(fs.existsSync(path.join(expectedDir, "carried.jsonl"))).toBe(true); }); - - it("separates colliding legacy cwd buckets into safe directories", () => { - const firstCwd = path.join(testAgentDir, "project", "hail-mary"); - const secondCwd = path.join(testAgentDir, "project-hail-mary"); - fs.mkdirSync(firstCwd, { recursive: true }); - fs.mkdirSync(secondCwd, { recursive: true }); - - const legacyName = `-tmp-${path.relative(os.tmpdir(), firstCwd).replace(/[/\\:]/g, "-")}`; - expect(legacyName).toBe(`-tmp-${path.relative(os.tmpdir(), secondCwd).replace(/[/\\:]/g, "-")}`); - const legacyDir = path.join(getSessionsDir(), legacyName); - fs.mkdirSync(path.join(legacyDir, "first"), { recursive: true }); - fs.mkdirSync(path.join(legacyDir, "second"), { recursive: true }); - fs.writeFileSync( - path.join(legacyDir, "first.jsonl"), - `${JSON.stringify({ type: "session", id: "first", cwd: firstCwd })}\n`, - ); - fs.writeFileSync( - path.join(legacyDir, "second.jsonl"), - `${JSON.stringify({ type: "session", id: "second", cwd: secondCwd })}\n`, - ); - fs.writeFileSync(path.join(legacyDir, "first", "artifact.txt"), "first"); - fs.writeFileSync(path.join(legacyDir, "second", "artifact.txt"), "second"); - - const firstDir = path.dirname(SessionManager.create(firstCwd).getSessionFile()!); - const secondDir = path.dirname(SessionManager.create(secondCwd).getSessionFile()!); - - expect(firstDir).not.toBe(secondDir); - expect(path.basename(firstDir).startsWith("-")).toBe(false); - expect(path.basename(secondDir).startsWith("-")).toBe(false); - expect(fs.existsSync(path.join(firstDir, "first.jsonl"))).toBe(true); - expect(fs.existsSync(path.join(firstDir, "first", "artifact.txt"))).toBe(true); - expect(fs.existsSync(path.join(secondDir, "second.jsonl"))).toBe(true); - expect(fs.existsSync(path.join(secondDir, "second", "artifact.txt"))).toBe(true); - expect(fs.existsSync(legacyDir)).toBe(false); - }); }); describe("SessionManager legacy session migration persistence", () => { diff --git a/packages/coding-agent/test/session-manager/large-session-memory.test.ts b/packages/coding-agent/test/session-manager/large-session-memory.test.ts index 591187a2a..3d5df8523 100644 --- a/packages/coding-agent/test/session-manager/large-session-memory.test.ts +++ b/packages/coding-agent/test/session-manager/large-session-memory.test.ts @@ -12,7 +12,7 @@ import * as snapcompact from "@oh-my-pi/snapcompact"; class CountingMemorySessionStorage extends MemorySessionStorage { writeTextSyncCalls = 0; - writeTextSync(filePath: string, content: string): void { + override writeTextSync(filePath: string, content: string): void { this.writeTextSyncCalls++; super.writeTextSync(filePath, content); } diff --git a/packages/coding-agent/test/session-paths.test.ts b/packages/coding-agent/test/session-paths.test.ts new file mode 100644 index 000000000..f437f5851 --- /dev/null +++ b/packages/coding-agent/test/session-paths.test.ts @@ -0,0 +1,67 @@ +import { afterEach, 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 { computeDefaultSessionDir } from "@oh-my-pi/pi-coding-agent/session/session-paths"; +import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; + +const cleanup: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanup.push(dir); + return dir; +} + +function legacySessionDir(sessionsRoot: string, cwd: string): string { + const name = `--${path + .resolve(cwd) + .replace(/^[/\\]/, "") + .replace(/[/\\:]/g, "-")}--`; + return path.join(sessionsRoot, name); +} + +afterEach(() => { + for (const dir of cleanup.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("legacy session directory migration", () => { + test("keeps a colliding live legacy session reachable through its path", () => { + const sessionsRoot = makeTempDir("omp-session-root-"); + const cwd = makeTempDir("omp-session-cwd-"); + const storage = new FileSessionStorage(); + const canonicalDir = computeDefaultSessionDir(cwd, storage, sessionsRoot); + const legacyDir = legacySessionDir(sessionsRoot, cwd); + const source = path.join(legacyDir, "active.jsonl"); + const destination = path.join(canonicalDir, "active.jsonl"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(source, "live-before\n"); + fs.writeFileSync(destination, "stale\n"); + const fd = fs.openSync(source, "a"); + + computeDefaultSessionDir(cwd, storage, sessionsRoot); + fs.writeSync(fd, "live-after\n"); + fs.closeSync(fd); + + expect(fs.readFileSync(source, "utf8")).toBe("live-before\nlive-after\n"); + expect(fs.readFileSync(destination, "utf8")).toBe("stale\n"); + }); + + test("preserves writes when an older process recreates its cached legacy directory", () => { + const sessionsRoot = makeTempDir("omp-session-root-"); + const cwd = makeTempDir("omp-session-cwd-"); + const storage = new FileSessionStorage(); + const canonicalDir = computeDefaultSessionDir(cwd, storage, sessionsRoot); + const legacyDir = legacySessionDir(sessionsRoot, cwd); + const destination = path.join(canonicalDir, "active.jsonl"); + fs.writeFileSync(destination, "canonical\n"); + + fs.mkdirSync(legacyDir, { recursive: true }); + const recreated = path.join(legacyDir, "active.jsonl"); + fs.writeFileSync(recreated, "older-process-write\n"); + computeDefaultSessionDir(cwd, storage, sessionsRoot); + + expect(fs.readFileSync(recreated, "utf8")).toBe("older-process-write\n"); + expect(fs.readFileSync(destination, "utf8")).toBe("canonical\n"); + }); +}); diff --git a/packages/coding-agent/test/settings-stream-fn.test.ts b/packages/coding-agent/test/settings-stream-fn.test.ts index 424889447..44be30735 100644 --- a/packages/coding-agent/test/settings-stream-fn.test.ts +++ b/packages/coding-agent/test/settings-stream-fn.test.ts @@ -81,7 +81,17 @@ describe("createSettingsAwareStreamFn", () => { expect(calls[0]?.options?.hideThinkingSummary).toBe(true); }); - it("applies Responses-family text verbosity from settings while preserving caller overrides", () => { + it("applies Codex text verbosity only when settings or caller options configure it", () => { + const unconfiguredSettings = Settings.isolated({}); + const { fn: unconfiguredBase, calls: unconfiguredCalls } = captureBase(); + const unconfiguredWrapped = createSettingsAwareStreamFn(unconfiguredSettings, unconfiguredBase); + + unconfiguredWrapped(stubCodexModel, stubContext, undefined); + unconfiguredWrapped(stubCodexModel, stubContext, { textVerbosity: "medium" }); + + expect(unconfiguredCalls[0]?.options?.textVerbosity).toBeUndefined(); + expect(unconfiguredCalls[1]?.options?.textVerbosity).toBe("medium"); + const settings = Settings.isolated({ textVerbosity: "low" }); const { fn: base, calls } = captureBase(); const wrapped = createSettingsAwareStreamFn(settings, base); diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index 7ef4de7ee..e07706b23 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -33,6 +33,7 @@ function makeJsonlSessionFile(dirPath: string, entries: object[]): string { function makeSubagentRegistry(sessions: ObservableSession[]) { return { getSessions: () => sessions, + getSession: (id: string) => sessions.find(session => session.id === id), onChange: () => () => {}, setMainSession: () => {}, getActiveSubagentCount: () => sessions.filter(s => s.status === "active").length, diff --git a/packages/coding-agent/test/silent-abort-print-mode.test.ts b/packages/coding-agent/test/silent-abort-print-mode.test.ts index 32cae2d3f..2240f078b 100644 --- a/packages/coding-agent/test/silent-abort-print-mode.test.ts +++ b/packages/coding-agent/test/silent-abort-print-mode.test.ts @@ -55,6 +55,7 @@ function createMockSession( subscribe: () => () => {}, prompt: async () => {}, prepareForHeadlessAdvisorDrain: () => {}, + setTextOutputCommitted: () => {}, waitForAdvisorCatchup: async () => true, dispose, } as unknown as AgentSession; diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 5b8c43e6b..148615b79 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { type Skill as CapabilitySkill, skillCapability } from "@oh-my-pi/pi-coding-agent/capability/skill"; import { getCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { getWslWindowsHomeCandidate } from "@oh-my-pi/pi-coding-agent/discovery/agents"; import { loadSkills, loadSkillsFromDir, @@ -234,6 +235,69 @@ describe("skills", () => { } }); + it("should load Windows host ~/.agents/skills when running under WSL (#3779)", async () => { + const tempHostHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-wsl-host-")); + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-wsl-cwd-")); + const skillDir = path.join(tempHostHome, ".agents", "skills", "wsl-host-skill"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + ["---", "description: Loaded from WSL host USERPROFILE", "---", "", "# wsl-host-skill"].join("\n"), + ); + const previousWslDistroName = process.env.WSL_DISTRO_NAME; + const previousWslInterop = process.env.WSL_INTEROP; + const previousUserProfile = process.env.USERPROFILE; + const previousPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "linux" }); + process.env.WSL_DISTRO_NAME = "Ubuntu"; + delete process.env.WSL_INTEROP; + process.env.USERPROFILE = tempHostHome; + try { + const { skills } = await loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + cwd: tempCwd, + }); + const skill = skills.find(s => s.name === "wsl-host-skill"); + expect(skill?.source).toBe("agents:user"); + expect(skill?.filePath).toBe(path.join(skillDir, "SKILL.md")); + } finally { + if (previousWslDistroName === undefined) delete process.env.WSL_DISTRO_NAME; + else process.env.WSL_DISTRO_NAME = previousWslDistroName; + if (previousWslInterop === undefined) delete process.env.WSL_INTEROP; + else process.env.WSL_INTEROP = previousWslInterop; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + Object.defineProperty(process, "platform", { value: previousPlatform }); + await removeWithRetries(tempHostHome); + await removeWithRetries(tempCwd); + } + }); + + it("converts Windows USERPROFILE paths to the default WSL mount (#3779)", () => { + const resolved = getWslWindowsHomeCandidate({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu", USERPROFILE: "C:\\Users\\alice" }, + wslPath: () => undefined, + }); + + expect(resolved).toBe(path.join("/mnt", "c", "Users", "alice")); + }); + + it("resolves the Windows profile through interop when USERPROFILE is not exported (#3779)", () => { + const resolved = getWslWindowsHomeCandidate({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + windowsUserProfile: () => "C:\\Users\\alice", + wslPath: () => "/mnt/c/Users/alice", + }); + + expect(resolved).toBe("/mnt/c/Users/alice"); + }); + it("respects an explicit enableAgentsUser: false (#2401)", async () => { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-home-off-")); const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-cwd-off-")); 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 69e8e7dc8..ca95f5f5f 100644 --- a/packages/coding-agent/test/task/executor-async-quiescence.test.ts +++ b/packages/coding-agent/test/task/executor-async-quiescence.test.ts @@ -8,6 +8,7 @@ */ import { afterEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; @@ -18,7 +19,7 @@ import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; const baseAgent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; -function assistantStopMessage(text: string): AssistantMessage { +function assistantStopMessage(text: string, totalTokens = 0): AssistantMessage { return { role: "assistant", content: [{ type: "text", text }], @@ -27,10 +28,10 @@ function assistantStopMessage(text: string): AssistantMessage { model: "mock", usage: { input: 0, - output: 0, + output: totalTokens, cacheRead: 0, cacheWrite: 0, - totalTokens: 0, + totalTokens, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", @@ -44,9 +45,15 @@ interface AsyncQuiescenceHarness { abortCalls: () => number; settleCalls: () => number; emitTerminalYield: (data: unknown) => void; + emitAssistant: (text: string, totalTokens?: number) => void; finishJob: () => void; } +interface AsyncSessionOptions { + abort?: () => Promise; + dispose?: () => Promise; +} + /** * Mock session with the owner-async surface the barrier drives: * `hasPendingAsyncWork` / `getAsyncJobSnapshot` / `settleAsyncWork`. The job @@ -56,6 +63,7 @@ interface AsyncQuiescenceHarness { */ function createAsyncSession( onPrompt: (params: { text: string; promptIndex: number; harness: AsyncQuiescenceHarness }) => void, + options: AsyncSessionOptions = {}, ): AsyncQuiescenceHarness { const listeners: Array<(event: AgentSessionEvent) => void> = []; const state = { messages: [] as AssistantMessage[] }; @@ -103,6 +111,11 @@ function createAsyncSession( state.messages.push(reaction); emit({ type: "message_end", message: reaction } as AgentSessionEvent); }; + const emitAssistant = (text: string, totalTokens = 0) => { + const message = assistantStopMessage(text, totalTokens); + state.messages.push(message); + emit({ type: "message_end", message } as AgentSessionEvent); + }; const harness: AsyncQuiescenceHarness = { session: undefined as unknown as AgentSession, @@ -110,6 +123,7 @@ function createAsyncSession( abortCalls: () => abortCount, settleCalls: () => settleCount, emitTerminalYield, + emitAssistant, finishJob, }; @@ -143,8 +157,9 @@ function createAsyncSession( }, abort: async () => { abortCount += 1; + await options.abort?.(); }, - dispose: async () => {}, + dispose: options.dispose ?? (async () => {}), setIrcWakeTurnObserver: () => {}, }; harness.session = session as unknown as AgentSession; @@ -163,6 +178,7 @@ function mockCreateAgentSession(session: AgentSession) { describe("runSubprocess async quiescence fresh-yield contract", () => { afterEach(() => { vi.restoreAllMocks(); + AsyncJobManager.resetForTests(); }); it("parks a pending yield, injects the result, and completes on the fresh yield", async () => { @@ -250,4 +266,125 @@ describe("runSubprocess async quiescence fresh-yield contract", () => { expect(result.exitCode).toBe(0); expect(result.output).toContain("done"); }); + + it("does not wait on a second idle barrier after a terminal yield", async () => { + const harness = createAsyncSession(({ promptIndex, harness: h }) => { + if (promptIndex === 1) { + h.finishJob(); + h.emitTerminalYield({ report: "done" }); + } + }); + const idleStarted = Promise.withResolvers(); + const releaseIdle = Promise.withResolvers(); + let idleCalls = 0; + harness.session.waitForIdle = async () => { + idleCalls += 1; + idleStarted.resolve(); + await releaseIdle.promise; + }; + mockCreateAgentSession(harness.session); + + const run = runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "quiescence-no-second-idle", + }); + const outcome = await Promise.race([ + run.then(() => "completed" as const), + idleStarted.promise.then(() => "blocked" as const), + ]); + releaseIdle.resolve(); + const result = await run; + + expect(outcome).toBe("completed"); + expect(idleCalls).toBe(0); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("done"); + }); + + it("returns an aborted result after cleanup grace and waits for every late resource", async () => { + const abortStarted = Promise.withResolvers(); + const abortGate = Promise.withResolvers(); + const disposeGate = Promise.withResolvers(); + const lateJobGate = Promise.withResolvers(); + const manager = new AsyncJobManager({}); + AsyncJobManager.setInstance(manager); + let lateJobId: string | undefined; + let deferredCleanup: Promise | undefined; + const harness = createAsyncSession( + ({ promptIndex, harness: h }) => { + if (promptIndex !== 1) return; + h.finishJob(); + h.emitAssistant("captured before cleanup", 7); + h.emitTerminalYield({ report: "yielded output" }); + }, + { + abort: async () => { + abortStarted.resolve(); + await abortGate.promise; + }, + dispose: async () => { + lateJobId = manager.register( + "task", + "shutdown-time job", + async () => { + await lateJobGate.promise; + return "late result"; + }, + { ownerId: "cleanup-timeout" }, + ); + await disposeGate.promise; + }, + }, + ); + mockCreateAgentSession(harness.session); + + const run = runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "cleanup-timeout", + keepAlive: false, + onCleanupDeferred: completion => { + deferredCleanup = completion; + }, + }); + await abortStarted.promise; + + const result = await run; + expect(result.exitCode).toBe(1); + expect(result.aborted).toBe(true); + expect(result.abortReason).toBe("cleanup exceeded 10000 ms"); + expect(result.error).toBe( + "Task aborted. Cleanup did not finish within 10000 ms. This task was not isolated, so its changes may remain in the working directory.", + ); + expect(result.output).toContain("yielded output"); + expect(result.usage?.totalTokens).toBe(7); + expect(lateJobId).toBeDefined(); + expect(deferredCleanup).toBeDefined(); + + let cleanupSettled = false; + const cleanupOutcome = deferredCleanup?.then( + () => { + cleanupSettled = true; + }, + () => { + cleanupSettled = true; + }, + ); + abortGate.resolve(); + disposeGate.reject(new Error("dispose failed")); + for (let attempt = 0; attempt < 10 && manager.getJob(lateJobId ?? "")?.status === "running"; attempt += 1) { + await Promise.resolve(); + } + expect(cleanupSettled).toBe(false); + expect(manager.getJob(lateJobId ?? "")?.status).toBe("cancelled"); + + lateJobGate.resolve(); + await cleanupOutcome; + expect(cleanupSettled).toBe(true); + }, 15_000); }); 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 f1cddae10..51aba195c 100644 --- a/packages/coding-agent/test/task/executor-pass-through.test.ts +++ b/packages/coding-agent/test/task/executor-pass-through.test.ts @@ -365,4 +365,25 @@ describe("runSubprocess parent-discovery pass-through (issue #2190)", () => { const forwarded = spy.mock.calls[0]?.[0]; expect(forwarded?.thinkingLevel).toBe(ThinkingLevel.Low); }); + it("persists an explicit role from a caller model override", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); + const settings = Settings.isolated({ + modelRoles: { reviewer: `${model.provider}/${model.id}` }, + }); + const session = yieldEmittingSession(); + const initSpy = vi.spyOn(session.sessionManager, "appendSessionInit"); + vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session)); + + const result = await runSubprocess({ + ...baseOptions, + id: "subagent-model-override-role", + modelOverride: "@reviewer", + settings, + modelRegistry: createModelRegistry(model), + }); + + expect(result.exitCode).toBe(0); + expect(initSpy).toHaveBeenCalledWith(expect.objectContaining({ modelRole: "reviewer" })); + }); }); diff --git a/packages/coding-agent/test/task/isolation-runner.test.ts b/packages/coding-agent/test/task/isolation-runner.test.ts index 6a9a3c242..591d396ec 100644 --- a/packages/coding-agent/test/task/isolation-runner.test.ts +++ b/packages/coding-agent/test/task/isolation-runner.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; import { applyEligibleNestedPatches, @@ -73,6 +74,7 @@ async function seedFooRepo(finalContent: string): Promise<{ repoRoot: string; pa describe("runIsolatedSubprocess", () => { afterEach(async () => { vi.restoreAllMocks(); + AgentRegistry.resetGlobalForTests(); await Promise.all(tempRoots.splice(0).map(tempRoot => fs.rm(tempRoot, { force: true, recursive: true }))); }); @@ -107,6 +109,13 @@ describe("runIsolatedSubprocess", () => { nestedPatches: [], }); const cleanupSpy = vi.spyOn(worktreeModule, "cleanupIsolation").mockResolvedValue(); + AgentRegistry.global().register({ + id: "PreserveBranchFailure", + displayName: "PreserveBranchFailure", + kind: "sub", + session: null, + status: "parked", + }); const deleteSpy = vi.spyOn(gitModule.branch, "tryDelete").mockResolvedValue(true); const outcome = await runIsolatedSubprocess({ @@ -138,6 +147,64 @@ describe("runIsolatedSubprocess", () => { expect(captureSpy).toHaveBeenCalledWith(isolationDir, baseline); expect(deleteSpy).toHaveBeenCalledWith(repoRoot, "omp/task/PreserveBranchFailure"); expect(cleanupSpy).toHaveBeenCalledTimes(1); + expect(AgentRegistry.global().get("PreserveBranchFailure")?.history?.patchPath).toBe(patchPath); + }); + + it("keeps an isolated worktree until deferred child cleanup settles", async () => { + const cleanupGate = Promise.withResolvers(); + vi.spyOn(worktreeModule, "ensureIsolation").mockResolvedValue({ + mergedDir: "/repo/isolated", + backend: natives.IsoBackendKind.Rcopy, + fellBack: false, + fallbackReason: null, + }); + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + options.onCleanupDeferred?.(cleanupGate.promise); + return result({ exitCode: 1, aborted: true, error: "cleanup exceeded its deadline" }); + }); + const cleanupSpy = vi.spyOn(worktreeModule, "cleanupIsolation").mockResolvedValue(); + + const outcome = await runIsolatedSubprocess({ + baseOptions: { + cwd: "/repo", + agent: { + name: "task", + description: "Task agent", + systemPrompt: "test", + source: "bundled", + }, + task: "Do work", + index: 0, + id: "DeferredCleanup", + }, + context: { + repoRoot: "/repo", + baseline: { + root: { + repoRoot: "/repo", + headCommit: "base", + staged: "", + unstaged: "", + untracked: [], + untrackedPatch: "", + }, + nested: [], + }, + }, + preferredBackend: undefined, + agentId: "DeferredCleanup", + mergeMode: "patch", + artifactsDir: "/artifacts", + buildFailureResult: error => result({ exitCode: 1, error: String(error) }), + }); + + expect(outcome.exitCode).toBe(1); + expect(cleanupSpy).not.toHaveBeenCalled(); + cleanupGate.resolve(); + await cleanupGate.promise; + await Promise.resolve(); + await Promise.resolve(); + expect(cleanupSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/coding-agent/test/task/persisted-revive.test.ts b/packages/coding-agent/test/task/persisted-revive.test.ts index eede151d1..cac45363d 100644 --- a/packages/coding-agent/test/task/persisted-revive.test.ts +++ b/packages/coding-agent/test/task/persisted-revive.test.ts @@ -62,7 +62,7 @@ function createRevivedSession(activeToolNames: string[][]): RevivedSessionHandle return { session, observer: () => observer }; } -async function createPersistedSession(cwd: string, restrictToolNames?: boolean): Promise { +async function createPersistedSession(cwd: string, restrictToolNames?: boolean, modelRole?: string): Promise { const manager = SessionManager.create(cwd, path.join(cwd, "sessions")); const sessionFile = manager.getSessionFile(); if (!sessionFile) throw new Error("Expected a persisted session file"); @@ -71,6 +71,8 @@ async function createPersistedSession(cwd: string, restrictToolNames?: boolean): task: "persisted task", tools: ["read", "yield"], restrictToolNames, + modelRole, + resolvedModel: modelRole ? "anthropic/claude-sonnet-4-5" : undefined, }); manager.appendMessage({ role: "assistant", @@ -179,6 +181,42 @@ describe("persisted subagent revival", () => { expect(capturedOptions?.customTools?.map(tool => tool.name)).toEqual(["mcp__server_read"]); }); + it("restores the persisted custom model role before reopening the session", async () => { + const cwd = makeTempDir("@pi-custom-role-revive-"); + const sessionFile = await createPersistedSession(cwd, false, "review-fast"); + let capturedOptions: CreateAgentSessionOptions | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + capturedOptions = options; + return { session: createRevivedSession([]).session } as CreateAgentSessionResult; + }); + + const ref = createRef(sessionFile); + const reviver = await createFactory(cwd)(ref); + if (!reviver) throw new Error("Expected a persisted reviver"); + await reviver(ref); + + expect(capturedOptions?.modelPattern).toEqual(["@review-fast", "anthropic/claude-sonnet-4-5"]); + expect(capturedOptions?.modelPatternAuthFallback).toBe("anthropic/claude-sonnet-4-5"); + }); + + it("pins the persisted concrete model when the default role is revived", async () => { + const cwd = makeTempDir("@pi-default-role-revive-"); + const sessionFile = await createPersistedSession(cwd, false, "default"); + let capturedOptions: CreateAgentSessionOptions | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + capturedOptions = options; + return { session: createRevivedSession([]).session } as CreateAgentSessionResult; + }); + + const ref = createRef(sessionFile); + const reviver = await createFactory(cwd)(ref); + if (!reviver) throw new Error("Expected a persisted reviver"); + await reviver(ref); + + expect(capturedOptions?.modelPattern).toBe("anthropic/claude-sonnet-4-5"); + expect(capturedOptions?.modelPatternAuthFallback).toBe("anthropic/claude-sonnet-4-5"); + }); + it("installs an IRC wake monitor that emits cold-revive lifecycle frames on the shared bus", async () => { AgentRegistry.resetGlobalForTests(); AgentLifecycleManager.resetGlobalForTests(); diff --git a/packages/coding-agent/test/task/structured-subagent.test.ts b/packages/coding-agent/test/task/structured-subagent.test.ts index 371a4d2e6..a24facde7 100644 --- a/packages/coding-agent/test/task/structured-subagent.test.ts +++ b/packages/coding-agent/test/task/structured-subagent.test.ts @@ -37,6 +37,7 @@ function session( maxDepth?: number; isolationMode?: "none" | "worktree"; isolationApply?: boolean; + modelRoles?: Record; } = {}, ): ToolSession { return { @@ -47,6 +48,7 @@ function session( "task.maxRecursionDepth": options.maxDepth ?? 2, "task.isolation.mode": options.isolationMode ?? "none", "task.enableLsp": true, + ...(options.modelRoles ? { modelRoles: options.modelRoles } : {}), ...(options.isolationApply !== undefined ? { "task.isolation.apply": options.isolationApply } : {}), }), getSessionFile: () => null, @@ -165,6 +167,112 @@ describe("structured subagent primitive", () => { ).rejects.toThrow("isolation, apply, and merge controls are unavailable in plan mode"); expect(discover).not.toHaveBeenCalled(); }); + it("propagates a custom thinking-suffixed role alias through policy, dispatch, and settlement", async () => { + const customAgent = { ...AGENT, model: ["@reviewer:high"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { reviewer: "openai/gpt-4o" } }); + const dispatched: executorModule.ExecutorOptions[] = []; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + dispatched.push(options); + return { ...result(), modelRole: options.modelRole }; + }); + + const settled = await runStructuredSubagent( + request({ session: childSession, agent: "worker", retainArtifacts: true }), + ); + + expect(settled.policy.modelRole).toBe("reviewer"); + expect(dispatched[0]?.modelRole).toBe("reviewer"); + expect(settled.result.modelRole).toBe("reviewer"); + await fs.rm(settled.artifactsDir, { recursive: true, force: true }); + }); + it("derives modelRole from the raw selector source in request, override, definition order", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const roleSession = session({ + modelRoles: { + request: "openai/gpt-4o", + override: "openai/gpt-4o", + definition: "openai/gpt-4o", + }, + }); + roleSession.settings.override("task.agentModelOverrides", { worker: "@override" }); + + const requestPolicy = await resolveEffectiveSubagentPolicy(request({ session: roleSession, model: "@request" })); + expect(requestPolicy.modelRole).toBe("request"); + + const overridePolicy = await resolveEffectiveSubagentPolicy(request({ session: roleSession })); + expect(overridePolicy.modelRole).toBe("override"); + + const concreteOverrideSession = session({ + modelRoles: { + override: "openai/gpt-4o", + definition: "openai/gpt-4o", + }, + }); + concreteOverrideSession.settings.override("task.agentModelOverrides", { worker: "openai/gpt-4o" }); + const concreteOverridePolicy = await resolveEffectiveSubagentPolicy( + request({ session: concreteOverrideSession }), + ); + expect(concreteOverridePolicy.modelRole).toBeUndefined(); + + const definitionPolicy = await resolveEffectiveSubagentPolicy( + request({ session: session({ modelRoles: { definition: "openai/gpt-4o" } }) }), + ); + expect(definitionPolicy.modelRole).toBe("definition"); + }); + it("falls through an empty request selector to the agent definition role", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { definition: "openai/gpt-4o" } }); + + const policy = await resolveEffectiveSubagentPolicy(request({ session: childSession, model: "" })); + + expect(policy.modelRole).toBe("definition"); + expect(policy.modelOverride).toEqual(["openai/gpt-4o"]); + }); + + it("falls through an empty configured override to the agent definition role", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { definition: "openai/gpt-4o" } }); + childSession.settings.override("task.agentModelOverrides", { worker: "" }); + + const policy = await resolveEffectiveSubagentPolicy(request({ session: childSession })); + + expect(policy.modelRole).toBe("definition"); + expect(policy.modelOverride).toEqual(["openai/gpt-4o"]); + }); + it("falls through a configured alias that expands to no patterns", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { empty: "", definition: "openai/gpt-4o" } }); + childSession.settings.override("task.agentModelOverrides", { worker: "@empty" }); + + const policy = await resolveEffectiveSubagentPolicy(request({ session: childSession })); + + expect(policy.modelRole).toBe("definition"); + expect(policy.modelOverride).toEqual(["openai/gpt-4o"]); + }); + + it("does not assign a role when a child uses an explicit model selector", async () => { + mockDiscovery(); + const childSession = session({ modelRoles: { reviewer: "openai/gpt-4o" } }); + const dispatched: executorModule.ExecutorOptions[] = []; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + dispatched.push(options); + return result(); + }); + + const settled = await runStructuredSubagent( + request({ session: childSession, model: "openai/gpt-4o", retainArtifacts: true }), + ); + + expect(settled.policy.modelRole).toBeUndefined(); + expect(dispatched[0]?.modelRole).toBeUndefined(); + expect(settled.result.modelRole).toBeUndefined(); + await fs.rm(settled.artifactsDir, { recursive: true, force: true }); + }); it("leases temporary artifacts for a retained invocation and registers them for agent URLs", async () => { mockDiscovery(); diff --git a/packages/coding-agent/test/tools/bash-interceptor.test.ts b/packages/coding-agent/test/tools/bash-interceptor.test.ts index 104e53bea..924aacd6d 100644 --- a/packages/coding-agent/test/tools/bash-interceptor.test.ts +++ b/packages/coding-agent/test/tools/bash-interceptor.test.ts @@ -77,13 +77,19 @@ describe("compound command interception", () => { "git add file && git commit -m message", "git add file; git commit -m message", "git add file || git commit -m message", - "git add file | git commit -m message", "git add file & git commit -m message", "git add file\ngit commit -m message", ])("blocks a later command after %s", command => { expect(checkBashInterception(command, ["commit"], rules).block).toBe(true); }); + it("does not intercept a downstream pipe stage that consumes piped stdin", () => { + // `git commit` after a single `|` reads the previous stage's stdout, so + // the dedicated tool cannot replace it. `||` still starts a fresh command. + expect(checkBashInterception("git add file | git commit -m message", ["commit"], rules).block).toBe(false); + expect(checkBashInterception("git add file || git commit -m message", ["commit"], rules).block).toBe(true); + }); + it("removes one or more leading environment assignments before matching", () => { expect( checkBashInterception('GIT_AUTHOR_EMAIL="a@example.com" git commit -m message', ["commit"], rules).block, @@ -203,6 +209,40 @@ describe("default echo/printf redirect rule", () => { }); }); +describe("default grep rule and pipeline stdin", () => { + const tools = ["grep"]; + + it("blocks standalone file searches", () => { + expect(checkBashInterception("grep pattern path", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + expect(checkBashInterception("rg pattern src", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + }); + + it("blocks a first-stage grep that produces pipeline input", () => { + expect(checkBashInterception("grep x file | wc -l", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + }); + + it("does not block grep consuming pipeline stdin", () => { + expect(checkBashInterception("printf 'x\\n' | grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false); + expect( + checkBashInterception("tr -d '\\r' < input.log | grep -v '^ *foo'", tools, DEFAULT_BASH_INTERCEPTOR_RULES) + .block, + ).toBe(false); + expect(checkBashInterception("printf 'x\\n' |\n grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe( + false, + ); + expect( + checkBashInterception("printf 'x\\n' |\n # filter\n grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block, + ).toBe(false); + expect(checkBashInterception("printf 'x\\n' |& grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false); + }); + + it("still blocks a standalone grep sequenced after a pipeline", () => { + expect( + checkBashInterception("cat log | tr a b && grep err file", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block, + ).toBe(true); + }); +}); + describe("default hub start rules", () => { const tools = ["hub"]; diff --git a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts index 51df6907e..768021be3 100644 --- a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts +++ b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts @@ -42,6 +42,7 @@ import { runInTab, } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index"; +import * as logger from "@oh-my-pi/pi-utils/logger"; function makeKind(socketSuffix: string): CmuxKind { return { @@ -286,6 +287,146 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { } }); + it("logs a user continuation rejection after its cmux run ends", async () => { + spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); + spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); + spyOn(CmuxSocketClient.prototype, "request").mockImplementation( + async (method: string): Promise> => { + switch (method) { + case "browser.open_split": + return { surface_id: "surface-late-rejection", url: "about:blank" }; + case "browser.url.get": + return { url: "about:blank" }; + case "browser.snapshot": + return { page: { html: "" } }; + case "browser.eval": + return { value: "" }; + default: + return {}; + } + }, + ); + const warningLogged = Promise.withResolvers(); + const warn = spyOn(logger, "warn").mockImplementation(message => { + if (message === "Unhandled rejection after browser run ended") warningLogged.resolve(); + }); + const browser = await acquireBrowser(makeKind("late-rejection"), { cwd: "/tmp" }); + await acquireTab("late-rejection", browser, { + timeoutMs: 5_000, + ownerSessionId: "session-late-rejection", + }); + + const result = await runInTab("late-rejection", { + code: ` + const continuationStarted = Promise.withResolvers(); + void tab.title().then(async () => { + continuationStarted.resolve(); + await Bun.sleep(50); + throw new Error("late cmux continuation failed"); + }); + await continuationStarted.promise; + return "completed"; + `, + timeoutMs: 5_000, + session: makeSession("/tmp"), + }); + expect(result.returnValue).toBe("completed"); + + await warningLogged.promise; + expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { + runId: expect.any(String), + error: "late cmux continuation failed", + }); + }); + + it("fails a browser error rethrown through a native promise combinator", async () => { + spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); + spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); + spyOn(CmuxSocketClient.prototype, "request").mockImplementation( + async (method: string): Promise> => { + switch (method) { + case "browser.open_split": + return { surface_id: "surface-combinator-rejection", url: "about:blank" }; + case "browser.url.get": + return { url: "about:blank" }; + case "browser.snapshot": + return { page: { html: "" } }; + case "browser.eval": + return { value: "" }; + case "browser.navigate": + throw new Error("navigation failed"); + default: + return {}; + } + }, + ); + const browser = await acquireBrowser(makeKind("combinator-rejection"), { cwd: "/tmp" }); + await acquireTab("combinator-rejection", browser, { + timeoutMs: 5_000, + ownerSessionId: "session-combinator-rejection", + }); + + const run = runInTab("combinator-rejection", { + code: ` + void Promise.all([ + tab.goto("https://example.test"), + ]).catch(reason => { + throw reason; + }); + await wait(50); + return "incorrect success"; + `, + timeoutMs: 5_000, + session: makeSession("/tmp"), + }); + + await expect(run).rejects.toThrow("Unhandled rejection (missing await?): navigation failed"); + }); + + it("aborts the cmux run facade before draining floated continuations", async () => { + spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); + spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); + const navigatedUrls: string[] = []; + spyOn(CmuxSocketClient.prototype, "request").mockImplementation( + async (method: string, params: Record): Promise> => { + switch (method) { + case "browser.open_split": + return { surface_id: "surface-drain-abort", url: "about:blank" }; + case "browser.url.get": + return { url: "about:blank" }; + case "browser.snapshot": + return { page: { html: "" } }; + case "browser.eval": + await Bun.sleep(0); + return { value: "ready" }; + case "browser.navigate": + navigatedUrls.push(String(params.url)); + return { url: params.url }; + default: + return {}; + } + }, + ); + const browser = await acquireBrowser(makeKind("drain-abort"), { cwd: "/tmp" }); + await acquireTab("drain-abort", browser, { + timeoutMs: 5_000, + ownerSessionId: "session-drain-abort", + }); + + const result = await runInTab("drain-abort", { + code: ` + void tab.title().then(() => tab.goto("https://late.example")); + return "completed"; + `, + timeoutMs: 5_000, + session: makeSession("/tmp"), + }); + expect(result.returnValue).toBe("completed"); + + await Bun.sleep(20); + expect(navigatedUrls).toEqual([]); + }); + it("ignores the daemon screenshot path when no screenshot directory is configured", async () => { spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index 1d677507f..0026a43fc 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { stealthIgnoreDefaultArgsForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; + +import * as path from "node:path"; +import { + stealthIgnoreDefaultArgsForTest, + systemChromiumCandidatesForTest, +} from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const EXECUTABLE_PROBE = path.resolve(import.meta.dir, "../fixtures/browser-executable-probe.ts"); const AUTOMATION_FLAG = "--enable-automation"; @@ -33,3 +41,88 @@ describe("browser launch stealth defaults", () => { } }); }); + +const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; + +describe("system Chromium candidates", () => { + const linuxCandidates = (which: (name: string) => string | undefined = () => undefined) => + systemChromiumCandidatesForTest("linux", "/home/test", which); + + it("offers Ungoogled Chromium executables on Linux", () => { + const candidates = linuxCandidates(); + + expect(candidates).toContain("/usr/bin/ungoogled-chromium"); + expect(candidates).toContain("/usr/bin/ungoogled-chromium-browser"); + expect(candidates).toContain(`/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); + expect(candidates).toContain(`/home/test/.local/share/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); + }); + + it("keeps the previously supported Linux executables", () => { + const candidates = linuxCandidates(); + + for (const executablePath of [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/var/lib/flatpak/exports/bin/com.google.Chrome", + "/var/lib/flatpak/exports/bin/org.chromium.Chromium", + ]) { + expect(candidates).toContain(executablePath); + } + }); + + it("ranks PATH-resolved Ungoogled Chromium below stock builds", () => { + const ungoogledPath = "/custom/bin/ungoogled-chromium"; + const candidates = linuxCandidates(name => (name === "ungoogled-chromium" ? ungoogledPath : undefined)); + const ungoogled = candidates.indexOf(ungoogledPath); + + for (const executablePath of [ + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/snap/bin/chromium", + "/var/lib/flatpak/exports/bin/org.chromium.Chromium", + ]) { + expect(ungoogled).toBeGreaterThan(candidates.indexOf(executablePath)); + } + }); + + it("does not add Ungoogled Chromium candidates on macOS or Windows", () => { + for (const platform of ["darwin", "win32"] as const) { + const candidates = systemChromiumCandidatesForTest(platform, "/home/test", () => "/custom/ungoogled"); + expect(candidates.some(candidate => candidate.toLowerCase().includes("ungoogled"))).toBeFalse(); + } + }); +}); + +describe("browser executable selection", () => { + it("honors PUPPETEER_EXECUTABLE_PATH before a detected Windows system Chrome", async () => { + const tempDir = TempDir.createSync("@browser-executable-"); + try { + const override = path.join(tempDir.path(), "chrome-headless-shell.exe"); + const systemChrome = path.join(tempDir.path(), "Google\\Chrome\\Application\\chrome.exe"); + await Bun.write(override, "override"); + await Bun.write(systemChrome, "system"); + + const result = Bun.spawnSync([process.execPath, EXECUTABLE_PROBE], { + env: { + ...process.env, + OMP_BROWSER_PROBE_PLATFORM: "win32", + ProgramFiles: tempDir.path(), + "ProgramFiles(x86)": path.join(tempDir.path(), "missing-x86"), + LOCALAPPDATA: path.join(tempDir.path(), "missing-local"), + PUPPETEER_EXECUTABLE_PATH: override, + }, + stdout: "pipe", + stderr: "pipe", + }); + const stderr = new TextDecoder().decode(result.stderr); + + expect(result.exitCode, stderr).toBe(0); + expect(new TextDecoder().decode(result.stdout)).toBe(override); + } finally { + await tempDir.remove(); + } + }); +}); diff --git a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts index 52f2c24ca..133a7bdd9 100644 --- a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts +++ b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts @@ -1,9 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; import { postmortem } from "@oh-my-pi/pi-utils"; import { JsRuntime, type RuntimeHooks } from "../../src/eval/js/shared/runtime"; -import { bindRunFacade, markHandled, waitForRun } from "../../src/tools/run-scope"; +import { + bindRunFacade, + isBrowserRunOwnedRejection, + isBrowserRunRejection, + markBrowserRunRejection, + markHandled, + waitForRun, + withBrowserPromiseCombinatorTracking, +} from "../../src/tools/run-scope"; import { ToolAbortError } from "../../src/tools/tool-errors"; +const runScopeModuleUrl = new URL("../../src/tools/run-scope.ts", import.meta.url).href; + async function collectUnhandledRejections(action: () => void | Promise): Promise { const reasons: unknown[] = []; const onUnhandled = (reason: unknown) => reasons.push(reason); @@ -127,6 +138,302 @@ describe("browser run cancellation", () => { expect(reasons).toEqual([]); }); + it("scopes browser rejection markers to the owning run and direct reason", () => { + const owner = {}; + const browserFailure = new Error("browser failed"); + markBrowserRunRejection(browserFailure, owner); + + expect(isBrowserRunRejection(browserFailure, owner)).toBe(true); + expect(isBrowserRunRejection(browserFailure, {})).toBe(false); + expect(isBrowserRunRejection(new Error("unrelated", { cause: browserFailure }), owner)).toBe(false); + }); + + it("keeps unrelated worker rejections outside the active browser run", () => { + const owner = {}; + const workerFailure = new Error("transport failed"); + workerFailure.stack = "Error: transport failed\n at tab-worker.ts:1:1"; + const evaluatedFailure = new Error("evaluated failure"); + evaluatedFailure.stack = "Error: evaluated failure\n at browser-run-run-1.js:1:1"; + + expect(isBrowserRunOwnedRejection(workerFailure, owner, "browser-run-run-1.js")).toBe(false); + expect(isBrowserRunOwnedRejection(evaluatedFailure, owner, "browser-run-run-1.js")).toBe(true); + expect( + isBrowserRunOwnedRejection(markBrowserRunRejection(workerFailure, owner), owner, "browser-run-run-1.js"), + ).toBe(true); + }); + + it("keeps a later cause-wrapped rejection on the fatal path", async () => { + vi.useRealTimers(); + const script = ` + import { markBrowserRunRejection } from ${JSON.stringify(runScopeModuleUrl)}; + + const browserFailure = new Error("browser failure"); + markBrowserRunRejection(browserFailure, {}); + Promise.reject(new Error("unrelated fatal", { cause: browserFailure })); + await Promise.resolve(); + `; + const proc = Bun.spawn([process.execPath, "-e", script], { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("[Unhandled Rejection] Error: unrelated fatal"); + }); + + it("preserves a browser rejection marker through native await", async () => { + const owner = {}; + const browserFailure = new Error("browser failed"); + const facade = bindRunFacade( + { + fail(): Promise { + return Promise.reject(browserFailure); + }, + }, + new AbortController().signal, + owner, + ); + + let caught: unknown; + try { + await (async () => await facade.fail())(); + } catch (error) { + caught = error; + } + + expect(caught).toBe(browserFailure); + expect(isBrowserRunRejection(caught, owner)).toBe(true); + }); + + it("reports user rethrows from native browser-promise combinators", async () => { + vi.useRealTimers(); + for (const name of ["all", "race"] as const) { + const owner = {}; + const browserFailure = new Error(`${name} browser failure`); + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + fail(): Promise { + return Promise.reject(browserFailure); + }, + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + const originalCombinator = Promise[name]; + + await withBrowserPromiseCombinatorTracking( + owner, + reason => floatingRejections.push(reason), + async () => { + const combined = name === "all" ? Promise.all([facade.fail()]) : Promise.race([facade.fail()]); + void combined.catch(reason => { + throw reason; + }); + await Bun.sleep(20); + }, + ); + + expect(floatingRejections).toEqual([browserFailure]); + expect(Promise[name]).toBe(originalCombinator); + } + }); + + it("preserves native await through a tracked browser-promise combinator", async () => { + vi.useRealTimers(); + const owner = {}; + const browserFailure = new Error("browser failed"); + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + fail(): Promise { + return Promise.reject(browserFailure); + }, + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + let caught: unknown; + await withBrowserPromiseCombinatorTracking( + owner, + reason => floatingRejections.push(reason), + async () => { + try { + await Promise.all([facade.fail()]); + } catch (error) { + caught = error; + } + await Bun.sleep(10); + }, + ); + + expect(caught).toBe(browserFailure); + expect(floatingRejections).toEqual([]); + }); + + it("keeps a real worker alive after floating browser and continuation rejections", async () => { + vi.useRealTimers(); + const workerPath = `/tmp/omp-browser-rejections-${process.pid}.ts`; + await Bun.write( + workerPath, + ` + import { + bindRunFacade, + installBrowserWorkerRejectionGuard, + } from ${JSON.stringify(runScopeModuleUrl)}; + + const failures = []; + const uninstall = installBrowserWorkerRejectionGuard(reason => { + failures.push(reason instanceof Error ? reason.message : String(reason)); + return true; + }); + const facade = bindRunFacade( + { + waitForResponse() { + return Promise.reject(new Error("browser timeout")); + }, + title() { + return Promise.resolve("ready"); + }, + }, + new AbortController().signal, + {}, + ); + void (async () => { + await facade.waitForResponse(); + })(); + void facade.title().then(() => { + throw new Error("continuation failed"); + }); + setTimeout(() => { + uninstall(); + postMessage({ alive: true, failures }); + }, 50); + `, + ); + try { + const script = ` + const worker = new Worker(${JSON.stringify(workerPath)}, { type: "module" }); + const done = Promise.withResolvers(); + worker.onmessage = event => done.resolve(event.data); + worker.onerror = event => done.reject(new Error(event.message)); + try { + const result = await Promise.race([ + done.promise, + Bun.sleep(1000).then(() => { + throw new Error("worker timed out"); + }), + ]); + console.log(JSON.stringify(result)); + } finally { + await worker.terminate(); + } + `; + const proc = Bun.spawn([process.execPath, "-e", script], { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("browser timeout"); + expect(stdout).toContain("continuation failed"); + } finally { + await fs.rm(workerPath, { force: true }); + } + }); + + it("does not mark errors thrown by user continuations", async () => { + const owner = {}; + const continuationFailure = new Error("continuation failed"); + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + ok: async (): Promise => "ok", + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + const root = facade.ok(); + expect(root).toBeInstanceOf(Promise); + expect(Object.getPrototypeOf(root)).toBe(Promise.prototype); + const continuation = root.then(() => { + throw continuationFailure; + }); + + await expect(continuation).rejects.toBe(continuationFailure); + expect(isBrowserRunRejection(continuationFailure, owner)).toBe(false); + expect(floatingRejections).toEqual([]); + }); + + it("reports unhandled errors from then, catch, and finally continuations", async () => { + vi.useRealTimers(); + const owner = {}; + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + fail: async (): Promise => { + throw new Error("browser failure"); + }, + ok: async (): Promise => "ok", + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + void facade.ok().then(() => { + throw new Error("then failed"); + }); + void facade.fail().catch(() => { + throw new Error("catch failed"); + }); + void facade.ok().finally(() => { + throw new Error("finally failed"); + }); + await Bun.sleep(20); + + const messages = floatingRejections + .map(reason => (reason instanceof Error ? reason.message : String(reason))) + .sort(); + expect(messages).toEqual(["catch failed", "finally failed", "then failed"]); + }); + + it("reports a browser error rethrown by a user rejection continuation", async () => { + vi.useRealTimers(); + const owner = {}; + const browserFailure = new Error("browser failure"); + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + fail: (): Promise => Promise.reject(browserFailure), + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + void facade.fail().catch(reason => { + throw reason; + }); + await Bun.sleep(20); + + expect(isBrowserRunRejection(browserFailure, owner)).toBe(true); + expect(floatingRejections).toEqual([browserFailure]); + }); + it("rejects awaited facade method calls that settle after abort", async () => { const controller = new AbortController(); const deferred = Promise.withResolvers(); diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index 5d6234d30..30c299a20 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, vi } 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 { getTabsMapForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor"; +import * as logger from "@oh-my-pi/pi-utils/logger"; import { chromiumAvailable } from "./chromium-probe"; const CHROMIUM_AVAILABLE = await chromiumAvailable(); @@ -208,6 +209,295 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("keeps the tab worker alive after an unhandled waitForResponse timeout descendant", async () => { + const tool = new BrowserTool(makeSession()); + const name = `response-timeout-descendant-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const tabSession = getTabsMapForTest().get(name); + if (tabSession?.backend !== "worker") throw new Error("Worker tab was not created"); + expect(tabSession.worker.mode).toBe("worker"); + const result = await tool.execute("run", { + action: "run", + name, + timeout: 2, + // Real worker timers are intentional: the rejection must cross an + // unhandledRejection turn while the browser run remains active. + code: ` + void tab.waitForResponse("/never", { timeout: 10 }).then(() => undefined); + await Bun.sleep(50); + return "survived timeout"; + `, + }); + expect(result.content).toEqual([{ type: "text", text: "survived timeout" }]); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + + it("fails floated user continuations without killing the tab worker", async () => { + const tool = new BrowserTool(makeSession()); + const name = `continuation-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; + try { + await tool.execute("run", { + action: "run", + name, + timeout: 2, + code: ` + void tab.title().then(() => { + throw new Error("continuation failed"); + }); + await Bun.sleep(50); + return "incorrect success"; + `, + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + expect(failure).toContain("Unhandled rejection (missing await?): continuation failed"); + + let rethrowFailure = ""; + try { + await tool.execute("run", { + action: "run", + name, + timeout: 2, + code: ` + void tab.waitForResponse("/never", { timeout: 10 }).catch(reason => { + throw reason; + }); + await Bun.sleep(50); + return "incorrect success"; + `, + }); + } catch (error) { + rethrowFailure = error instanceof Error ? error.message : String(error); + } + expect(rethrowFailure).toContain( + "Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms", + ); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + + it("fails a browser error rethrown through a native promise combinator", async () => { + const tool = new BrowserTool(makeSession()); + const name = `combinator-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; + try { + await tool.execute("run", { + action: "run", + name, + timeout: 2, + code: ` + void Promise.all([ + tab.waitForResponse("/never", { timeout: 10 }), + ]).catch(reason => { + throw reason; + }); + await Bun.sleep(50); + return "incorrect success"; + `, + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + expect(failure).toContain("Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms"); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + + it("restores promise tracking after evaluated code freezes Promise", async () => { + const tool = new BrowserTool(makeSession()); + const name = `frozen-promise-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const frozen = await tool.execute("run", { + action: "run", + name, + code: ` + Object.freeze(Promise); + return Object.isFrozen(Promise); + `, + }); + expect(frozen.content).toEqual([{ type: "text", text: "true" }]); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return (await Promise.all([42]))[0];", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + + it("aborts the run facade before draining floated continuations", async () => { + const tool = new BrowserTool(makeSession()); + const name = `drain-abort-${process.pid}`; + const url = "data:text/html,original

ready

"; + + try { + await tool.execute("open", { + action: "open", + name, + url, + }); + const result = await tool.execute("run", { + action: "run", + name, + code: ` + page.title = async () => { + await Bun.sleep(0); + return "ready"; + }; + void tab.title().then(() => tab.goto("data:text/html,late")); + return "completed"; + `, + }); + expect(result.content).toEqual([{ type: "text", text: "completed" }]); + + await Bun.sleep(100); + const followup = await tool.execute("run", { + action: "run", + name, + code: "return tab.url();", + }); + expect(followup.content).toEqual([{ type: "text", text: url }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + + it("folds a user continuation rejection that settles during cleanup", async () => { + const tool = new BrowserTool(makeSession()); + const name = `cleanup-continuation-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; + try { + await tool.execute("run", { + action: "run", + name, + code: ` + await page.setRequestInterception(true); + page.setRequestInterception = async () => { + await Bun.sleep(50); + }; + const continuationStarted = Promise.withResolvers(); + void tab.title().then(async () => { + continuationStarted.resolve(); + await Bun.sleep(10); + throw new Error("cleanup continuation failed"); + }); + await continuationStarted.promise; + return "incorrect success"; + `, + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + expect(failure).toContain("Unhandled rejection (missing await?): cleanup continuation failed"); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + + it("logs a user continuation rejection after its browser run ends", async () => { + const warningLogged = Promise.withResolvers(); + const warn = vi.spyOn(logger, "warn").mockImplementation(message => { + if (message === "Unhandled rejection after browser run ended") warningLogged.resolve(); + }); + const tool = new BrowserTool(makeSession()); + const name = `late-continuation-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const result = await tool.execute("run", { + action: "run", + name, + code: ` + const continuationStarted = Promise.withResolvers(); + void tab.title().then(async () => { + continuationStarted.resolve(); + await Bun.sleep(50); + throw new Error("late continuation failed"); + }); + await continuationStarted.promise; + return "completed"; + `, + }); + expect(result.content).toEqual([{ type: "text", text: "completed" }]); + + await warningLogged.promise; + expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { + runId: expect.any(String), + error: "late continuation failed", + }); + } finally { + warn.mockRestore(); + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("observes floating raw page promises when the target closes", async () => { const tool = new BrowserTool(makeSession()); const name = `target-close-${process.pid}`; diff --git a/packages/coding-agent/test/tools/glob-validate-paths.test.ts b/packages/coding-agent/test/tools/glob-validate-paths.test.ts index 91187f689..567eec7ac 100644 --- a/packages/coding-agent/test/tools/glob-validate-paths.test.ts +++ b/packages/coding-agent/test/tools/glob-validate-paths.test.ts @@ -106,6 +106,24 @@ describe("delimited path expansion", () => { ).toEqual(["apps/**/*.txt", "packages/**/*.txt"]); }); + it("splits a semicolon list whose joined string exceeds NAME_MAX (issue #7597)", async () => { + // Bare filenames in one directory form a single slash-free run once joined, + // so ~12 short entries already push the run past NAME_MAX (255). lstat on + // the joined string then throws ENAMETOOLONG, which used to be read as an + // inconclusive probe and suppress the split, collapsing the whole list to + // one non-existent literal path. + const names: string[] = []; + for (let i = 0; i < 20; i++) { + const name = `enametoolong-probe-${String(i).padStart(2, "0")}.txt`; + await Bun.write(path.join(tempDir, name), "needle\n"); + names.push(name); + } + const joined = names.join("; "); + expect(joined.length).toBeGreaterThan(255); + expect(await splitDelimitedPathEntry(joined, tempDir)).toEqual(names); + expect(await expandDelimitedPathEntries([joined], tempDir)).toEqual(names); + }); + it("normalizes Windows path separators before parsing find globs", async () => { expect(parseFindPattern("apps\\**\\*.txt")).toEqual({ basePath: "apps", diff --git a/packages/coding-agent/test/tools/grep-path-lists.test.ts b/packages/coding-agent/test/tools/grep-path-lists.test.ts index 17ab6597f..adbe10449 100644 --- a/packages/coding-agent/test/tools/grep-path-lists.test.ts +++ b/packages/coding-agent/test/tools/grep-path-lists.test.ts @@ -93,6 +93,7 @@ async function makeJsonlSessionFile(dirPath: string, entries: object[]): Promise function makeSubagentRegistry(sessions: ObservableSession[]): SessionObserverRegistry { return { getSessions: () => sessions, + getSession: (id: string) => sessions.find(session => session.id === id), onChange: () => () => {}, setMainSession: () => {}, getActiveSubagentCount: () => sessions.filter(session => session.status === "active").length, diff --git a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts index 13ed0a57c..4f967c1ad 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -5,7 +5,7 @@ import { createLspWritethrough, type FileDiagnosticsResult, FileFormatResult } f import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; import type { Diagnostic, LinterClient, LspClient, ServerConfig } from "@oh-my-pi/pi-coding-agent/lsp/types"; -import { fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; +import { EquivalentUriMap, fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; import type { DeferredDiagnosticsEntry, ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; import { type ptree, TempDir } from "@oh-my-pi/pi-utils"; @@ -47,7 +47,7 @@ function createClient(cwd: string, config: ServerConfig): LspClient { config, proc: {} as ptree.ChildProcess<"pipe">, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, openFiles: new Map(), pendingRequests: new Map(), @@ -438,6 +438,51 @@ describe("LSP diagnostics freshness", () => { expect(result?.messages.some(m => m.includes("stale error"))).toBe(false); }); + it("matches published diagnostics when the server renormalizes the document URI", async () => { + const filePath = path.join(tempDir.path(), "renormalized.ts"); + const uri = fileToUri(filePath); + const serverUri = uri.replace("/renormalized.ts", "/%72enormalized.ts"); + const client = createClient(tempDir.path(), TEST_SERVER); + const clock = new VirtualClock(Date.now()); + installVirtualTime(clock); + + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", TEST_SERVER]]); + vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); + vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => { + const syncedUri = fileToUri(syncedFilePath); + mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" }); + }); + vi.spyOn(lspClient, "notifySaved").mockImplementation(async mockClient => { + clock.in(10, () => { + publishDiagnostics(mockClient, serverUri, [createDiagnostic("renormalized URI error")], 1); + }); + }); + + const writethrough = createLspWritethrough(tempDir.path(), { + enableFormat: false, + enableDiagnostics: true, + }); + const result = await writethrough(filePath, "export const value = missing;\n"); + + expect(result?.errored).toBe(true); + expect(result?.messages.some(message => message.includes("renormalized URI error"))).toBe(true); + }); + + it("matches Windows drive-letter case and percent-encoding differences", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!platformDescriptor) throw new Error("process.platform descriptor is unavailable"); + Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" }); + try { + const diagnostics = new EquivalentUriMap(); + diagnostics.set("file:///c%3A/Users/serge/doc.md", "published"); + + expect(diagnostics.get("file:///C:/Users/serge/doc.md")).toBe("published"); + } finally { + Object.defineProperty(process, "platform", platformDescriptor); + } + }); + it("returns completed pull diagnostics inside the inline write window", async () => { const filePath = path.join(tempDir.path(), "pull-only.ts"); const uri = fileToUri(filePath); diff --git a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts index e79abfbde..6e82c64fd 100644 --- a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts +++ b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts @@ -121,6 +121,13 @@ describe("literal colon filename resolution (issue #4618)", () => { await fs.symlink(path.join(tmpDir, "nowhere"), literal); expect(await probeLiteralPathExists(literal, tmpDir)).toBe("exists"); }); + + it('returns "missing" for an ENAMETOOLONG path (issue #7597)', async () => { + // A single component past NAME_MAX can never name a real entry, so the + // probe must report "missing" (not "unknown") to let delimited splits run. + const overlong = path.join(tmpDir, "x".repeat(300)); + expect(await probeLiteralPathExists(overlong, tmpDir)).toBe("missing"); + }); }); describe("read tool", () => { diff --git a/packages/coding-agent/test/tools/read-artifact-large.test.ts b/packages/coding-agent/test/tools/read-artifact-large.test.ts index 46831a30f..389bebc2b 100644 --- a/packages/coding-agent/test/tools/read-artifact-large.test.ts +++ b/packages/coding-agent/test/tools/read-artifact-large.test.ts @@ -104,6 +104,12 @@ describe("read tool large artifact handling", () => { expect(output).not.toContain("line-032"); }); + it("records the source line count for an open-ended artifact range that reaches EOF", async () => { + const result = await tool.execute("call-raw-tail", { path: "artifact://0:raw:301-" }); + + expect(result.details?.totalLines).toBe(400); + }); + it("shortens artifact paths under the user's home dir instead of leaking the absolute path", async () => { const homeSpy = spyOn(os, "homedir").mockReturnValue(testDir); try { diff --git a/packages/coding-agent/test/tools/read-raw-range.test.ts b/packages/coding-agent/test/tools/read-raw-range.test.ts index 75d5c3cc9..10f4b292b 100644 --- a/packages/coding-agent/test/tools/read-raw-range.test.ts +++ b/packages/coding-agent/test/tools/read-raw-range.test.ts @@ -59,6 +59,12 @@ describe("read tool raw range exactness", () => { expect(output.trimEnd()).toBe("L01\nL02"); }); + it("records the source line count for an open-ended range that reaches EOF", async () => { + const result = await tool.execute("call-raw-tail", { path: `${filePath}:raw:31-` }); + + expect(result.details?.totalLines).toBe(60); + }); + it("keeps context padding for numbered range reads", async () => { // Numbered mode intentionally pads (leading anchor buffer + trailing // disambiguation lines) — line numbers make the padding self-describing. 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 b9494bab4..2d5f91eee 100644 --- a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts +++ b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts @@ -40,9 +40,12 @@ function makeMessage(content: AssistantMessage["content"], model: Model): Assist function createHost( model: Model, modelRegistry: ModelRegistry, - fallbackChains?: Record, + options: { + fallbackChains?: Record; + textOutputCommitted?: boolean; + } = {}, ): TurnRecoveryHost { - const settings = Settings.isolated(fallbackChains ? { "retry.fallbackChains": fallbackChains } : {}); + const settings = Settings.isolated(options.fallbackChains ? { "retry.fallbackChains": options.fallbackChains } : {}); return { agent: undefined as never, sessionManager: undefined as never, @@ -51,6 +54,7 @@ function createHost( modelRegistry, configWarnings: [], model: () => model, + textOutputCommitted: () => options.textOutputCommitted !== false, thinkingLevel: () => undefined, configuredThinkingLevel: () => undefined, setThinkingLevel: () => {}, @@ -95,18 +99,160 @@ describe("TurnRecovery replay-unsafe output classification", () => { tempDir.removeSync(); }); + it("rolls back a usage fallback cancelled during model reconciliation", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback model"); + let activeModel = model; + const fallbackApplied = Promise.withResolvers(); + const releaseReconciliation = Promise.withResolvers(); + const modelChanges: string[] = []; + const emittedEvents: string[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + if (nextModel.provider === fallback.provider && nextModel.id === fallback.id) { + fallbackApplied.resolve(); + await releaseReconciliation.promise; + } + }; + host.emitSessionEvent = async event => { + emittedEvents.push(event.type); + }; + const recovery = new TurnRecovery(host); + const controller = new AbortController(); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key", signal: controller.signal }, + ); + + await fallbackApplied.promise; + controller.abort(); + releaseReconciliation.resolve(); + const committed = await applying; + + expect(committed).toBe(false); + expect(activeModel).toBe(model); + expect(modelChanges).toEqual([]); + expect(emittedEvents).toEqual([]); + }); + + it("does not commit a fallback superseded during model reconciliation", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback race model"); + const selectedModel = { ...fallback, baseUrl: "https://user-selected-route.example" }; + let activeModel = model; + const fallbackApplied = Promise.withResolvers(); + const releaseReconciliation = Promise.withResolvers(); + const modelChanges: string[] = []; + const emittedEvents: string[] = []; + const thinkingChanges: unknown[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setThinkingLevel = level => thinkingChanges.push(level); + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + if (nextModel.provider === fallback.provider && nextModel.id === fallback.id) { + fallbackApplied.resolve(); + await releaseReconciliation.promise; + } + }; + host.emitSessionEvent = async event => { + emittedEvents.push(event.type); + }; + const recovery = new TurnRecovery(host); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key" }, + ); + + await fallbackApplied.promise; + activeModel = selectedModel; + releaseReconciliation.resolve(); + const committed = await applying; + + expect(committed).toBe(false); + expect(activeModel).toBe(selectedModel); + expect(modelChanges).toEqual([]); + expect(thinkingChanges).toEqual([]); + expect(emittedEvents).toEqual([]); + }); + it("keeps a committed fallback when cancellation arrives during applied-event delivery", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback model"); + let activeModel = model; + const eventStarted = Promise.withResolvers(); + const releaseEvent = Promise.withResolvers(); + const modelChanges: string[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + }; + host.emitSessionEvent = async event => { + if (event.type !== "retry_fallback_applied") return; + eventStarted.resolve(); + await releaseEvent.promise; + }; + const recovery = new TurnRecovery(host); + const controller = new AbortController(); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key", signal: controller.signal }, + ); + + await eventStarted.promise; + controller.abort(); + releaseEvent.resolve(); + const committed = await applying; + + expect(committed).toBe(true); + expect(activeModel.provider).toBe(fallback.provider); + expect(activeModel.id).toBe(fallback.id); + expect(modelChanges).toEqual([`${fallback.provider}/${fallback.id}`]); + }); + it("treats a failed turn with partial non-whitespace text as NOT retriable", () => { const recovery = new TurnRecovery(createHost(model, modelRegistry)); const message = makeMessage([{ type: "text", text: "Here is the first part of my answer" }], model); expect(recovery.isRetryableError(message)).toBe(false); }); - it("allows replay-safe hard fallback and excludes visible text with a configured chain", () => { - const recovery = new TurnRecovery( - createHost(model, modelRegistry, { - [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], - }), - ); + it("allows replay-safe hard fallback and excludes committed text with a configured chain", () => { + const fallbackChains = { + [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], + }; + const recovery = new TurnRecovery(createHost(model, modelRegistry, { fallbackChains })); // Thinking-only output is replay-safe: nothing visible reached the user. const message = makeMessage([{ type: "thinking", thinking: "safe reasoning before failing" }], model); const visible = makeMessage([{ type: "text", text: "Already shown" }], model); @@ -114,6 +260,18 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recovery.isHardErrorFallbackEligible(message)).toBe(true); }); + it("retries partial text while its buffered output remains uncommitted", () => { + const fallbackChains = { + [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], + }; + const recovery = new TurnRecovery( + createHost(model, modelRegistry, { fallbackChains, textOutputCommitted: false }), + ); + const message = makeMessage([{ type: "text", text: "Buffered partial answer" }], model); + expect(recovery.isRetryableError(message)).toBe(true); + expect(recovery.isHardErrorFallbackEligible(message)).toBe(true); + }); + it("excludes a Fireworks Fast failed turn with partial visible text from Fast→base fallback", () => { const fastModel = getBundledModel("fireworks", "kimi-k2.6-fast"); if (!fastModel) throw new Error("Expected bundled model kimi-k2.6-fast"); @@ -154,6 +312,19 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recovery.isHardErrorFallbackEligible(message)).toBe(false); }); + it("keeps side-effecting output replay-unsafe while text is uncommitted", () => { + const recovery = new TurnRecovery(createHost(model, modelRegistry, { textOutputCommitted: false })); + const message = makeMessage( + [ + { type: "text", text: "Buffered partial answer" }, + { type: "toolCall", id: "call-1", name: "bash", arguments: { command: "ls" } }, + ], + model, + ); + expect(recovery.isRetryableError(message)).toBe(false); + expect(recovery.isHardErrorFallbackEligible(message)).toBe(false); + }); + it("keeps an empty-content error retriable (baseline)", () => { const recovery = new TurnRecovery(createHost(model, modelRegistry)); const message = makeMessage([], model); diff --git a/packages/coding-agent/test/unexpected-stop-classifier.test.ts b/packages/coding-agent/test/unexpected-stop-classifier.test.ts index 26c988f71..8024fd30c 100644 --- a/packages/coding-agent/test/unexpected-stop-classifier.test.ts +++ b/packages/coding-agent/test/unexpected-stop-classifier.test.ts @@ -76,6 +76,30 @@ describe("isUnexpectedStopCandidate", () => { }); expect(isUnexpectedStopCandidate(message)).toBe(false); }); + + it("returns true for a signed thinking-only stop", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: " 响应", thinkingSignature: "reasoning_content" }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(true); + }); + + it("returns false for an unsigned thinking-only stop (empty-stop path owns it)", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: "responseAll four reviewers complete." }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(false); + }); + + it("returns false when the thinking block is only whitespace", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: " \n\t ", thinkingSignature: "reasoning_content" }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(false); + }); }); describe("classifyUnexpectedStop", () => { diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 4bf380ce0..4d731d4cc 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -380,6 +380,30 @@ describe("update-cli bun cache pruning", () => { expect(await Bun.file(path.join(dir, "pkg", "1.0.0@@@1")).exists()).toBe(true); expect(await Bun.file(path.join(dir, "pkg@1.0.0@@@1", "package.json")).exists()).toBe(true); }); + + it("compares numeric version segments without precision loss", async () => { + const dir = await makeTempDir(); + const older = "1.0.99999999999999999999"; + const newer = "1.0.100000000000000000000"; + await Bun.write(path.join(dir, "pkg", `${older}@@@1`), ""); + await Bun.write(path.join(dir, "pkg", `${newer}@@@1`), ""); + await Bun.write( + path.join(dir, `pkg@${older}@@@1`, "package.json"), + JSON.stringify({ name: "pkg", version: older }), + ); + await Bun.write( + path.join(dir, `pkg@${newer}@@@1`, "package.json"), + JSON.stringify({ name: "pkg", version: newer }), + ); + + const result = await pruneBunInstallCache(dir, new Set(["pkg"])); + + expect(result).toEqual({ scannedPackages: 1, removedEntries: 2 }); + expect(await Bun.file(path.join(dir, "pkg", `${older}@@@1`)).exists()).toBe(false); + expect(await Bun.file(path.join(dir, `pkg@${older}@@@1`, "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "pkg", `${newer}@@@1`)).exists()).toBe(true); + expect(await Bun.file(path.join(dir, `pkg@${newer}@@@1`, "package.json")).exists()).toBe(true); + }); }); describe("update-cli release binary integrity", () => { diff --git a/packages/coding-agent/test/vibe/vibe-runtime.test.ts b/packages/coding-agent/test/vibe/vibe-runtime.test.ts deleted file mode 100644 index 288d5595a..000000000 --- a/packages/coding-agent/test/vibe/vibe-runtime.test.ts +++ /dev/null @@ -1,2171 +0,0 @@ -/** - * Contracts: vibe worker-session registry lifecycle. - * - * 1. `spawn` returns immediately (session id + turn job id) while the turn - * runs in the background; the settled turn self-delivers a result carrying - * the activity trace AND the worker's response, and the session stays - * addressable (idle) afterwards. - * 2. `send` routes by state: steering into a streaming mid-turn worker, - * queueing when the worker is mid-turn but not steerable (drained into the - * next turn automatically), and starting a follow-up turn on the SAME - * worker id when idle. - * 3. `runSubagentFollowUpTurn` continues a live session in place: consecutive - * turns hit the same AgentSession instance (context retained) and the - * finalized result carries the yield payload + tool trace. - * 4. `wait` wakes on the FIRST settling turn among concurrent sessions and - * acknowledges its delivery so the result is not delivered twice. - * 5. `kill` cancels the in-flight turn job and releases the worker session. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; -import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; -import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { - SessionManager, - SessionPersistenceIndeterminateError, -} from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { - FileSessionStorage, - type SessionStorage, - type SessionStorageWriter, - type WriteTextAtomicOptions, -} from "@oh-my-pi/pi-coding-agent/session/session-storage"; -import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; -import type { AgentProgress, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types"; -import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; - -const PERSISTED_WORKER_SYSTEM_PROMPT = "Persisted vibe worker"; -const PERSISTED_WORKER_TOOLS = ["read", "yield"]; -const INITIAL_VIBE_TASK = "Complete the first persisted turn."; -const FOLLOW_UP_VIBE_TASK = "Continue from the persisted conversation."; -const RESTORED_VIBE_RESPONSE = "Continued from persisted context."; - -async function fileExists(file: string): Promise { - try { - return (await fs.stat(file)).isFile(); - } catch { - return false; - } -} - -type AtomicWriteHook = (commit: () => Promise) => Promise; - -class FaultInjectingSessionStorage extends FileSessionStorage { - readonly atomicWriteHooks: AtomicWriteHook[] = []; - atomicWriteAttempts = 0; - failedWriterClosed = false; - #appendFault: { error: Error; prefixBytes: number } | undefined; - - failNextAppendWithPrefix(error: Error, prefixBytes: number): void { - this.#appendFault = { error, prefixBytes }; - } - - override async writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise { - this.atomicWriteAttempts++; - const hook = this.atomicWriteHooks.shift(); - const commit = () => super.writeTextAtomic(filePath, content, options); - if (hook) await hook(commit); - else await commit(); - } - - override openWriter( - filePath: string, - options?: { flags?: "a" | "w"; onError?: (error: Error) => void }, - ): SessionStorageWriter { - const inner = super.openWriter(filePath, options); - let injectedError: Error | undefined; - let pendingFault: Promise = Promise.resolve(); - let faulted = false; - return { - append: line => { - const fault = this.#appendFault; - if (!fault) return inner.append(line); - this.#appendFault = undefined; - faulted = true; - const prefix = Buffer.from(line).subarray(0, fault.prefixBytes); - const operation = fs.appendFile(filePath, prefix).then(() => { - injectedError = fault.error; - options?.onError?.(fault.error); - throw fault.error; - }); - pendingFault = operation.catch(() => {}); - return operation; - }, - flush: async () => { - await pendingFault; - if (injectedError) throw injectedError; - await inner.flush(); - }, - isOpen: () => inner.isOpen(), - close: async () => { - await pendingFault; - await inner.close(); - if (faulted) this.failedWriterClosed = true; - }, - getError: () => injectedError ?? inner.getError(), - }; - } -} - -class SwitchGatedSessionStorage extends FaultInjectingSessionStorage { - #readGate: - | { - filePath: string; - started: ReturnType>; - release: ReturnType>; - } - | undefined; - - gateNextRead(filePath: string): { started: Promise; release: () => void } { - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - this.#readGate = { filePath, started, release }; - return { started: started.promise, release: release.resolve }; - } - - override async readTextSlices( - filePath: string, - prefixBytes: number, - suffixBytes: number, - ): Promise<[string, string]> { - const gate = this.#readGate; - if (gate?.filePath === filePath) { - this.#readGate = undefined; - gate.started.resolve(); - await gate.release.promise; - } - return super.readTextSlices(filePath, prefixBytes, suffixBytes); - } -} - -interface TestSessionOptions { - manager?: AsyncJobManager; - sessionManager?: SessionManager; - ownerId?: string; - parentSessionId?: string; -} - -function createSession(options: TestSessionOptions = {}): ToolSession { - const sessionManager = options.sessionManager; - return { - cwd: sessionManager?.getCwd() ?? "/tmp", - hasUI: false, - settings: Settings.isolated({}), - getSessionFile: () => sessionManager?.getSessionFile() ?? null, - getSessionId: () => options.parentSessionId ?? sessionManager?.getSessionId() ?? "vibe-test-parent", - getAgentId: () => options.ownerId ?? "Main", - getArtifactsDir: () => sessionManager?.getArtifactsDir() ?? null, - getSessionSpawns: () => "*", - sessionManager, - asyncJobManager: options.manager, - }; -} - -interface PersistWorkerOptions { - cwd: string; - artifactsDir: string; - id: string; - task: string; -} - -async function persistWorkerSession(options: PersistWorkerOptions): Promise { - const childSessionFile = path.join(options.artifactsDir, `${options.id}.jsonl`); - const childManager = SessionManager.create(options.cwd, options.artifactsDir); - await childManager.setSessionFile(childSessionFile); - childManager.appendSessionInit({ - systemPrompt: PERSISTED_WORKER_SYSTEM_PROMPT, - task: options.task, - tools: [...PERSISTED_WORKER_TOOLS], - spawns: "", - }); - await childManager.flush(); - await childManager.close(); - return childSessionFile; -} - -function makeResult(id: string, overrides: Partial = {}): SingleResult { - return { - index: 0, - id, - agent: "task", - agentSource: "bundled", - task: "prompt", - exitCode: 0, - output: "All done.", - stderr: "", - truncated: false, - durationMs: 5, - tokens: 0, - requests: 1, - ...overrides, - }; -} - -interface Deferred { - promise: Promise; - resolve: () => void; -} - -function deferred(): Deferred { - const { promise, resolve } = Promise.withResolvers(); - return { promise, resolve }; -} - -async function flushMicrotasks(): Promise { - for (let index = 0; index < 5; index++) await Promise.resolve(); -} - -async function pollUntil(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now(); - while (!predicate()) { - if (Date.now() - start > timeoutMs) throw new Error("pollUntil timed out"); - await Bun.sleep(5); - } -} - -/** - * Minimal stand-in for a worker AgentSession: records prompts/steers, replays - * a scripted event stream through subscribed listeners on each prompt, and - * reports a final assistant message — enough surface for the executor's run - * monitor + driveSessionToYield. - */ -function createFakeWorkerSession(options: { streaming?: boolean; onDispose?: () => void | Promise } = {}) { - const listeners = new Set<(event: unknown) => void>(); - const prompts: string[] = []; - const steers: string[] = []; - let disposed = false; - let lastAssistant: { stopReason: string; content: Array<{ type: string; text: string }> } | undefined; - let script: { events: unknown[]; responseText: string } | undefined; - const fake = { - isStreaming: options.streaming ?? false, - model: undefined, - subscribe(listener: (event: unknown) => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); - }, - async prompt(text: string): Promise { - prompts.push(text); - const active = script; - script = undefined; - if (active) { - for (const event of active.events) { - for (const listener of [...listeners]) listener(event); - } - lastAssistant = { stopReason: "stop", content: [{ type: "text", text: active.responseText }] }; - const end = { type: "message_end", message: { role: "assistant", content: lastAssistant.content } }; - for (const listener of [...listeners]) listener(end); - } - return true; - }, - async steer(text: string): Promise { - steers.push(text); - }, - async waitForIdle(): Promise {}, - getLastAssistantMessage() { - return lastAssistant; - }, - async abort(): Promise {}, - async dispose(): Promise { - disposed = true; - await options.onDispose?.(); - }, - }; - return { - session: fake as unknown as AgentSession, - prompts, - steers, - isDisposed: () => disposed, - setStreaming(value: boolean) { - fake.isStreaming = value; - }, - setScript(next: { events: unknown[]; responseText: string }) { - script = next; - }, - }; -} - -/** Scripted turn: one `read` tool call, then a successful `yield` carrying `data`. */ -function yieldTurnEvents(data: unknown): unknown[] { - return [ - { type: "tool_execution_start", toolName: "read", args: { path: "src/foo.ts" }, intent: "Reading foo" }, - { type: "tool_execution_end", toolName: "read", result: {}, isError: false }, - { type: "tool_execution_start", toolName: "yield", args: {} }, - { - type: "tool_execution_end", - toolName: "yield", - result: { details: { status: "success", data } }, - isError: false, - }, - ]; -} - -/** Progress snapshot in the shape the executor's run monitor emits. */ -function progressSnapshot(id: string, overrides: Partial = {}): AgentProgress { - return { - index: 0, - id, - agent: "task", - agentSource: "bundled", - status: "running", - task: "prompt", - recentTools: [], - recentOutput: [], - toolCount: 0, - requests: 0, - tokens: 0, - cost: 0, - durationMs: 0, - ...overrides, - }; -} - -describe("vibe session registry", () => { - const managers: AsyncJobManager[] = []; - const persistedManagers: SessionManager[] = []; - const tempRoots: string[] = []; - - async function createPersistedParent(storage?: SessionStorage): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-vibe-resume-")); - tempRoots.push(root); - const cwd = path.join(root, "workspace"); - await fs.mkdir(cwd, { recursive: true }); - const manager = SessionManager.create(cwd, path.join(root, "sessions"), storage); - persistedManagers.push(manager); - return manager; - } - - function createManager(): AsyncJobManager { - const manager = new AsyncJobManager({ onJobComplete: () => {} }); - managers.push(manager); - return manager; - } - - function installPersistedSpawnMock(): void { - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const parentSessionFile = options.sessionFile; - if (!parentSessionFile) throw new Error("Persisted vibe test requires a parent session file"); - const snapshot = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - try { - const actions = snapshot.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return data.id === options.id && typeof data.action === "string" ? [data.action] : []; - }); - expect(actions).toEqual(["spawn", "turn-started"]); - } finally { - await snapshot.close(); - } - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - AgentRegistry.global().setStatus(options.id, "idle"); - AgentLifecycleManager.global().adopt(options.id, { - idleTtlMs: 0, - revive: async () => worker.session, - }); - return makeResult(options.id, { output: "Persisted first turn." }); - }); - } - - function installPersistedReviver(capture: { sessionFile?: string; prompts?: string[] }): void { - AgentLifecycleManager.global().setPersistedSubagentReviverFactory(async ref => { - if (!ref.sessionFile) return undefined; - const persisted = await SessionManager.peekSessionInit(ref.sessionFile); - if (!persisted?.init) return undefined; - const worker = createFakeWorkerSession(); - worker.prompts.push(persisted.init.task); - worker.setScript({ - events: yieldTurnEvents({ report: RESTORED_VIBE_RESPONSE }), - responseText: RESTORED_VIBE_RESPONSE, - }); - capture.sessionFile = ref.sessionFile; - capture.prompts = worker.prompts; - return async () => worker.session; - }, 0); - } - - async function simulateProcessBoundary(): Promise { - await AgentLifecycleManager.global().dispose(); - VibeSessionRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - AgentRegistry.resetGlobalForTests(); - } - - beforeEach(() => { - AgentRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - VibeSessionRegistry.resetGlobalForTests(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - for (const manager of managers.splice(0)) { - await manager.dispose({ timeoutMs: 1000 }); - } - for (const manager of persistedManagers.splice(0)) { - await manager.close(); - } - for (const root of tempRoots.splice(0)) { - await fs.rm(root, { recursive: true, force: true }); - } - VibeSessionRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - AgentRegistry.resetGlobalForTests(); - }); - - it("spawn returns immediately and self-delivers a turn result with activity trace + response", async () => { - const gate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - options.onProgress?.( - progressSnapshot(options.id, { - toolCount: 2, - recentTools: [ - { tool: "bash", args: "bun test", endMs: 2 }, - { tool: "read", args: "src/foo.ts", endMs: 1 }, - ], - lastIntent: "Running tests", - resolvedModel: "prov/fast-model", - }), - ); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "Implemented the widget.", requests: 3 }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - - const { id, jobId } = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Build the widget." }); - expect(id).toBe("Fast"); - - // Ack is immediate: the job is still running behind the gate. - const job = manager.getJob(jobId)!; - expect(job.status).toBe("running"); - expect(registry.screens(session)[0]?.cli).toBe("fast"); - - gate.resolve(); - await job.promise; - - expect(job.status).toBe("completed"); - const text = job.resultText ?? ""; - // Envelope + summarized activity (compressed tool trace, oldest first) + response. - expect(text).toContain(' { - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const originalFlush = parentManager.flush.bind(parentManager); - const flush = vi - .spyOn(parentManager, "flush") - .mockImplementationOnce(originalFlush) - .mockRejectedValueOnce(new Error("tombstone write failed")); - const register = vi.spyOn(jobs, "register").mockImplementation(() => { - throw new Error("job registry failed"); - }); - - await expect( - VibeSessionRegistry.global().spawn(session, { - cli: "fast", - name: "failed-spawn", - prompt: INITIAL_VIBE_TASK, - }), - ).rejects.toThrow("tombstone write failed"); - - expect(VibeSessionRegistry.global().screens(session)[0]).toMatchObject({ - id: "failed-spawn", - state: "dead", - lastActivity: "spawn failed", - }); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - flush.mockRestore(); - register.mockRestore(); - expect(await VibeSessionRegistry.global().killAll(session)).toBe(1); - expect(VibeSessionRegistry.global().listIds(session)).toEqual([]); - expect(parentManager.buildSessionContext().mode).toBe("none"); - }); - - it("send steers a streaming mid-turn worker and queues for a non-steerable one", async () => { - const gate = deferred(); - const fake = createFakeWorkerSession({ streaming: true }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id); - }); - const followUps: Array<{ id: string; message: string }> = []; - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - followUps.push({ id: options.id, message: options.message }); - return makeResult(options.id, { output: "queued work done" }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { cli: "good", name: "Good", prompt: "Design it." }); - await pollUntil(() => AgentRegistry.global().get("Good") !== undefined); - - // Streaming worker → steering. - const steered = await registry.send(session, { session: "Good", message: "Focus on the API first." }); - expect(steered.mode).toBe("steered"); - expect(fake.steers).toEqual(["Focus on the API first."]); - - // Not streaming → queued for the next turn. - fake.setStreaming(false); - const queued = await registry.send(session, { session: "Good", message: "Then write tests." }); - expect(queued.mode).toBe("queued"); - expect(registry.screens(session)[0]?.queued).toBe(1); - - // Settling the turn drains the queue into an automatic follow-up turn. - gate.resolve(); - await manager.getJob(jobId)!.promise; - await pollUntil(() => followUps.length === 1); - expect(followUps[0]).toEqual({ id: "Good", message: "Then write tests." }); - }); - - it("send to an idle session starts a follow-up turn on the same worker", async () => { - const gate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id); - }); - const followUps: Array<{ id: string; message: string }> = []; - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - followUps.push({ id: options.id, message: options.message }); - options.onProgress?.( - progressSnapshot(options.id, { - toolCount: 1, - recentTools: [{ tool: "edit", args: "src/foo.ts", endMs: 1 }], - }), - ); - return makeResult(options.id, { output: "Renamed everything." }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const spawn = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "First task." }); - gate.resolve(); - await manager.getJob(spawn.jobId)!.promise; - - const outcome = await registry.send(session, { session: "Fast", message: "Now rename the helpers." }); - expect(outcome.mode).toBe("turn"); - const turnJob = manager.getJob(outcome.jobId!)!; - await turnJob.promise; - - expect(followUps).toEqual([{ id: "Fast", message: "Now rename the helpers." }]); - const text = turnJob.resultText ?? ""; - expect(text).toContain('turn="2"'); - expect(text).toContain("edit(src/foo.ts)"); - expect(text).toContain("Renamed everything."); - expect(registry.screens(session)[0]?.turns).toBe(2); - }); - - it("rehydrates an idle worker after a process boundary and continues turn two with prior context", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - expect(await fileExists(parentSessionFile)).toBe(false); - const firstManager = createManager(); - const firstSession = createSession({ manager: firstManager, sessionManager: parentManager }); - const firstRegistry = VibeSessionRegistry.global(); - const spawned = await firstRegistry.spawn(firstSession, { - cli: "fast", - name: "push-fixes", - prompt: INITIAL_VIBE_TASK, - }); - expect(await fileExists(parentSessionFile)).toBe(true); - await firstManager.getJob(spawned.jobId)!.promise; - expect(firstRegistry.screens(firstSession)[0]).toMatchObject({ id: "push-fixes", state: "idle", turns: 1 }); - - const wrongOwner = createSession({ manager: firstManager, sessionManager: parentManager, ownerId: "Other" }); - await expect( - firstRegistry.send(wrongOwner, { session: "push-fixes", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "push-fixes"'); - const wrongParent = createSession({ - manager: firstManager, - sessionManager: parentManager, - parentSessionId: "different-parent", - }); - await expect( - firstRegistry.send(wrongParent, { session: "push-fixes", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "push-fixes"'); - - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: resumedManager }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - - const resumedRegistry = VibeSessionRegistry.global(); - expect(await resumedRegistry.rehydrate(resumedSession)).toBe(1); - expect(AgentRegistry.global().get("push-fixes")?.status).toBe("parked"); - expect(resumedRegistry.screens(resumedSession)[0]).toMatchObject({ - id: "push-fixes", - state: "idle", - turns: 1, - }); - - const outcome = await resumedRegistry.send(resumedSession, { - session: "push-fixes", - message: FOLLOW_UP_VIBE_TASK, - }); - expect(outcome.mode).toBe("turn"); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.sessionFile).toBe(path.join(parentSessionFile.slice(0, -6), "push-fixes.jsonl")); - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - expect(turnJob.resultText).toContain(RESTORED_VIBE_RESPONSE); - }); - - it("suspends an idle persisted worker for same-process disposal and resumes turn two", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(firstSession, { - cli: "fast", - name: "idle-dispose", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(spawned.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(firstSession), firstJobs)).toBe(1); - expect(AgentRegistry.global().get("idle-dispose")).toBeUndefined(); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).action === "tombstone"; - }), - ).toBe(false); - await parentManager.close(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: reopened }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - expect(await registry.rehydrate(resumedSession)).toBe(1); - const outcome = await registry.send(resumedSession, { - session: "idle-dispose", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("suspends a blocked in-flight worker for fresh-process disposal without tombstoning it", async () => { - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Interrupted by disposal.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(firstSession, { - cli: "fast", - name: "running-dispose", - prompt: INITIAL_VIBE_TASK, - }); - await pollUntil(() => AgentRegistry.global().get("running-dispose")?.status === "running"); - expect(await registry.suspendScope(registry.ownerScope(firstSession), firstJobs)).toBe(1); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).action === "tombstone"; - }), - ).toBe(false); - await parentManager.close(); - await simulateProcessBoundary(); - - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: reopened }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - const freshRegistry = VibeSessionRegistry.global(); - expect(await freshRegistry.rehydrate(resumedSession)).toBe(1); - expect(freshRegistry.screens(resumedSession)[0]).toMatchObject({ - id: "running-dispose", - state: "idle", - turns: 1, - lastActivity: "turn 1 interrupted by process restart", - }); - const outcome = await freshRegistry.send(resumedSession, { - session: "running-dispose", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("bounds parent-session suspension when a cancelled turn ignores abort and settles late", async () => { - const gate = deferred(); - const started = deferred(); - const disposed = deferred(); - const fake = createFakeWorkerSession({ onDispose: disposed.resolve }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - started.resolve(); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { - cli: "fast", - name: "IgnoresSuspendAbort", - prompt: "Keep working through a parent-session switch.", - }); - await started.promise; - - vi.useFakeTimers(); - try { - const suspension = registry.suspendScope(registry.ownerScope(session), manager); - await disposed.promise; - await flushMicrotasks(); - expect(vi.getTimerCount()).toBeGreaterThan(0); - vi.advanceTimersByTime(250); - - expect(await suspension).toBe(1); - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresSuspendAbort")).toBeUndefined(); - expect(registry.listIds(session)).toEqual([]); - - gate.resolve(); - await manager.getJob(jobId)!.promise; - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresSuspendAbort")).toBeUndefined(); - expect(registry.listIds(session)).toEqual([]); - } finally { - gate.resolve(); - vi.useRealTimers(); - } - }); - - it("restores an interrupted turn as idle without replay and continues only after send", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const spawned = await VibeSessionRegistry.global().spawn(firstSession, { - cli: "fast", - name: "interrupted", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(spawned.jobId)!.promise; - const turnStarted = parentManager.getEntries().find(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "interrupted" && data.action === "turn-started"; - }); - if (!turnStarted) throw new Error("Expected a persisted turn-started lifecycle event"); - parentManager.branch(turnStarted.id); - parentManager.appendModeChange("vibe"); - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: resumedManager }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - const registry = VibeSessionRegistry.global(); - expect(await registry.rehydrate(resumedSession)).toBe(1); - expect(registry.screens(resumedSession)[0]).toMatchObject({ - id: "interrupted", - state: "idle", - turns: 1, - lastActivity: "turn 1 interrupted by process restart", - }); - expect(revived).toEqual({}); - - const outcome = await registry.send(resumedSession, { - session: "interrupted", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("never lets an old lifecycle append cross into a target session during its load window", async () => { - installPersistedSpawnMock(); - const storage = new SwitchGatedSessionStorage(); - const sourceManager = await createPersistedParent(storage); - sourceManager.appendModeChange("vibe"); - const sourceSessionId = sourceManager.getSessionId(); - const sourceSessionFile = sourceManager.getSessionFile(); - if (!sourceSessionFile) throw new Error("Persisted source session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: sourceManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "switch-race", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const targetManager = await createPersistedParent(storage); - targetManager.appendModeChange("goal"); - targetManager.appendCustomEntry("target-only"); - await targetManager.ensureOnDisk(); - const targetSessionFile = targetManager.getSessionFile(); - if (!targetSessionFile) throw new Error("Persisted target session file was not created"); - await targetManager.close(); - - const lifecycleAppendStarted = Promise.withResolvers(); - const releaseLifecycleAppend = Promise.withResolvers(); - const originalEnsureOnDisk = sourceManager.ensureOnDisk.bind(sourceManager); - let gateLifecycleAppend = true; - const ensureOnDisk = vi.spyOn(sourceManager, "ensureOnDisk").mockImplementation(async () => { - if (gateLifecycleAppend) { - gateLifecycleAppend = false; - lifecycleAppendStarted.resolve(); - await releaseLifecycleAppend.promise; - } - await originalEnsureOnDisk(); - }); - const followUp = await registry.send(session, { - session: "switch-race", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = jobs.getJob(followUp.jobId!)!; - await lifecycleAppendStarted.promise; - - const targetRead = storage.gateNextRead(targetSessionFile); - const switching = sourceManager.setSessionFile(targetSessionFile); - await targetRead.started; - releaseLifecycleAppend.resolve(); - await turnJob.promise.catch(() => undefined); - targetRead.release(); - await switching; - ensureOnDisk.mockRestore(); - - expect(turnJob.status).toBe("failed"); - expect(sourceManager.buildSessionContext().mode).toBe("goal"); - expect( - sourceManager - .getBranch() - .map(entry => - entry.type === "mode_change" - ? `mode:${entry.mode}` - : `custom:${entry.type === "custom" ? entry.customType : entry.type}`, - ), - ).toEqual(["mode:goal", "custom:target-only"]); - expect( - sourceManager.getEntries().some(entry => { - if (entry.type !== "custom" || entry.customType !== "vibe-session-lifecycle") return false; - if (typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).parentSessionId === sourceSessionId; - }), - ).toBe(false); - expect(await fs.readFile(targetSessionFile, "utf8")).not.toContain(sourceSessionId); - }); - - it("never sends through or releases a registry ref with the wrong child session file", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "exact-ref", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - await AgentLifecycleManager.global().release("exact-ref"); - const mismatchedFile = path.join(parentSessionFile.slice(0, -6), "another-parent.jsonl"); - AgentRegistry.global().register({ - id: "exact-ref", - displayName: "exact-ref", - kind: "sub", - parentId: "Main", - session: null, - sessionFile: mismatchedFile, - status: "parked", - }); - - await expect(registry.send(session, { session: "exact-ref", message: FOLLOW_UP_VIBE_TASK })).rejects.toThrow( - "no longer resolves to this parent session", - ); - await registry.kill(session, "exact-ref"); - expect(AgentRegistry.global().get("exact-ref")).toMatchObject({ - status: "parked", - sessionFile: mismatchedFile, - }); - }); - - it("suspends a parent without tombstones so the same worker id can move between isolated scopes", async () => { - installPersistedSpawnMock(); - const parentA = await createPersistedParent(); - const parentB = await createPersistedParent(); - parentA.appendModeChange("vibe"); - parentB.appendModeChange("vibe"); - const jobsA = createManager(); - const jobsB = createManager(); - const sessionA = createSession({ manager: jobsA, sessionManager: parentA }); - const sessionB = createSession({ manager: jobsB, sessionManager: parentB }); - const registry = VibeSessionRegistry.global(); - const workerA = await registry.spawn(sessionA, { - cli: "fast", - name: "shared-name", - prompt: INITIAL_VIBE_TASK, - }); - await jobsA.getJob(workerA.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(sessionA), jobsA)).toBe(1); - expect(AgentRegistry.global().get("shared-name")).toBeUndefined(); - - const workerB = await registry.spawn(sessionB, { - cli: "fast", - name: "shared-name", - prompt: INITIAL_VIBE_TASK, - }); - expect(workerB.id).toBe("shared-name"); - await jobsB.getJob(workerB.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(sessionB), jobsB)).toBe(1); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - expect(await registry.rehydrate(sessionA)).toBe(1); - expect(registry.listIds(sessionA)).toEqual(["shared-name"]); - await expect(registry.send(sessionB, { session: "shared-name", message: FOLLOW_UP_VIBE_TASK })).rejects.toThrow( - 'Unknown vibe session "shared-name"', - ); - - const outcome = await registry.send(sessionA, { - session: "shared-name", - message: FOLLOW_UP_VIBE_TASK, - }); - await jobsA.getJob(outcome.jobId!)!.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - }); - - it("does not let late teardown from a suspended parent mutate a new same-id worker", async () => { - const parentA = await createPersistedParent(); - const parentB = await createPersistedParent(); - parentA.appendModeChange("vibe"); - parentB.appendModeChange("vibe"); - const parentAFile = parentA.getSessionFile(); - if (!parentAFile) throw new Error("Persisted parent A session file was not created"); - let workerA: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - if (options.sessionFile === parentAFile) workerA = worker; - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - if (options.sessionFile === parentAFile) { - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Parent A suspended.", aborted: true }); - } - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "Parent B finished." }); - }); - const jobsA = createManager(); - const jobsB = createManager(); - const sessionA = createSession({ manager: jobsA, sessionManager: parentA }); - const sessionB = createSession({ manager: jobsB, sessionManager: parentB }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(sessionA, { cli: "fast", name: "reused", prompt: INITIAL_VIBE_TASK }); - await pollUntil(() => workerA !== undefined); - const oldRef = AgentRegistry.global().get("reused"); - if (!oldRef) throw new Error("Expected parent A worker ref"); - expect(await registry.suspendScope(registry.ownerScope(sessionA), jobsA)).toBe(1); - - const second = await registry.spawn(sessionB, { cli: "fast", name: "reused", prompt: INITIAL_VIBE_TASK }); - await jobsB.getJob(second.jobId)!.promise; - const replacement = AgentRegistry.global().get("reused"); - expect(replacement).toMatchObject({ status: "idle" }); - expect(replacement).not.toBe(oldRef); - - await executorModule.finalizeSubagentLifecycle({ - id: "reused", - session: workerA!.session, - aborted: true, - keepAlive: true, - isolated: false, - agentIdleTtlMs: 0, - reviveSession: null, - }); - await AgentLifecycleManager.global().release("reused", oldRef); - expect(AgentRegistry.global().get("reused")).toBe(replacement); - expect(registry.screens(sessionB)[0]).toMatchObject({ id: "reused", state: "idle" }); - }); - - it("rejects escaped child paths and JSONL files without persisted worker init", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const spawned = await VibeSessionRegistry.global().spawn(session, { - cli: "fast", - name: "valid-worker", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const lifecycleEntries = parentManager.getEntries().filter(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).id === "valid-worker"; - }); - if (lifecycleEntries.length < 3) throw new Error("Expected complete persisted worker lifecycle events"); - const artifactsDir = parentSessionFile.slice(0, -6); - const invalidManager = SessionManager.create(parentManager.getCwd(), artifactsDir); - persistedManagers.push(invalidManager); - await invalidManager.ensureOnDisk(); - const invalidSource = invalidManager.getSessionFile(); - if (!invalidSource) throw new Error("Expected an invalid child fixture path"); - await invalidManager.close(); - await fs.rename(invalidSource, path.join(artifactsDir, "invalid-init.jsonl")); - - for (const { id, childSessionFile } of [ - { id: "escaped", childSessionFile: "../escaped.jsonl" }, - { id: "invalid-init", childSessionFile: "invalid-init.jsonl" }, - ]) { - for (const entry of lifecycleEntries) { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) continue; - const data: Record = { ...(entry.data as Record), id }; - if (data.action === "spawn") data.childSessionFile = childSessionFile; - parentManager.appendCustomEntry(entry.customType, data); - } - } - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedSession = createSession({ manager: createManager(), sessionManager: resumedManager }); - const registry = VibeSessionRegistry.global(); - expect(await registry.rehydrate(resumedSession)).toBe(1); - expect(registry.listIds(resumedSession)).toEqual(["valid-worker"]); - expect(AgentRegistry.global().get("escaped")).toBeUndefined(); - expect(AgentRegistry.global().get("invalid-init")).toBeUndefined(); - }); - - it("reserves orphan JSONL and lifecycle-known ids before allocating a worker name", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const artifactsDir = parentSessionFile.slice(0, -6); - await fs.mkdir(artifactsDir, { recursive: true }); - await fs.writeFile(path.join(artifactsDir, "orphan.jsonl"), "orphaned transcript"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const orphanCollision = await registry.spawn(firstSession, { - cli: "fast", - name: "orphan", - prompt: INITIAL_VIBE_TASK, - }); - expect(orphanCollision.id).toBe("orphan-2"); - await firstJobs.getJob(orphanCollision.jobId)!.promise; - - const metadataOnly = await registry.spawn(firstSession, { - cli: "fast", - name: "metadata-only", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(metadataOnly.jobId)!.promise; - await registry.kill(firstSession, metadataOnly.id); - await fs.rm(path.join(artifactsDir, "metadata-only.jsonl"), { force: true }); - await simulateProcessBoundary(); - - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: parentManager }); - const metadataCollision = await VibeSessionRegistry.global().spawn(resumedSession, { - cli: "fast", - name: "metadata-only", - prompt: INITIAL_VIBE_TASK, - }); - expect(metadataCollision.id).toBe("metadata-only-2"); - await resumedJobs.getJob(metadataCollision.jobId)!.promise; - }); - - it("retains a cold candidate blocked by another parent id collision so mode exit can tombstone it", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "collision", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - await registry.suspendScope(registry.ownerScope(session), jobs); - const otherWorker = createFakeWorkerSession(); - const otherSessionFile = path.join(path.dirname(parentSessionFile), "other-parent", "collision.jsonl"); - const otherRef = AgentRegistry.global().register({ - id: "collision", - displayName: "collision", - kind: "sub", - parentId: "Main", - session: otherWorker.session, - sessionFile: otherSessionFile, - status: "idle", - }); - - expect(await registry.rehydrate(session)).toBe(1); - expect(registry.screens(session)[0]?.lastActivity).toBe("blocked by an agent id collision"); - expect(await registry.killAll(session)).toBe(1); - expect(AgentRegistry.global().get("collision")).toBe(otherRef); - expect(otherWorker.isDisposed()).toBe(false); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "collision" && data.action === "tombstone"; - }), - ).toBe(true); - - AgentRegistry.global().unregister("collision", otherRef); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("collision")).toMatchObject({ status: "aborted", session: null }); - }); - - it("rejects a spawn queued behind mode exit and leaves no live or untombstoned worker", async () => { - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - await parentManager.ensureOnDisk(); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const exitWriteStarted = Promise.withResolvers(); - const releaseExitWrite = Promise.withResolvers(); - storage.atomicWriteHooks.push(async commit => { - exitWriteStarted.resolve(); - await releaseExitWrite.promise; - await commit(); - }); - - const exiting = registry.killAll(session); - await exitWriteStarted.promise; - let spawnSettled = false; - const lateSpawn = registry - .spawn(session, { cli: "fast", name: "late-after-exit", prompt: INITIAL_VIBE_TASK }) - .then( - () => ({ error: undefined }), - error => ({ error }), - ) - .finally(() => { - spawnSettled = true; - }); - await flushMicrotasks(); - expect(spawnSettled).toBe(false); - - releaseExitWrite.resolve(); - expect(await exiting).toBe(0); - const { error } = await lateSpawn; - expect(error).toBeInstanceOf(Error); - expect(String(error)).toContain("Vibe mode has exited"); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("late-after-exit")).toBeUndefined(); - expect(parentManager.buildSessionContext().mode).toBe("none"); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "late-after-exit" && data.action === "spawn"; - }), - ).toBe(false); - }); - - it("keeps mode and workers live after a real atomic mode-exit failure, then retries cleanly", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const first = await registry.spawn(session, { - cli: "fast", - name: "retry-exit-one", - prompt: INITIAL_VIBE_TASK, - }); - const second = await registry.spawn(session, { - cli: "good", - name: "retry-exit-two", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([jobs.getJob(first.jobId)!.promise, jobs.getJob(second.jobId)!.promise]); - const firstRef = AgentRegistry.global().get("retry-exit-one"); - const secondRef = AgentRegistry.global().get("retry-exit-two"); - if (!firstRef || !secondRef) throw new Error("Expected both live worker refs"); - await parentManager.flush(); - const beforeBytes = await fs.readFile(parentSessionFile); - const beforeSize = (await fs.stat(parentSessionFile)).size; - storage.atomicWriteHooks.push(async () => { - throw Object.assign(new Error("atomic publish unavailable"), { code: "ENOSPC" }); - }); - - await expect(registry.killAll(session)).rejects.toThrow("atomic publish unavailable"); - - expect((await fs.stat(parentSessionFile)).size).toBe(beforeSize); - expect(await fs.readFile(parentSessionFile)).toEqual(beforeBytes); - expect(registry.screens(session).map(screen => [screen.id, screen.state])).toEqual([ - ["retry-exit-one", "idle"], - ["retry-exit-two", "idle"], - ]); - expect(AgentRegistry.global().get("retry-exit-one")?.status).toBe("idle"); - expect(AgentRegistry.global().get("retry-exit-two")?.status).toBe("idle"); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - const failedExitActions = parentManager.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return typeof data.id === "string" && data.id.startsWith("retry-exit-") && data.action === "tombstone" - ? [data.action] - : []; - }); - expect(failedExitActions).toEqual([]); - - VibeSessionRegistry.resetGlobalForTests(); - const reloadedRegistry = VibeSessionRegistry.global(); - expect(await reloadedRegistry.rehydrate(session)).toBe(2); - expect(reloadedRegistry.listIds(session)).toEqual(["retry-exit-one", "retry-exit-two"]); - expect(AgentRegistry.global().get("retry-exit-one")).toBe(firstRef); - expect(AgentRegistry.global().get("retry-exit-two")).toBe(secondRef); - - expect(await reloadedRegistry.killAll(session)).toBe(2); - expect(reloadedRegistry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("retry-exit-one")?.status).toBe("aborted"); - expect(AgentRegistry.global().get("retry-exit-two")?.status).toBe("aborted"); - expect(parentManager.buildSessionContext().mode).toBe("none"); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(reopened.buildSessionContext().mode).toBe("none"); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("retry-exit-one")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("retry-exit-two")).toMatchObject({ status: "aborted", session: null }); - }); - - it("fail-closes workers when mode-exit rollback durability is indeterminate", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "indeterminate-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - storage.atomicWriteHooks.push( - async commit => { - await commit(); - throw new Error("mode exit committed but acknowledgement failed"); - }, - async () => { - throw new Error("authoritative rollback publish failed"); - }, - ); - - const failure = await registry.killAll(session).catch(error => error); - - expect(failure).toBeInstanceOf(SessionPersistenceIndeterminateError); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("indeterminate-exit")).toMatchObject({ status: "aborted", session: null }); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - const durableText = await fs.readFile(parentSessionFile, "utf8"); - expect(durableText).toContain('"reason":"mode-exit"'); - expect(durableText).toContain('"mode":"none"'); - - await parentManager.recoverPersistenceFromCurrentState(); - expect(await registry.killAll(session)).toBe(1); - expect(parentManager.buildSessionContext().mode).toBe("none"); - }); - - it("tears down and repairs a partial writer failure while persisting an explicit tombstone", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "explicit-io-failure", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - const liveRef = AgentRegistry.global().get("explicit-io-failure"); - if (!liveRef?.session) throw new Error("Expected a live worker ref"); - const dispose = vi.spyOn(liveRef.session, "dispose"); - await parentManager.flush(); - const beforeSize = (await fs.stat(parentSessionFile)).size; - storage.failNextAppendWithPrefix(Object.assign(new Error("explicit tombstone ENOSPC"), { code: "ENOSPC" }), 23); - - await expect(registry.kill(session, "explicit-io-failure")).rejects.toThrow("explicit tombstone ENOSPC"); - - expect(dispose).toHaveBeenCalled(); - expect(storage.failedWriterClosed).toBe(true); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("explicit-io-failure")).toMatchObject({ status: "aborted", session: null }); - await parentManager.flush(); - expect((await fs.stat(parentSessionFile)).size).toBeGreaterThan(beforeSize); - const repairedLines = (await fs.readFile(parentSessionFile, "utf8")).trimEnd().split("\n"); - expect(repairedLines.every(line => Boolean(JSON.parse(line)))).toBe(true); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "explicit-io-failure" && data.action === "tombstone" && data.reason === "explicit-kill"; - }), - ).toBe(true); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("explicit-io-failure")).toMatchObject({ status: "aborted", session: null }); - }); - - it("serializes explicit kill ahead of a failing mode exit so rollback cannot erase it", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const explicit = await registry.spawn(session, { - cli: "fast", - name: "overlap-explicit", - prompt: INITIAL_VIBE_TASK, - }); - const survivor = await registry.spawn(session, { - cli: "good", - name: "overlap-survivor", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([jobs.getJob(explicit.jobId)!.promise, jobs.getJob(survivor.jobId)!.promise]); - - const originalFlush = parentManager.flush.bind(parentManager); - const explicitFlushStarted = Promise.withResolvers(); - const releaseExplicitFlush = Promise.withResolvers(); - const explicitTeardownStarted = Promise.withResolvers(); - const releaseExplicitTeardown = Promise.withResolvers(); - const lifecycle = AgentLifecycleManager.global(); - const originalRelease = lifecycle.release.bind(lifecycle); - const releaseSpy = vi.spyOn(lifecycle, "release").mockImplementation(async (id, expected) => { - if (id === "overlap-explicit") { - explicitTeardownStarted.resolve(); - await releaseExplicitTeardown.promise; - } - return originalRelease(id, expected); - }); - let flushCalls = 0; - const flush = vi.spyOn(parentManager, "flush").mockImplementation(async () => { - flushCalls++; - if (flushCalls === 1) { - explicitFlushStarted.resolve(); - await releaseExplicitFlush.promise; - await originalFlush(); - return; - } - if (flushCalls === 2) throw new Error("overlapping mode exit failed"); - await originalFlush(); - }); - - const explicitKill = registry.kill(session, "overlap-explicit"); - await explicitFlushStarted.promise; - const failedExit = registry.killAll(session).catch(error => error); - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseExplicitFlush.resolve(); - await explicitTeardownStarted.promise; - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseExplicitTeardown.resolve(); - expect((await explicitKill).id).toBe("overlap-explicit"); - const exitError = await failedExit; - expect(exitError).toBeInstanceOf(Error); - expect(String(exitError)).toContain("overlapping mode exit failed"); - expect(flushCalls).toBe(2); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - expect(AgentRegistry.global().get("overlap-explicit")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("overlap-survivor")?.status).toBe("idle"); - - flush.mockRestore(); - releaseSpy.mockRestore(); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(1); - expect(AgentRegistry.global().get("overlap-explicit")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("overlap-survivor")).toMatchObject({ status: "parked", session: null }); - }); - - it("serializes overlapping mode exits so a later success cannot be revoked by an earlier failure", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "overlap-mode-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const originalFlush = parentManager.flush.bind(parentManager); - const firstExitFlushStarted = Promise.withResolvers(); - const releaseFirstExitFlush = Promise.withResolvers(); - let flushCalls = 0; - const flush = vi.spyOn(parentManager, "flush").mockImplementation(async () => { - flushCalls++; - if (flushCalls === 1) { - firstExitFlushStarted.resolve(); - await releaseFirstExitFlush.promise; - throw new Error("first mode exit failed"); - } - await originalFlush(); - }); - - const firstExit = registry.killAll(session).catch(error => error); - await firstExitFlushStarted.promise; - const successfulExit = registry.killAll(session); - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseFirstExitFlush.resolve(); - const firstError = await firstExit; - expect(firstError).toBeInstanceOf(Error); - expect(String(firstError)).toContain("first mode exit failed"); - expect(await successfulExit).toBe(1); - expect(flushCalls).toBe(2); - expect(parentManager.buildSessionContext().mode).toBe("none"); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("overlap-mode-exit")).toMatchObject({ status: "aborted", session: null }); - expect(await registry.killAll(session)).toBe(0); - expect(flushCalls).toBe(2); - - flush.mockRestore(); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("overlap-mode-exit")).toMatchObject({ status: "aborted", session: null }); - }); - - it("persists mode none when re-exiting a rewound pre-exit vibe branch", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "rewound-mode-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - const preExitLeaf = parentManager.getLeafId(); - if (!preExitLeaf) throw new Error("Expected a pre-exit Vibe branch leaf"); - - expect(await registry.killAll(session)).toBe(1); - expect(parentManager.buildSessionContext().mode).toBe("none"); - parentManager.branch(preExitLeaf); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - expect(await registry.rehydrate(session)).toBe(0); - expect(registry.listIds(session)).toEqual([]); - - expect(await registry.killAll(session)).toBe(0); - expect(parentManager.buildSessionContext().mode).toBe("none"); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - expect(reopened.buildSessionContext().mode).toBe("none"); - }); - - it("does not rehydrate workers tombstoned by explicit kill or deliberate mode exit", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstManager = createManager(); - const firstSession = createSession({ manager: firstManager, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const explicitlyKilled = await registry.spawn(firstSession, { - cli: "fast", - name: "explicitly-killed", - prompt: INITIAL_VIBE_TASK, - }); - const modeExited = await registry.spawn(firstSession, { - cli: "good", - name: "mode-exited", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([ - firstManager.getJob(explicitlyKilled.jobId)!.promise, - firstManager.getJob(modeExited.jobId)!.promise, - ]); - const preKillLeaf = parentManager.getLeafId(); - if (!preKillLeaf) throw new Error("Expected a persisted lifecycle leaf before kill"); - expect((await registry.kill(firstSession, "explicitly-killed")).cancelledTurn).toBe(false); - parentManager.appendCustomEntry("vibe-session-lifecycle", { - version: 1, - action: "tombstone-revoked", - id: "explicitly-killed", - ownerId: "Main", - parentSessionId: parentManager.getSessionId(), - reason: "mode-exit", - }); - await parentManager.flush(); - expect(await registry.killAll(firstSession)).toBe(1); - const killedSnapshot = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - try { - const tombstonedIds = killedSnapshot.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return data.action === "tombstone" && typeof data.id === "string" ? [data.id] : []; - }); - expect(tombstonedIds.toSorted()).toEqual(["explicitly-killed", "mode-exited"]); - } finally { - await killedSnapshot.close(); - } - parentManager.branch(preKillLeaf); - - parentManager.appendModeChange("none"); - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - const artifactsDir = parentSessionFile.slice(0, -6); - for (const id of ["explicitly-killed", "mode-exited"]) { - AgentRegistry.global().register({ - id, - displayName: id, - kind: "sub", - parentId: "Main", - session: null, - sessionFile: path.join(artifactsDir, `${id}.jsonl`), - status: "parked", - }); - } - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - expect(resumedManager.buildSessionContext().mode).toBe("none"); - const resumedSession = createSession({ manager: createManager(), sessionManager: resumedManager }); - const resumedRegistry = VibeSessionRegistry.global(); - expect(await resumedRegistry.rehydrate(resumedSession)).toBe(0); - expect(resumedRegistry.listIds(resumedSession)).toEqual([]); - const explicitRef = AgentRegistry.global().get("explicitly-killed"); - const modeExitRef = AgentRegistry.global().get("mode-exited"); - expect(explicitRef).toMatchObject({ status: "aborted", session: null }); - expect(modeExitRef).toMatchObject({ status: "aborted", session: null }); - if (!explicitRef?.sessionFile || !modeExitRef?.sessionFile) { - throw new Error("Tombstoned workers must retain readable transcript references"); - } - expect((await SessionManager.peekSessionInit(explicitRef.sessionFile))?.init?.task).toBe(INITIAL_VIBE_TASK); - expect((await SessionManager.peekSessionInit(modeExitRef.sessionFile))?.init?.task).toBe(INITIAL_VIBE_TASK); - await expect(AgentLifecycleManager.global().ensureLive("explicitly-killed")).rejects.toThrow( - "aborted and cannot be revived", - ); - await expect( - resumedRegistry.send(resumedSession, { session: "explicitly-killed", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "explicitly-killed"'); - await expect( - resumedRegistry.send(resumedSession, { session: "mode-exited", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "mode-exited"'); - }); - - it("runSubagentFollowUpTurn continues the same live session and finalizes trace + yield response", async () => { - const fake = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: "Worker", - displayName: "Worker", - kind: "sub", - parentId: "Main", - session: fake.session, - status: "idle", - }); - const agent = { name: "task", description: "worker", systemPrompt: "sp", source: "bundled" as const }; - - fake.setScript({ events: yieldTurnEvents({ report: "did the first thing" }), responseText: "first summary" }); - const progressSnapshots: AgentProgress[] = []; - const first = await executorModule.runSubagentFollowUpTurn({ - id: "Worker", - agent, - message: "do the first thing", - onProgress: progress => progressSnapshots.push({ ...progress, recentTools: progress.recentTools.slice() }), - }); - expect(first.exitCode).toBe(0); - expect(first.output).toContain("did the first thing"); - expect(progressSnapshots.some(progress => progress.recentTools.some(entry => entry.tool === "read"))).toBe(true); - - // Second turn lands on the SAME session instance — prior context retained. - fake.setScript({ events: yieldTurnEvents({ report: "built on prior work" }), responseText: "second summary" }); - const second = await executorModule.runSubagentFollowUpTurn({ id: "Worker", agent, message: "now extend it" }); - expect(second.exitCode).toBe(0); - expect(second.output).toContain("built on prior work"); - expect(fake.prompts).toEqual(["do the first thing", "now extend it"]); - expect(fake.isDisposed()).toBe(false); - }); - - it("wait wakes on the first settling turn among concurrent sessions and suppresses its re-delivery", async () => { - const gates = new Map(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - const gate = deferred(); - gates.set(options.id, gate); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: `${options.id} finished.` }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const fast = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Task A." }); - const good = await registry.spawn(session, { cli: "good", name: "Good", prompt: "Task B." }); - await pollUntil(() => gates.size === 2); - - const waitPromise = registry.wait(session, { sessions: ["Fast", "Good"], timeoutMs: 5000 }); - gates.get("Fast")!.resolve(); - const outcome = await waitPromise; - - expect(outcome.timedOut).toBe(false); - expect(outcome.settled.map(entry => entry.id)).toEqual(["Fast"]); - expect(outcome.settled[0]!.resultText).toContain("Fast finished."); - expect(outcome.stillRunning).toEqual(["Good"]); - // The reported result must not be delivered a second time as a follow-up. - expect(manager.isDeliverySuppressed(fast.jobId)).toBe(true); - expect(manager.isDeliverySuppressed(good.jobId)).toBe(false); - - gates.get("Good")!.resolve(); - await manager.getJob(good.jobId)!.promise; - }); - - it("wait reports the settled turn even when a queued follow-up starts immediately", async () => { - const firstGate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - await firstGate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "First turn done." }); - }); - const followUpGate = deferred(); - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - await followUpGate.promise; - return makeResult(options.id, { output: "Follow-up done." }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Task A." }); - await pollUntil(() => AgentRegistry.global().get("Fast") !== undefined); - - // Queued while mid-turn: #finishTurn starts this follow-up turn inside - // the settling job's callback, BEFORE the watched job's promise resolves. - const queued = await registry.send(session, { session: "Fast", message: "Task B." }); - expect(queued.mode).toBe("queued"); - - const waitPromise = registry.wait(session, { sessions: ["Fast"], timeoutMs: 5000 }); - firstGate.resolve(); - const outcome = await waitPromise; - - // The settled first turn is reported (not shadowed by the new in-flight - // turn) and acknowledged so it is not re-delivered … - expect(outcome.settled.map(entry => entry.jobId)).toEqual([jobId]); - expect(outcome.settled[0]!.resultText).toContain("First turn done."); - expect(manager.isDeliverySuppressed(jobId)).toBe(true); - // … while the drained-queue follow-up shows as still running. - expect(outcome.stillRunning).toEqual(["Fast"]); - - followUpGate.resolve(); - await manager.getJob("Fast-t2")!.promise; - }); - - it("bounds kill teardown when a cancelled turn ignores abort and settles late", async () => { - const gate = deferred(); - const started = deferred(); - const disposed = deferred(); - const fake = createFakeWorkerSession({ onDispose: disposed.resolve }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - started.resolve(); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { - cli: "fast", - name: "IgnoresKillAbort", - prompt: "Keep working through explicit termination.", - }); - await started.promise; - - vi.useFakeTimers(); - try { - const kill = registry.kill(session, "IgnoresKillAbort"); - await disposed.promise; - await flushMicrotasks(); - expect(vi.getTimerCount()).toBeGreaterThan(0); - vi.advanceTimersByTime(250); - - const outcome = await kill; - expect(outcome.cancelledTurn).toBe(true); - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresKillAbort")).toBeUndefined(); - expect(registry.screens(session)[0]?.state).toBe("dead"); - await expect(registry.send(session, { session: "IgnoresKillAbort", message: "hello?" })).rejects.toThrow( - "dead", - ); - - gate.resolve(); - await manager.getJob(jobId)!.promise; - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresKillAbort")).toBeUndefined(); - expect(registry.screens(session)[0]?.state).toBe("dead"); - await expect(registry.send(session, { session: "IgnoresKillAbort", message: "still there?" })).rejects.toThrow( - "dead", - ); - } finally { - gate.resolve(); - vi.useRealTimers(); - } - }); - - it("keeps a persisted in-flight kill terminal when the old executor finalizes late", async () => { - let worker: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Killed during work.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "persisted-kill", prompt: "Keep working." }); - await pollUntil(() => worker !== undefined); - - expect((await registry.kill(session, "persisted-kill")).cancelledTurn).toBe(true); - const terminal = AgentRegistry.global().get("persisted-kill"); - expect(terminal).toMatchObject({ status: "aborted", session: null }); - - await executorModule.finalizeSubagentLifecycle({ - id: "persisted-kill", - session: worker!.session, - aborted: true, - keepAlive: true, - isolated: false, - agentIdleTtlMs: 0, - reviveSession: null, - }); - expect(AgentRegistry.global().get("persisted-kill")).toBe(terminal); - expect(AgentRegistry.global().get("persisted-kill")).toMatchObject({ status: "aborted", session: null }); - await expect(AgentLifecycleManager.global().ensureLive("persisted-kill")).rejects.toThrow("cannot be revived"); - }); - - it("does not terminalize a same-path replacement installed while the killed worker disposes", async () => { - let worker: ReturnType | undefined; - let replacement: ReturnType | undefined; - let replacementWorker: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - replacementWorker = createFakeWorkerSession(); - worker = createFakeWorkerSession({ - onDispose: () => { - replacement = AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: replacementWorker!.session, - sessionFile: childSessionFile, - status: "idle", - }); - }, - }); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Old worker killed.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "same-path-replacement", prompt: "Keep working." }); - await pollUntil(() => worker !== undefined); - - expect((await registry.kill(session, "same-path-replacement")).cancelledTurn).toBe(true); - expect(replacement).toBeDefined(); - expect(AgentRegistry.global().get("same-path-replacement")).toBe(replacement); - expect(replacement).toMatchObject({ status: "idle", session: replacementWorker!.session }); - expect(replacementWorker!.isDisposed()).toBe(false); - }); - - it("persists a kill issued before child initialization and terminalizes the worker if it registers late", async () => { - // Handshake so kill() lands while the worker is mid-init, not before its - // job body has started executing: on a loaded runner the job may not have - // dispatched yet when spawn() returns, and kill would then observe no - // registration at all (the mock registers only after the abort lands), - // failing the registry assertion below. Resolving once the body is inside - // the abort-wait keeps the test exercising exactly the late-registration - // path it names. - const workerStarted = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const signal = options.signal; - if (!signal) throw new Error("Pre-initialization worker requires a cancellation signal"); - workerStarted.resolve(); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: createFakeWorkerSession().session, - sessionFile: childSessionFile, - status: "idle", - }); - return makeResult(options.id, { output: "Killed before initialization.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "pre-init-kill", prompt: "Start later." }); - // Wait for the worker to be mid-init (inside the abort-wait) before - // issuing the kill — see the handshake comment at the top of the test. - await workerStarted.promise; - - expect((await registry.kill(session, "pre-init-kill")).cancelledTurn).toBe(true); - expect(AgentRegistry.global().get("pre-init-kill")).toMatchObject({ status: "aborted", session: null }); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "pre-init-kill" && data.action === "tombstone"; - }), - ).toBe(true); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("pre-init-kill")).toMatchObject({ status: "aborted", session: null }); - }); - - it("killAll terminates every session for the owner (mode-exit path)", async () => { - const gates = new Map(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - const gate = deferred(); - gates.set(options.id, gate); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const one = await registry.spawn(session, { cli: "fast", name: "One", prompt: "A." }); - const two = await registry.spawn(session, { cli: "good", name: "Two", prompt: "B." }); - await pollUntil(() => gates.size === 2); - - const killPromise = registry.killAll(session); - await pollUntil(() => manager.getJob(one.jobId)?.status === "cancelled"); - gates.get("One")!.resolve(); - await pollUntil(() => manager.getJob(two.jobId)?.status === "cancelled"); - gates.get("Two")!.resolve(); - const killed = await killPromise; - expect(killed).toBe(2); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("One")).toBeUndefined(); - expect(AgentRegistry.global().get("Two")).toBeUndefined(); - }); -}); diff --git a/packages/hashline/package.json b/packages/hashline/package.json index 81aa7620c..449a92376 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "17.2.8", + "version": "17.2.9", "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/hashline/src/fs.ts b/packages/hashline/src/fs.ts index bca93496a..6294fadc3 100644 --- a/packages/hashline/src/fs.ts +++ b/packages/hashline/src/fs.ts @@ -150,11 +150,11 @@ export class InMemoryFilesystem extends Filesystem { return { text: content }; } - async delete(path: string): Promise { + override async delete(path: string): Promise { if (!this.#files.delete(path)) throw new NotFoundError(path); } - async move(from: string, to: string, content?: string): Promise { + override async move(from: string, to: string, content?: string): Promise { const existing = this.#files.get(from); if (existing === undefined) throw new NotFoundError(from); const finalContent = content ?? existing; @@ -162,7 +162,7 @@ export class InMemoryFilesystem extends Filesystem { this.#files.delete(from); } - async exists(path: string): Promise { + override async exists(path: string): Promise { return this.#files.has(path); } @@ -199,7 +199,7 @@ export class NodeFilesystem extends Filesystem { return file.text(); } - async readBinary(path: string): Promise { + override async readBinary(path: string): Promise { try { return await fs.readFile(path); } catch (error) { @@ -213,7 +213,7 @@ export class NodeFilesystem extends Filesystem { return { text: content }; } - async delete(path: string): Promise { + override async delete(path: string): Promise { try { await fs.rm(path); } catch (error) { @@ -222,7 +222,7 @@ export class NodeFilesystem extends Filesystem { } } - async move(from: string, to: string, content?: string): Promise { + override async move(from: string, to: string, content?: string): Promise { if (content !== undefined) { await Bun.write(to, content); await this.delete(from); @@ -236,11 +236,11 @@ export class NodeFilesystem extends Filesystem { } } - canonicalPath(path: string): string { + override canonicalPath(path: string): string { return pathModule.resolve(path); } - async exists(path: string): Promise { + override async exists(path: string): Promise { return Bun.file(path).exists(); } } diff --git a/packages/hashline/src/snapshots.ts b/packages/hashline/src/snapshots.ts index 874433b2b..f755cfd93 100644 --- a/packages/hashline/src/snapshots.ts +++ b/packages/hashline/src/snapshots.ts @@ -180,7 +180,7 @@ export class InMemorySnapshotStore extends SnapshotStore { return history?.find(version => version.text === fullText) ?? null; } - findByHash(hash: string): Snapshot[] { + override findByHash(hash: string): Snapshot[] { const matches: Snapshot[] = []; for (const history of this.#versions.values()) { for (const version of history) { diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts index 817469925..bdb343ccf 100644 --- a/packages/hashline/test/core-contracts.test.ts +++ b/packages/hashline/test/core-contracts.test.ts @@ -71,7 +71,7 @@ class BlockingFilesystem extends InMemoryFilesystem { for (const filePath of blocked) this.#blocked.add(filePath); } - async preflightWrite(filePath: string): Promise { + override async preflightWrite(filePath: string): Promise { if (this.#blocked.has(filePath)) throw new Error(`blocked write: ${filePath}`); } } diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 67b8478d4..452d91c60 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -158,7 +158,7 @@ describe("Patcher snapshot tag integrity", () => { // call returned), but `writeText` echoes back a *reformatted* copy — spaces // turned into tabs, exactly the corruption reported against the ACP bridge. class DriftingFilesystem extends InMemoryFilesystem { - async writeText(path: string, content: string): Promise { + override async writeText(path: string, content: string): Promise { const drifted = content.replace(/^ {4}/gm, "\t"); await super.writeText(path, drifted); return { text: drifted }; diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index 24a938afc..5e4141c27 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.2.8", + "version": "17.2.9", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/mnemopi/test/recovery.test.ts b/packages/mnemopi/test/recovery.test.ts index aff4ec8ee..d6c772f0f 100644 --- a/packages/mnemopi/test/recovery.test.ts +++ b/packages/mnemopi/test/recovery.test.ts @@ -52,7 +52,7 @@ function withFrozenNow(iso: string, fn: () => T): T { else super(value); } - static now(): number { + static override now(): number { return fixedMs; } } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 5f4c8f991..eef2c690a 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Changed + +- Bounded fuzzy-find scored-match retention to the top-K results (worst-first heap) instead of collecting and fully sorting every hit; ranking and totals are unchanged ([#7415](https://github.com/can1357/oh-my-pi/issues/7415)). + +### Fixed + +- Fixed newer OMP versions deleting a freshly created older native addon cache directory during concurrent startup, which could interrupt extraction with `ENOENT`. + ## [17.2.7] - 2026-08-03 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 0a0b89c25..9ff263be2 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_2_8(): void +export declare function __piNativesV17_2_9(): 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 cbf4f7405..d6da608ad 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_2_8 = nativeBindings.__piNativesV17_2_8; +export const __piNativesV17_2_9 = nativeBindings.__piNativesV17_2_9; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; export const astMatch = nativeBindings.astMatch; diff --git a/packages/natives/native/loader-state.d.ts b/packages/natives/native/loader-state.d.ts index 63a042cd7..e61d2da21 100644 --- a/packages/natives/native/loader-state.d.ts +++ b/packages/natives/native/loader-state.d.ts @@ -88,6 +88,8 @@ export interface CleanupStaleNativeVersionsInput { export function cleanupStaleNativeVersions(input: CleanupStaleNativeVersionsInput): string[]; +export function prepareNativeVersionDir(versionedDir: string): void; + export interface ExtractEmbeddedAddonArchiveInput { archivePath: string; files: EmbeddedAddonFile[]; diff --git a/packages/natives/native/loader-state.js b/packages/natives/native/loader-state.js index e77ff2ea1..385aed862 100644 --- a/packages/natives/native/loader-state.js +++ b/packages/natives/native/loader-state.js @@ -196,6 +196,26 @@ function isOlderReleaseVersion(candidate, current) { return false; } +// A concurrently starting older OMP binary creates or refreshes this directory +// before extracting its addon. Keep fresh directories long enough for that +// startup to finish; a later launch can reclaim them once they are genuinely +// stale. +const NATIVE_CACHE_CLEANUP_GRACE_MS = 10 * 60_000; + +/** + * Create a version cache directory and refresh its activity timestamp before + * extraction or staging begins. Recursive mkdir does not update the mtime of + * an existing directory, so the explicit touch is what protects interrupted + * or partially populated caches from concurrent cleanup. + * + * @param {string} versionedDir + */ +export function prepareNativeVersionDir(versionedDir) { + fs.mkdirSync(versionedDir, { recursive: true }); + const now = new Date(); + fs.utimesSync(versionedDir, now, now); +} + /** * Remove version-pinned native cache directories older than the loaded package. * Best-effort by design: permission errors and concurrent processes must not @@ -217,6 +237,8 @@ export function cleanupStaleNativeVersions({ nativesDir, currentVersion }) { if (!entry.isDirectory() || !isOlderReleaseVersion(entry.name, currentVersion)) continue; const targetPath = path.join(nativesDir, entry.name); try { + const stat = fs.statSync(targetPath); + if (Date.now() - stat.mtimeMs < NATIVE_CACHE_CLEANUP_GRACE_MS) continue; fs.rmSync(targetPath, { recursive: true, force: true }); removed.push(targetPath); } catch { @@ -514,7 +536,7 @@ function maybeExtractEmbeddedAddon(ctx, errors) { startupMarker("native:extractEmbeddedAddon:start"); try { - fs.mkdirSync(ctx.versionedDir, { recursive: true }); + prepareNativeVersionDir(ctx.versionedDir); } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`embedded addon dir: ${message}`); @@ -581,7 +603,7 @@ function maybeStageNodeModulesAddon(ctx, errors) { if (!fs.existsSync(sourcePath)) continue; try { - fs.mkdirSync(ctx.versionedDir, { recursive: true }); + prepareNativeVersionDir(ctx.versionedDir); } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`staged addon dir: ${message}`); diff --git a/packages/natives/package.json b/packages/natives/package.json index 9ea41c710..9d5ded0f5 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "17.2.8", + "version": "17.2.9", "description": "Native Rust bindings for 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/natives/test/windows-staging.test.ts b/packages/natives/test/windows-staging.test.ts index 01be98647..a951ba5b7 100644 --- a/packages/natives/test/windows-staging.test.ts +++ b/packages/natives/test/windows-staging.test.ts @@ -27,6 +27,7 @@ import { cleanupStaleNativeVersions, getAddonFilenames, initLoaderContext, + prepareNativeVersionDir, resolveLoaderCandidates, shouldStageNodeModulesAddon, } from "../native/loader-state.js"; @@ -195,18 +196,24 @@ describe("windows native addon staging", () => { const nativesDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-natives-cache-")); const currentMajor = Number.parseInt(packageJson.version, 10); const futureVersion = `${currentMajor + 1}.0.0`; + const staleVersion = "15.10.11"; + const freshVersion = "15.10.12"; try { - await fs.mkdir(path.join(nativesDir, "15.10.11")); + await fs.mkdir(path.join(nativesDir, staleVersion)); + await fs.mkdir(path.join(nativesDir, freshVersion)); await fs.mkdir(path.join(nativesDir, packageJson.version)); await fs.mkdir(path.join(nativesDir, futureVersion)); await fs.mkdir(path.join(nativesDir, "not-a-version")); await Bun.write(path.join(nativesDir, "README.txt"), "not a version directory"); + await fs.utimes(path.join(nativesDir, staleVersion), new Date(0), new Date(0)); + await fs.utimes(path.join(nativesDir, freshVersion), new Date(0), new Date(0)); + prepareNativeVersionDir(path.join(nativesDir, freshVersion)); const removed = cleanupStaleNativeVersions({ nativesDir, currentVersion: packageJson.version }); - expect(removed.map(filePath => path.basename(filePath))).toEqual(["15.10.11"]); + expect(removed.map(filePath => path.basename(filePath))).toEqual([staleVersion]); expect((await fs.readdir(nativesDir)).sort()).toEqual( - ["README.txt", packageJson.version, futureVersion, "not-a-version"].sort(), + ["README.txt", freshVersion, packageJson.version, futureVersion, "not-a-version"].sort(), ); } finally { await fs.rm(nativesDir, { recursive: true, force: true }); diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md index 9c6e03621..c20279cb2 100644 --- a/packages/omptype/CHANGELOG.md +++ b/packages/omptype/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Fixed + +- Fixed the TypeBox adapter emitting an invalid left-bound-only DSL for min-only numeric schemas (e.g. `Type.Integer({ minimum: 1 })`), which threw `left bound requires a corresponding right bound` and broke extension tool loading ([#7648](https://github.com/can1357/oh-my-pi/issues/7648)). + ## [17.2.8] - 2026-08-04 ### Added diff --git a/packages/omptype/package.json b/packages/omptype/package.json index 7d177dcfd..1972a698a 100644 --- a/packages/omptype/package.json +++ b/packages/omptype/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omptype", - "version": "17.2.8", + "version": "17.2.9", "description": "ArkType-compatible runtime schema validation with lazy JIT compilation", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/omptype/src/typebox.ts b/packages/omptype/src/typebox.ts index 2654f5372..7e252a556 100644 --- a/packages/omptype/src/typebox.ts +++ b/packages/omptype/src/typebox.ts @@ -267,9 +267,19 @@ function tNumber(opts?: NumberOpts, integer = false): TNumber { upper = { value: opts.exclusiveMaximum, exclusive: true }; } const keyword = integer ? "number.integer" : "number"; - const lowerDsl = lower ? `${lower.value} ${lower.exclusive ? "<" : "<="} ` : ""; - const upperDsl = upper ? ` ${upper.exclusive ? "<" : "<="} ${upper.value}` : ""; - let schema = asRuntime(type.raw(`${lowerDsl}${keyword}${upperDsl}`)); + // The `LO <= TYPE <= HI` range spelling requires both bounds; a min-only + // bound must use the postfix `TYPE >= LO` form (see parseBounded in ir.ts). + let src: string; + if (lower && upper) { + src = `${lower.value} ${lower.exclusive ? "<" : "<="} ${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`; + } else if (lower) { + src = `${keyword} ${lower.exclusive ? ">" : ">="} ${lower.value}`; + } else if (upper) { + src = `${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`; + } else { + src = keyword; + } + let schema = asRuntime(type.raw(src)); if (opts?.multipleOf !== undefined) { const divisor = opts.multipleOf; schema = schema.narrow((value, ctx) => { diff --git a/packages/omptype/test/typebox.test.ts b/packages/omptype/test/typebox.test.ts index f41e7911f..0ded2d33f 100644 --- a/packages/omptype/test/typebox.test.ts +++ b/packages/omptype/test/typebox.test.ts @@ -56,6 +56,22 @@ describe("TypeBox adapter", () => { expect(valid(exclusive, 1)).toBe(false); expect(valid(exclusive, 3)).toBe(false); expect(exclusive.toJsonSchema()).toEqual({ type: "number", exclusiveMinimum: 1, exclusiveMaximum: 3 }); + + const minOnly = Type.Integer({ minimum: 1 }); + expect(valid(minOnly, 1)).toBe(true); + expect(valid(minOnly, 0)).toBe(false); + expect(valid(minOnly, 1.5)).toBe(false); + expect(minOnly.toJsonSchema()).toEqual({ type: "integer", minimum: 1 }); + const exclusiveMinOnly = Type.Number({ exclusiveMinimum: 0 }); + expect(valid(exclusiveMinOnly, 0)).toBe(false); + expect(valid(exclusiveMinOnly, 0.5)).toBe(true); + const maxOnly = Type.Number({ maximum: 5 }); + expect(valid(maxOnly, 5)).toBe(true); + expect(valid(maxOnly, 6)).toBe(false); + const minWithMultiple = Type.Integer({ minimum: 1, multipleOf: 2 }); + expect(valid(minWithMultiple, 4)).toBe(true); + expect(valid(minWithMultiple, 3)).toBe(false); + expect(valid(minWithMultiple, 0)).toBe(false); }); test("arrays, tuples, objects, records and intersections validate", () => { diff --git a/packages/snapcompact/package.json b/packages/snapcompact/package.json index cefb9c8a6..38c2e6eb7 100644 --- a/packages/snapcompact/package.json +++ b/packages/snapcompact/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/snapcompact", - "version": "17.2.8", + "version": "17.2.9", "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 74d8616a2..23c5c0396 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Fixed + +- Restricted the stats dashboard to IPv4 loopback and removed wildcard CORS access to its API ([#7633](https://github.com/can1357/oh-my-pi/issues/7633)). + ## [17.2.4] - 2026-08-01 ### Fixed diff --git a/packages/stats/package.json b/packages/stats/package.json index 3a0c73edd..97454dc55 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.2.8", + "version": "17.2.9", "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 d74c4080b..5894f73fb 100755 --- a/packages/stats/src/index.ts +++ b/packages/stats/src/index.ts @@ -171,8 +171,8 @@ Examples: // Start server const port = parseInt(values.port || "3847", 10); - const { port: actualPort } = await startServer(port); - console.log(`Dashboard available at: http://localhost:${actualPort}`); + const { hostname, port: actualPort } = await startServer(port); + console.log(`Dashboard available at: http://${hostname}:${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 c49045d64..74b2273a1 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -18,30 +18,27 @@ interface PortHolder { /** Header stamped on every dashboard response so reuse probes can identify us. */ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard"; -async function probeStatsDashboard(port: number): Promise { +/** Identity-header value for dashboards enforcing loopback-only, same-origin access. */ +export const STATS_DASHBOARD_SECURITY_VERSION = "2"; + +/** 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 { try { - const response = await fetch(`http://localhost:${port}/api/stats/models`, { + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, { signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS), }); - if (response.status !== 200) { - await response.body?.cancel(); - return false; - } - // A live omp-stats dashboard stamps this header on every response. - if (response.headers.get(STATS_DASHBOARD_HEADER)) { - await response.body?.cancel(); - return true; - } - // Older dashboards predate the header; fall back to the response shape - // (`/api/stats/models` returns a JSON array) so we never reuse — or later - // kill — a foreign 200 responder such as an SPA dev server catch-all. - if (!(response.headers.get("content-type") ?? "").includes("application/json")) { - await response.body?.cancel(); - return false; - } - return Array.isArray(await response.json()); + const reusable = + response.status === 200 && + response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION && + !response.headers.has("Access-Control-Allow-Origin"); + await response.body?.cancel(); + return reusable ? "reusable" : "occupied"; } catch { - return false; + return "unreachable"; } } @@ -216,10 +213,7 @@ async function terminatePortHolder(holder: PortHolder): Promise { await Bun.sleep(PROCESS_EXIT_POLL_MS); } -/** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */ -export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { - if (await probeStatsDashboard(port)) return "reuse"; - +async function reclaimStatsPort(port: number): Promise<"retry"> { const holder = await findPortHolder(port); if (!holder) { throw new Error(`Port ${port} is in use, but the listening process could not be identified.`); @@ -248,3 +242,22 @@ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> await terminatePortHolder(holder); return "retry"; } + +/** + * Reuse a secure dashboard or reclaim an insecure HTTP dashboard before binding. + * 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"> { + if (port === 0) return "retry"; + const probe = await probeStatsDashboard(port); + 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"; + return reclaimStatsPort(port); +} diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index 8349a868d..33dd1097d 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -21,7 +21,13 @@ import { import { decodeEmbeddedClientArchive } from "./embedded-client"; import embeddedClientArchiveTxt from "./embedded-client.generated.txt"; import { getGainDashboardStats } from "./gain-aggregator"; -import { recoverStatsPort, STATS_DASHBOARD_HEADER } from "./port-conflict"; +import { + prepareStatsPort, + recoverStatsPort, + STATS_DASHBOARD_HEADER, + STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_SECURITY_VERSION, +} from "./port-conflict"; const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt); @@ -303,21 +309,19 @@ async function handleStatic(requestPath: string): Promise { function createDashboardServer(port: number) { const server = Bun.serve({ port, + hostname: STATS_DASHBOARD_HOSTNAME, async fetch(req) { const url = new URL(req.url); const path = url.pathname; - // CORS headers for local development; the identity header lets another - // omp session's reuse probe positively recognize this dashboard. - const corsHeaders: Record = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - [STATS_DASHBOARD_HEADER]: "1", + // The identity header lets another omp session's reuse probe positively + // recognize this dashboard without allowing cross-origin API reads. + const dashboardHeaders: Record = { + [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION, }; if (req.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders }); + return new Response(null, { headers: dashboardHeaders }); } try { @@ -329,10 +333,10 @@ function createDashboardServer(port: number) { response = await handleStatic(path); } - // Add CORS headers to all responses + // Add the dashboard identity header to all responses. const headers = new Headers(response.headers); - for (const key in corsHeaders) { - headers.set(key, corsHeaders[key]); + for (const key in dashboardHeaders) { + headers.set(key, dashboardHeaders[key]); } return new Response(response.body, { @@ -343,7 +347,7 @@ function createDashboardServer(port: number) { console.error("Server error:", error); return Response.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: corsHeaders }, + { status: 500, headers: dashboardHeaders }, ); } }, @@ -354,12 +358,17 @@ 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<{ port: number; stop: () => void }> { +export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> { await ensureClientBuild(); + const preparation = await prepareStatsPort(port); + if (preparation === "reuse") { + return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; + } try { const server = createDashboardServer(port); return { + hostname: STATS_DASHBOARD_HOSTNAME, port: server.port ?? port, stop: () => server.stop(), }; @@ -368,12 +377,13 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: () const recovery = await recoverStatsPort(port); if (recovery === "reuse") { - return { port, stop: () => {} }; + return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; } try { const server = createDashboardServer(port); return { + hostname: STATS_DASHBOARD_HOSTNAME, port: server.port ?? port, stop: () => server.stop(), }; diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index 771953bdd..ae92163bc 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -1,22 +1,44 @@ import { afterEach, describe, expect, it } from "bun:test"; -import type { Subprocess } from "bun"; -import { STATS_DASHBOARD_HEADER } from "../src/port-conflict"; +import { networkInterfaces } from "node:os"; +import { connect, type Subprocess } from "bun"; +import { + STATS_DASHBOARD_HEADER, + STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_SECURITY_VERSION, +} from "../src/port-conflict"; import { startServer } from "../src/server"; +import { installStatsTestIsolation } from "./helpers/temp-agent"; + +installStatsTestIsolation("@pi-stats-port-conflict-"); + +/** + * Directly probe a TCP endpoint, bypassing any configured HTTP proxy so the + * loopback-only bind is asserted against the real listener rather than a proxy + * response. Resolves true when the connection is accepted, false when refused. + */ +async function tcpConnects(hostname: string, port: number): Promise { + try { + const socket = await connect({ hostname, port, socket: { data() {}, open() {}, close() {}, error() {} } }); + socket.end(); + return true; + } catch { + return false; + } +} const holderProcesses: Array> = []; -async function startBunHolder(responseExpr: string, options?: { statsOwned?: boolean }) { - // Bind the wildcard address: `startServer` binds the wildcard too, and on - // macOS SO_REUSEADDR lets a wildcard bind coexist with a 127.0.0.1-only - // listener, which would bypass the EADDRINUSE path this suite exercises. +async function startBunHolder(responseExpr: string, options?: { hostname?: string; statsOwned?: boolean }) { + const hostname = options?.hostname ?? STATS_DASHBOARD_HOSTNAME; const reservation = Bun.serve({ port: 0, + hostname: STATS_DASHBOARD_HOSTNAME, fetch: () => new Response("reserved"), }); const port = reservation.port; reservation.stop(true); - const source = `Bun.serve({ port: ${port}, fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; + const source = `Bun.serve({ port: ${port}, hostname: "${hostname}", fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; const args = [process.execPath, "-e", source]; if (options?.statsOwned) args.push("omp-stats"); const child = Bun.spawn(args, { @@ -46,13 +68,49 @@ afterEach(async () => { holderProcesses.length = 0; }); +describe("startServer access", () => { + it("only serves loopback requests without cross-origin access", async () => { + const server = await startServer(0); + + try { + expect(server.hostname).toBe(STATS_DASHBOARD_HOSTNAME); + 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("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(); + expect(await tcpConnects(server.hostname, server.port)).toBe(true); + if (nonLoopbackHostname) { + expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(false); + } + } finally { + server.stop(); + } + }); +}); + describe("startServer port conflicts", () => { it("reuses a live stats dashboard identified by its header", async () => { const existing = Bun.serve({ port: 0, + hostname: STATS_DASHBOARD_HOSTNAME, fetch: request => new URL(request.url).pathname === "/api/stats/models" - ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: "1" } }) + ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION } }) : new Response("dashboard"), }); @@ -62,21 +120,52 @@ describe("startServer port conflicts", () => { server.stop(); // The existing dashboard is untouched: it still answers on the port. - const response = await fetch(`http://127.0.0.1:${existing.port}/api/stats/models`); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${existing.port}/api/stats/models`); expect(response.status).toBe(200); - expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1"); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); await response.body?.cancel(); } finally { existing.stop(true); } }); + for (const fixture of [ + { + name: "reclaims a version 1 dashboard with wildcard CORS", + response: `Response.json([], { headers: { "${STATS_DASHBOARD_HEADER}": "1", "Access-Control-Allow-Origin": "*" } })`, + hostname: "0.0.0.0", + }, + { + name: "reclaims a headerless legacy dashboard", + response: "Response.json([])", + hostname: STATS_DASHBOARD_HOSTNAME, + }, + ]) { + it(fixture.name, async () => { + const holder = await startBunHolder(fixture.response, { + hostname: fixture.hostname, + statsOwned: true, + }); + const server = await startServer(holder.port); + + try { + expect(await holder.child.exited).not.toBe(0); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${server.port}/api/stats/models`); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.body?.cancel(); + } finally { + server.stop(); + } + }); + } + it("refuses to stop a foreign 200 responder", async () => { const holder = await startBunHolder('Response.json({ app: "spa" })'); await expect(startServer(holder.port)).rejects.toThrow("not identifiable as an omp stats dashboard"); expect(holder.child.exitCode).toBeNull(); - const response = await fetch(`http://127.0.0.1:${holder.port}/api/stats/models`); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${holder.port}/api/stats/models`); expect(await response.json()).toEqual({ app: "spa" }); }); diff --git a/packages/swarm-extension/.gitignore b/packages/swarm-extension/.gitignore deleted file mode 100644 index c2658d7d1..000000000 --- a/packages/swarm-extension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/packages/swarm-extension/CHANGELOG.md b/packages/swarm-extension/CHANGELOG.md deleted file mode 100644 index d5bc04f2b..000000000 --- a/packages/swarm-extension/CHANGELOG.md +++ /dev/null @@ -1,15 +0,0 @@ -# Changelog - -## [Unreleased] - -## [16.3.7] - 2026-07-05 - -### Fixed - -- Fixed the peer dependency range for @oh-my-pi/pi-coding-agent to match the current ^16 major version. - -## [15.9.0] - 2026-06-04 - -### Fixed - -- Fixed swarm `/swarm run` failing with authStorage/modelRegistry identity error ([#1472](https://github.com/can1357/oh-my-pi/issues/1472)) diff --git a/packages/swarm-extension/README.md b/packages/swarm-extension/README.md deleted file mode 100644 index f2ed4b51a..000000000 --- a/packages/swarm-extension/README.md +++ /dev/null @@ -1,472 +0,0 @@ -# Swarm Extension - -Multi-agent orchestration for oh-my-pi. Define agent workflows in YAML — pipelines, parallel fan-outs, sequential chains, or any DAG — and run them unattended until completion. - -Each agent is a full oh-my-pi subagent with access to every tool: bash, python, read, write, edit, grep, find, fetch, web_search, browser. The orchestrator manages lifecycle and ordering; agents communicate through the shared workspace filesystem. - -Use it for anything: research pipelines, code generation, data processing, content creation, analysis workflows, CI-like automation — any multi-step task that benefits from specialized agents working in coordination. - -## Setup - -```bash -cd packages/swarm-extension -bun install -``` - -## Running - -### Standalone (recommended for long-running work) - -```bash -# Foreground — runs until complete, no timeout: -omp-swarm path/to/swarm.yaml - -# Background — survives terminal close: -nohup omp-swarm path/to/swarm.yaml \ - > pipeline.log 2>&1 & disown -``` - -The standalone runner has no timeout. It runs iteration after iteration until the pipeline finishes or you kill it. - -### Inside oh-my-pi (TUI) - -Register the extension in your config (`~/.omp/config.json` or `.omp/config.json`): - -```json -{ - "extensions": ["packages/swarm-extension"] -} -``` - -Then: - -``` -/swarm run path/to/swarm.yaml -/swarm status -/swarm help -``` - -## Monitoring - -State persists to `/.swarm_/` while the pipeline runs: - -``` -.swarm_/ - state/pipeline.json # Live pipeline + per-agent status - logs/orchestrator.log # Wave transitions, iteration progress - logs/.log # Per-agent timestamps and errors - context/ # Agent session artifacts -``` - -Check on a running pipeline: - -```bash -# Quick status -cat workspace/.swarm_mypipeline/state/pipeline.json | python -m json.tool - -# Watch the orchestrator log -tail -f workspace/.swarm_mypipeline/logs/orchestrator.log -``` - ---- - -## YAML Reference - -Every swarm is a single YAML file with a top-level `swarm` key: - -```yaml -swarm: - name: my-pipeline # Identifier (state stored in .swarm_/) - workspace: ./workspace # Working directory (relative to YAML file location) - mode: pipeline # pipeline | parallel | sequential - target_count: 10 # Iterations (pipeline mode only, default: 1) - model: claude-opus-4-6 # Default model for agents without an override (optional) - - agents: - first_agent: - role: short-role-name - task: | - Full instructions for this agent. - extra_context: | - Optional additional system prompt text. - reports_to: - - downstream_agent - waits_for: - - upstream_agent - model: claude-sonnet-4-5 # Optional per-agent override -``` - -### Top-Level Fields - -| Field | Required | Default | Description | -| -------------- | -------- | --------------- | ------------------------------------------------------------------------------ | -| `name` | yes | — | Pipeline identifier. State directory is `.swarm_/` | -| `workspace` | yes | — | Shared working directory. Relative paths resolve from YAML file location | -| `mode` | no | `sequential` | Execution mode (see below) | -| `target_count` | no | `1` | How many times to repeat the full pipeline. Only meaningful in `pipeline` mode | -| `model` | no | session default | Default model for agents that do not set `agents..model` | - -### Agent Fields - -| Field | Required | Description | -| --------------- | -------- | ----------------------------------------------------------------------- | -| `role` | yes | Short role identifier — becomes the agent's system prompt | -| `task` | yes | Complete instructions sent as user prompt. Use YAML `\|` for multi-line | -| `extra_context` | no | Additional text appended to system prompt | -| `model` | no | Model override for this agent only | -| `reports_to` | no | List of agent names that depend on this agent | -| `waits_for` | no | List of agent names this agent depends on | - -### Execution Modes - -**`pipeline`** — Repeat the full agent graph `target_count` times. Each iteration runs all waves in order. Use for accumulative work: "find 50 things, one per iteration." - -**`sequential`** — Run agents once, chained by declaration order (unless explicit dependencies override). The default mode. - -**`parallel`** — Run all agents simultaneously (unless explicit dependencies impose ordering). - -### Dependency Resolution - -The orchestrator builds a DAG from `waits_for` and `reports_to`, then groups agents into **waves** using topological sort. Agents in the same wave run in parallel; waves execute in sequence. - -- `waits_for: [a, b]` — this agent won't start until both `a` and `b` finish -- `reports_to: [x]` — equivalent to `x` having `waits_for: [this_agent]` -- No explicit deps + `pipeline`/`sequential` mode — agents chain by YAML declaration order -- No explicit deps + `parallel` mode — all agents run in one wave -- Cycles are detected and rejected before execution - ---- - -## Patterns - -### Pipeline: Iterative Accumulation - -Run the same agent chain N times. Each iteration builds on the previous one's output. Good for: research collection, data gathering, batch processing, iterative refinement. - -```yaml -swarm: - name: research-collector - workspace: ./workspace - mode: pipeline - target_count: 25 - model: claude-opus-4-6 - - agents: - finder: - role: researcher - task: | - Find ONE new source on the topic defined in workspace/topic.md. - - 1. Read processed.txt to see what's already been found - 2. Use web_search to find a new, high-quality source - 3. Append the URL to processed.txt - 4. Write the URL to signals/finder_out.txt: FOUND: - - analyzer: - role: analyst - task: | - Read signals/finder_out.txt for the URL. - Fetch the page and extract key findings. - Read tracking/count.txt, increment it, write back. - Write analysis to analyzed/item_.md - Write to signals/analyzer_out.txt: DONE: - - compiler: - role: technical-writer - task: | - Read signals/analyzer_out.txt for the item number. - Read analyzed/item_.md. - Append a summary to output/report.md under a new section. -``` - -After 25 iterations: 25 sources found, analyzed, and compiled into a single report. - -### Fan-In: Parallel Specialists - -Multiple agents work independently, one synthesizer combines results. Good for: multi-perspective analysis, parallel code review, comprehensive audits. - -```yaml -swarm: - name: codebase-audit - workspace: ./workspace - - agents: - security: - role: security-auditor - task: | - Audit all code in src/ for security vulnerabilities. - Write findings to reports/security.md with severity ratings. - reports_to: - - lead - - performance: - role: performance-analyst - task: | - Profile and analyze src/ for performance bottlenecks. - Write findings to reports/performance.md with benchmarks. - reports_to: - - lead - - architecture: - role: architecture-reviewer - task: | - Review src/ for architectural issues, coupling, and tech debt. - Write findings to reports/architecture.md with refactoring suggestions. - reports_to: - - lead - - lead: - role: engineering-lead - task: | - Read all reports in reports/. - Create a prioritized action plan in output/action_plan.md. - Rank issues by impact and effort. - waits_for: - - security - - performance - - architecture -``` - -Execution: security + performance + architecture run in parallel (wave 1), lead starts after all three complete (wave 2). - -### Sequential Chain: Staged Handoff - -Linear progression through distinct phases. Good for: content pipelines, multi-stage processing, review chains. - -```yaml -swarm: - name: blog-post - workspace: ./workspace - mode: sequential - - agents: - researcher: - role: researcher - task: | - Research the topic in topic.md using web_search. - Write raw findings and source links to research/notes.md - - writer: - role: technical-writer - task: | - Read research/notes.md. - Write a complete blog post draft to drafts/post.md. - Include code examples where relevant. - - editor: - role: editor - task: | - Read drafts/post.md. - Fix grammar, improve flow, tighten prose. - Rewrite to drafts/post.md. - - reviewer: - role: senior-reviewer - task: | - Read drafts/post.md. - Check technical accuracy against research/notes.md. - Add an editorial note at top if issues found, otherwise - copy to output/final.md. -``` - -Execution: researcher -> writer -> editor -> reviewer, one after another. - -### Diamond: Fan-Out Then Fan-In - -One planner, parallel workers, one integrator. Good for: divide-and-conquer, modular code generation, multi-file refactors. - -```yaml -swarm: - name: feature-implementation - workspace: ./workspace - - agents: - planner: - role: architect - task: | - Read the feature spec in spec.md. - Break it into independent implementation tasks. - Write the plan to plan.md with file assignments. - reports_to: - - api - - ui - - tests - - api: - role: backend-developer - task: | - Read plan.md for your assigned files. - Implement the API layer. Write to src/api/. - reports_to: - - integrator - - ui: - role: frontend-developer - task: | - Read plan.md for your assigned files. - Implement the UI components. Write to src/ui/. - reports_to: - - integrator - - tests: - role: test-engineer - task: | - Read plan.md for the full feature scope. - Write integration tests to tests/. - reports_to: - - integrator - - integrator: - role: tech-lead - task: | - Read plan.md and review all code in src/ and tests/. - Wire everything together. Fix any integration issues. - Run the tests and fix failures. - Write status to output/done.md. -``` - -Execution: planner (wave 1) -> api + ui + tests in parallel (wave 2) -> integrator (wave 3). - -### Hybrid: Mixed Dependencies - -Any DAG is valid. Combine patterns freely. - -```yaml -swarm: - name: data-pipeline - workspace: ./workspace - mode: pipeline - target_count: 10 - - agents: - scraper_a: - role: web-scraper - task: | - Scrape data source A. Write to raw/source_a.json - reports_to: - - transformer - - scraper_b: - role: web-scraper - task: | - Scrape data source B. Write to raw/source_b.json - reports_to: - - transformer - - transformer: - role: data-engineer - task: | - Read raw/source_a.json and raw/source_b.json. - Clean, normalize, merge. Write to processed/merged.json - reports_to: - - loader - - validator - - validator: - role: qa-analyst - task: | - Read processed/merged.json. - Validate schema, check for anomalies. - Write report to qa/validation.md - - loader: - role: data-engineer - task: | - Read processed/merged.json. - Append to output/dataset.jsonl -``` - -Execution per iteration: scraper_a + scraper_b (wave 1) -> transformer (wave 2) -> loader + validator (wave 3). - ---- - -## Writing Agent Tasks - -### What Agents Can Do - -Each agent is a full oh-my-pi session. It can: - -- **bash/python**: Run commands, scripts, install packages, process data -- **read/write/edit**: Create and modify files in the workspace -- **grep/find**: Search the workspace (or anywhere on disk) -- **web_search**: Search the internet (via configured provider) -- **fetch**: Download web pages, APIs, documents -- **browser**: Navigate websites, scrape dynamic content, take screenshots - -### Inter-Agent Communication - -The orchestrator starts and stops agents in the right order. It does **not** pass data between them. Agents communicate through files in the shared workspace. - -Design your own protocol. Common patterns: - -**Signal files** — lightweight status flags an agent writes when done: - -``` -signals/finder_out.txt -> "FOUND:https://example.com" -signals/analyzer_out.txt -> "DONE:42" -signals/reviewer_out.txt -> "APPROVED" or "REJECTED:reason" -``` - -**Structured output** — detailed results other agents read: - -``` -analyzed/item_1.md -> Full analysis document -results/report.json -> Machine-readable data -output/final.docx -> Accumulated deliverable -``` - -**Tracking files** — prevent duplicate work across pipeline iterations: - -``` -processed.txt -> Items already handled (one per line) -tracking/count.txt -> Current item counter -tracking/status.json -> Cumulative state -``` - -### Tips for Reliable Agents - -- **Be explicit about paths.** Agents start fresh each iteration — they don't remember previous runs. Tell them exactly where to read input and write output. -- **Check existing state.** In pipeline mode, tell agents to read tracking files before doing work: "Read processed.txt to avoid duplicates." -- **Use numbered outputs.** `item_1.md`, `item_2.md` etc. so iterations don't clobber each other. -- **Handle failure.** Tell agents what to do when things go wrong: "If the source lacks depth, write SKIP to signals/out.txt and explain why." -- **Keep signal files simple.** One line, parseable format. Complex data goes in structured output files. -- **Scope the task tightly.** An agent that tries to do five things will do zero well. One clear objective per agent. - ---- - -## Models - -Any model configured in omp works. Set a swarm default and optionally override per agent: - -```yaml -swarm: - model: claude-opus-4-6 - agents: - writer: - role: technical-writer - task: | - Write the draft. - reviewer: - role: reviewer - model: claude-sonnet-4-5 - task: | - Review the draft. -``` - -Precedence: `agents..model` → `swarm.model` → session default. Check `packages/ai/src/models.json` for available model IDs. - ---- - -## Architecture - -``` -src/extension.ts TUI entry point (registers /swarm command) -src/cli.ts Standalone runner (no TUI, no timeout) -src/swarm/ - schema.ts YAML parsing + validation - dag.ts Dependency graph, cycle detection, topological sort - executor.ts Spawns agents via oh-my-pi's runSubprocess - pipeline.ts Iteration loop + wave controller - state.ts Filesystem state persistence - render.ts Progress display formatting -``` diff --git a/packages/swarm-extension/package.json b/packages/swarm-extension/package.json deleted file mode 100644 index 1041b8abe..000000000 --- a/packages/swarm-extension/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "type": "module", - "name": "@oh-my-pi/swarm-extension", - "version": "17.2.8", - "description": "Swarm orchestration extension for omp", - "homepage": "https://omp.sh", - "author": "Derek Rynd", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/can1357/oh-my-pi.git", - "directory": "packages/swarm-extension" - }, - "bugs": { - "url": "https://github.com/can1357/oh-my-pi/issues" - }, - "keywords": [ - "swarm", - "orchestration", - "agent", - "extension" - ], - "bin": { - "omp-swarm": "src/cli.ts" - }, - "scripts": { - "check": "biome check . && bun run check:types", - "check:types": "tsgo -p tsconfig.json --noEmit", - "lint": "biome lint .", - "fix": "biome check --write --unsafe .", - "fmt": "biome format --write ." - }, - "dependencies": { - "@oh-my-pi/pi-utils": "workspace:*" - }, - "devDependencies": { - "@types/bun": "^1.3.14" - }, - "peerDependencies": { - "@oh-my-pi/pi-coding-agent": "^16" - }, - "engines": { - "bun": ">=1.3.14" - }, - "omp": { - "extensions": [ - "./src/extension.ts" - ] - }, - "files": [ - "src", - "README.md", - "CHANGELOG.md" - ] -} diff --git a/packages/swarm-extension/src/cli.ts b/packages/swarm-extension/src/cli.ts deleted file mode 100755 index 6537fc059..000000000 --- a/packages/swarm-extension/src/cli.ts +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bun -/** - * Direct pipeline runner — executes a swarm pipeline outside of the TUI. - * - * Usage: bun cli.ts - */ - -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { discoverAuthStorage } from "@oh-my-pi/pi-coding-agent"; -import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { buildDependencyGraph, buildExecutionWaves, detectCycles } from "./swarm/dag"; -import { PipelineController } from "./swarm/pipeline"; -import { renderSwarmProgress } from "./swarm/render"; -import { parseSwarmYaml, validateSwarmDefinition } from "./swarm/schema"; -import { StateTracker } from "./swarm/state"; - -const yamlPath = process.argv[2]; -if (!yamlPath) { - console.error("Usage: omp-swarm "); - process.exit(1); -} - -const resolvedPath = path.resolve(yamlPath); -console.log(`Reading: ${resolvedPath}`); - -const content = await Bun.file(resolvedPath).text(); -const def = parseSwarmYaml(content); - -console.log(`Swarm: ${def.name}`); -console.log(`Mode: ${def.mode}`); -console.log(`Target count: ${def.targetCount}`); -console.log(`Agents: ${[...def.agents.keys()].join(", ")}`); - -// Validate -const errors = validateSwarmDefinition(def); -if (errors.length > 0) { - console.error("Validation errors:", errors); - process.exit(1); -} - -// Build DAG -const deps = buildDependencyGraph(def); -const cycles = detectCycles(deps); -if (cycles) { - console.error("Cycle detected:", cycles); - process.exit(1); -} -const waves = buildExecutionWaves(deps); -console.log(`Waves: ${waves.map((w, i) => `W${i + 1}:[${w.join(",")}]`).join(" -> ")}`); - -// Resolve workspace -const workspace = path.isAbsolute(def.workspace) - ? def.workspace - : path.resolve(path.dirname(resolvedPath), def.workspace); - -await fs.mkdir(workspace, { recursive: true }); -console.log(`Workspace: ${workspace}`); - -// Initialize -const stateTracker = new StateTracker(workspace, def.name); -await stateTracker.init([...def.agents.keys()], def.targetCount, def.mode); - -// Auth + settings -const authStorage = await discoverAuthStorage(); -const modelRegistry = new ModelRegistry(authStorage); -const settings = Settings.isolated(); - -// Progress display -let lastProgressDump = 0; -const PROGRESS_INTERVAL_MS = 5000; - -// Run -console.log("\n--- Pipeline starting ---\n"); - -const controller = new PipelineController(def, waves, stateTracker); -const result = await controller.run({ - workspace, - onProgress: () => { - const now = Date.now(); - if (now - lastProgressDump > PROGRESS_INTERVAL_MS) { - lastProgressDump = now; - const lines = renderSwarmProgress(stateTracker.state); - console.log(lines.join("\n")); - console.log(); - } - }, - modelRegistry, - settings, -}); - -console.log("\n--- Pipeline finished ---\n"); -console.log(`Status: ${result.status}`); -console.log(`Iterations completed: ${result.iterations}/${def.targetCount}`); -if (result.errors.length > 0) { - console.log(`Errors (${result.errors.length}):`); - for (const err of result.errors) { - console.log(` - ${err}`); - } -} -console.log(`\nState saved to: ${stateTracker.swarmDir}`); - -// Final state dump -const lines = renderSwarmProgress(stateTracker.state); -console.log(lines.join("\n")); diff --git a/packages/swarm-extension/src/extension.ts b/packages/swarm-extension/src/extension.ts deleted file mode 100644 index c0223cd7c..000000000 --- a/packages/swarm-extension/src/extension.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Swarm Extension — Multi-agent pipeline orchestration from YAML definitions. - * - * Registers: - * - /swarm run — Execute a swarm pipeline - * - /swarm status — Show current pipeline status - * - * Usage: Add this extension's directory to your extensions config, - * then use /swarm in any oh-my-pi session. - */ - -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import type { ExtensionAPI, ExtensionCommandContext } from "@oh-my-pi/pi-coding-agent"; -import { formatDuration } from "@oh-my-pi/pi-utils"; -import { buildDependencyGraph, buildExecutionWaves, detectCycles } from "./swarm/dag"; -import { PipelineController } from "./swarm/pipeline"; -import { renderSwarmProgress } from "./swarm/render"; -import { parseSwarmYaml, type SwarmDefinition, validateSwarmDefinition } from "./swarm/schema"; -import { StateTracker } from "./swarm/state"; - -export default function swarmExtension(pi: ExtensionAPI): void { - pi.setLabel("Swarm Orchestrator"); - - pi.registerCommand("swarm", { - description: "Run a multi-agent swarm pipeline from YAML", - getArgumentCompletions: prefix => { - const subcommands = ["run", "status", "help"]; - if (!prefix) return subcommands.map(s => ({ label: s, value: s })); - return subcommands.filter(s => s.startsWith(prefix)).map(s => ({ label: s, value: s })); - }, - handler: async (args: string, ctx: ExtensionCommandContext) => { - const parts = args.trim().split(/\s+/); - const subcommand = parts[0] ?? "help"; - - switch (subcommand) { - case "run": { - const yamlPath = parts[1]; - if (!yamlPath) { - ctx.ui.notify("Usage: /swarm run ", "error"); - return; - } - await handleRun(yamlPath, ctx, pi); - return; - } - case "status": { - await handleStatus(parts[1], ctx); - return; - } - default: - ctx.ui.notify( - [ - "Swarm — multi-agent pipeline orchestrator", - "", - " /swarm run Run a pipeline", - " /swarm status [name] Show pipeline status", - " /swarm help Show this help", - ].join("\n"), - "info", - ); - return; - } - }, - }); -} - -// ============================================================================ -// /swarm run -// ============================================================================ - -async function handleRun(yamlPath: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { - // 1. Resolve and read YAML - const resolvedPath = path.isAbsolute(yamlPath) ? yamlPath : path.resolve(ctx.cwd, yamlPath); - - let content: string; - try { - content = await Bun.file(resolvedPath).text(); - } catch { - ctx.ui.notify(`Cannot read file: ${resolvedPath}`, "error"); - return; - } - - // 2. Parse YAML - let def: SwarmDefinition; - try { - def = parseSwarmYaml(content); - } catch (err) { - ctx.ui.notify(`YAML error: ${err instanceof Error ? err.message : String(err)}`, "error"); - return; - } - - // 3. Validate - const validationErrors = validateSwarmDefinition(def); - if (validationErrors.length > 0) { - ctx.ui.notify(`Validation errors:\n${validationErrors.map(e => ` - ${e}`).join("\n")}`, "error"); - return; - } - - // 4. Build DAG - const deps = buildDependencyGraph(def); - const cycleNodes = detectCycles(deps); - if (cycleNodes) { - ctx.ui.notify(`Cycle detected in agent dependencies: [${cycleNodes.join(", ")}]`, "error"); - return; - } - const waves = buildExecutionWaves(deps); - - // 5. Resolve workspace (relative to YAML file location) - const workspace = path.isAbsolute(def.workspace) - ? def.workspace - : path.resolve(path.dirname(resolvedPath), def.workspace); - - // Ensure workspace exists - await fs.mkdir(workspace, { recursive: true }); - - // 6. Initialize state tracker - const stateTracker = new StateTracker(workspace, def.name); - await stateTracker.init([...def.agents.keys()], def.targetCount, def.mode); - - // 7. Log start - const agentList = [...def.agents.keys()].join(", "); - const waveDesc = waves.map((w, i) => `wave ${i + 1}: [${w.join(", ")}]`).join("; "); - pi.logger.debug("Swarm starting", { - name: def.name, - mode: def.mode, - agents: agentList, - waves: waveDesc, - workspace, - }); - - ctx.ui.notify( - `Starting swarm '${def.name}': ${def.agents.size} agents, ${waves.length} waves, ${def.targetCount} iteration(s)`, - "info", - ); - - // 8. Set up progress widget - const widgetKey = `swarm-${def.name}`; - const updateWidget = () => { - const lines = renderSwarmProgress(stateTracker.state); - ctx.ui.setWidget(widgetKey, lines); - }; - updateWidget(); - - // 9. Run pipeline - const controller = new PipelineController(def, waves, stateTracker); - - const result = await controller.run({ - workspace, - onProgress: () => updateWidget(), - modelRegistry: ctx.modelRegistry, - settings: pi.pi.settings, - }); - - // 10. Clear widget and show summary - ctx.ui.setWidget(widgetKey, undefined); - - const elapsed = stateTracker.state.completedAt - ? formatDuration(stateTracker.state.completedAt - stateTracker.state.startedAt) - : "unknown"; - - const summaryParts = [ - `Swarm '${def.name}' ${result.status}`, - `${result.iterations}/${def.targetCount} iterations`, - `elapsed: ${elapsed}`, - ]; - - if (result.errors.length > 0) { - summaryParts.push(`${result.errors.length} error(s)`); - } - - const summaryType = result.status === "completed" ? "info" : "error"; - ctx.ui.notify(summaryParts.join(" | "), summaryType); - - // Log errors - if (result.errors.length > 0) { - pi.logger.warn("Swarm completed with errors", { errors: result.errors }); - } - - // 11. Send summary to the conversation so the LLM knows what happened - const summaryMessage = buildSummaryMessage(def, result, stateTracker, workspace); - pi.sendMessage( - { - customType: "swarm-result", - content: [{ type: "text", text: summaryMessage }], - display: true, - details: { - swarmName: def.name, - status: result.status, - iterations: result.iterations, - errorCount: result.errors.length, - }, - }, - { triggerTurn: false }, - ); -} - -// ============================================================================ -// /swarm status -// ============================================================================ - -async function handleStatus(name: string | undefined, ctx: ExtensionCommandContext): Promise { - if (!name) { - ctx.ui.notify("Usage: /swarm status (reads .swarm_/state/pipeline.json from cwd)", "info"); - return; - } - - const stateTracker = new StateTracker(ctx.cwd, name); - const state = await stateTracker.load(); - if (!state) { - ctx.ui.notify(`No state found for swarm '${name}' in ${ctx.cwd}`, "error"); - return; - } - - const lines = renderSwarmProgress(state); - ctx.ui.notify(lines.join("\n"), "info"); -} - -// ============================================================================ -// Helpers -// ============================================================================ - -function buildSummaryMessage( - def: SwarmDefinition, - result: { status: string; iterations: number; errors: string[] }, - stateTracker: StateTracker, - workspace: string, -): string { - const lines: string[] = []; - lines.push(`## Swarm Pipeline: ${def.name}`); - lines.push(""); - lines.push(`- **Status**: ${result.status}`); - lines.push(`- **Mode**: ${def.mode}`); - lines.push(`- **Iterations**: ${result.iterations}/${def.targetCount}`); - lines.push(`- **Workspace**: ${workspace}`); - lines.push(`- **State dir**: ${stateTracker.swarmDir}`); - lines.push(""); - - lines.push("### Agent Results"); - lines.push(""); - for (const [name, agent] of Object.entries(stateTracker.state.agents)) { - const duration = - agent.startedAt && agent.completedAt ? formatDuration(agent.completedAt - agent.startedAt) : "n/a"; - lines.push(`- **${name}**: ${agent.status} (${duration})${agent.error ? ` — ${agent.error}` : ""}`); - } - - if (result.errors.length > 0) { - lines.push(""); - lines.push("### Errors"); - lines.push(""); - for (const error of result.errors) { - lines.push(`- ${error}`); - } - } - - return lines.join("\n"); -} diff --git a/packages/swarm-extension/src/swarm/dag.ts b/packages/swarm-extension/src/swarm/dag.ts deleted file mode 100644 index 7efc1cbba..000000000 --- a/packages/swarm-extension/src/swarm/dag.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Directed Acyclic Graph operations for swarm agent dependencies. - * - * Builds a dependency graph from waits_for / reports_to relationships, - * detects cycles, and produces execution waves via topological sort. - */ -import type { SwarmDefinition } from "./schema"; - -/** - * Build a dependency map: agent name → set of agents it depends on. - * - * Dependencies come from: - * 1. Explicit `waits_for` declarations - * 2. Implicit from `reports_to` (if A reports_to B, then B depends on A) - * 3. For pipeline/sequential mode with no explicit deps: chain by YAML declaration order - */ -export function buildDependencyGraph(def: SwarmDefinition): Map> { - const deps = new Map>(); - - for (const name of def.agents.keys()) { - deps.set(name, new Set()); - } - - // Explicit waits_for - for (const [name, agent] of def.agents) { - for (const dep of agent.waitsFor) { - if (deps.has(dep)) { - deps.get(name)!.add(dep); - } - } - } - - // reports_to implies the target waits for the reporter - for (const [name, agent] of def.agents) { - for (const target of agent.reportsTo) { - if (deps.has(target)) { - deps.get(target)!.add(name); - } - } - } - - // For pipeline/sequential with no explicit deps, chain by declaration order - if ((def.mode === "pipeline" || def.mode === "sequential") && !hasExplicitDeps(deps)) { - for (let i = 1; i < def.agentOrder.length; i++) { - deps.get(def.agentOrder[i])!.add(def.agentOrder[i - 1]); - } - } - - return deps; -} - -function hasExplicitDeps(deps: Map>): boolean { - for (const s of deps.values()) { - if (s.size > 0) return true; - } - return false; -} - -/** - * Detect cycles in the dependency graph. - * Returns the names of agents involved in cycles, or null if acyclic. - */ -export function detectCycles(deps: Map>): string[] | null { - // Kahn's algorithm: if topological sort doesn't include all nodes, cycles exist - const inDegree = new Map(); - const forward = new Map(); // dependency → its dependents - - for (const [node, nodeDeps] of deps) { - inDegree.set(node, nodeDeps.size); - for (const dep of nodeDeps) { - const list = forward.get(dep) ?? []; - list.push(node); - forward.set(dep, list); - } - } - - const queue: string[] = []; - for (const [node, degree] of inDegree) { - if (degree === 0) queue.push(node); - } - - const sorted: string[] = []; - while (queue.length > 0) { - const node = queue.shift()!; - sorted.push(node); - for (const dependent of forward.get(node) ?? []) { - const newDegree = inDegree.get(dependent)! - 1; - inDegree.set(dependent, newDegree); - if (newDegree === 0) queue.push(dependent); - } - } - - if (sorted.length < deps.size) { - return [...deps.keys()].filter(k => !sorted.includes(k)); - } - - return null; -} - -/** - * Build execution waves from dependency graph via topological sort. - * - * Each wave contains agents whose dependencies are all in earlier waves. - * Agents within a wave can execute in parallel. - */ -export function buildExecutionWaves(deps: Map>): string[][] { - const waves: string[][] = []; - const completed = new Set(); - const remaining = new Set(deps.keys()); - - while (remaining.size > 0) { - const wave: string[] = []; - - for (const node of remaining) { - const nodeDeps = deps.get(node)!; - let ready = true; - for (const dep of nodeDeps) { - if (!completed.has(dep)) { - ready = false; - break; - } - } - if (ready) { - wave.push(node); - } - } - - if (wave.length === 0) { - throw new Error( - `Deadlock: agents [${[...remaining].join(", ")}] cannot make progress. This indicates a bug in cycle detection.`, - ); - } - - // Sort for deterministic execution order - wave.sort(); - - for (const node of wave) { - remaining.delete(node); - completed.add(node); - } - - waves.push(wave); - } - - return waves; -} diff --git a/packages/swarm-extension/src/swarm/executor.ts b/packages/swarm-extension/src/swarm/executor.ts deleted file mode 100644 index 083698034..000000000 --- a/packages/swarm-extension/src/swarm/executor.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Swarm agent execution via oh-my-pi's subagent infrastructure. - * - * Wraps `runSubprocess` to spawn individual swarm agents with full tool access. - * Each agent runs in the swarm workspace with its task instructions as the user prompt. - */ -import * as path from "node:path"; -import type { - AgentDefinition, - AgentProgress, - AgentSource, - ModelRegistry, - Settings, - SingleResult, -} from "@oh-my-pi/pi-coding-agent"; -import { runSubprocess } from "@oh-my-pi/pi-coding-agent"; -import type { SwarmAgent } from "./schema"; -import type { StateTracker } from "./state"; - -export interface SwarmExecutorOptions { - workspace: string; - swarmName: string; - iteration: number; - modelOverride?: string; - signal?: AbortSignal; - onProgress?: (agentName: string, progress: AgentProgress) => void; - modelRegistry?: ModelRegistry; - settings?: Settings; - stateTracker: StateTracker; -} - -/** - * Execute a single swarm agent as an oh-my-pi subagent. - * - * The agent receives: - * - System prompt: built from role + extra_context - * - User prompt (task): the full task instructions from the YAML - * - Working directory: the swarm workspace - * - Full tool access (bash, python, read, write, edit, grep, find, fetch, web_search, browser) - */ -export async function executeSwarmAgent( - agent: SwarmAgent, - index: number, - options: SwarmExecutorOptions, -): Promise { - const { workspace, swarmName, iteration, modelOverride, signal, onProgress, modelRegistry, settings, stateTracker } = - options; - - const agentId = `swarm-${swarmName}-${agent.name}-${iteration}`; - - const agentDef: AgentDefinition = { - name: agent.name, - description: `Swarm agent: ${agent.role}`, - systemPrompt: buildSystemPrompt(agent), - source: "project" as AgentSource, - }; - - await stateTracker.updateAgent(agent.name, { - status: "running", - iteration, - startedAt: Date.now(), - }); - await stateTracker.appendLog(agent.name, `Starting iteration ${iteration}`); - - try { - const result = await runSubprocess({ - cwd: workspace, - agent: agentDef, - task: agent.task, - index, - id: agentId, - modelOverride, - signal, - onProgress: progress => onProgress?.(agent.name, progress), - modelRegistry, - settings, - enableLsp: false, - artifactsDir: path.join(stateTracker.swarmDir, "context"), - }); - - const status = result.exitCode === 0 ? ("completed" as const) : ("failed" as const); - await stateTracker.updateAgent(agent.name, { - status, - completedAt: Date.now(), - error: result.error, - }); - await stateTracker.appendLog( - agent.name, - `Iteration ${iteration} ${status}${result.error ? `: ${result.error}` : ""}`, - ); - - return result; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - await stateTracker.updateAgent(agent.name, { - status: "failed", - completedAt: Date.now(), - error, - }); - await stateTracker.appendLog(agent.name, `Iteration ${iteration} error: ${error}`); - throw err; - } -} - -function buildSystemPrompt(agent: SwarmAgent): string { - const parts = [`You are a ${agent.role}.`]; - if (agent.extraContext) { - parts.push(agent.extraContext); - } - return parts.join("\n\n"); -} diff --git a/packages/swarm-extension/src/swarm/pipeline.ts b/packages/swarm-extension/src/swarm/pipeline.ts deleted file mode 100644 index d2e05464d..000000000 --- a/packages/swarm-extension/src/swarm/pipeline.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Pipeline controller for swarm execution. - * - * Orchestrates execution waves within each iteration: - * - Agents in the same wave execute in parallel - * - Waves execute sequentially (wave N+1 starts after wave N completes) - * - For pipeline mode, iterations repeat the full DAG execution - */ -import type { AgentSource, ModelRegistry, Settings, SingleResult } from "@oh-my-pi/pi-coding-agent"; -import { executeSwarmAgent } from "./executor"; -import type { SwarmDefinition } from "./schema"; -import type { StateTracker } from "./state"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface PipelineOptions { - workspace: string; - signal?: AbortSignal; - onProgress?: (state: PipelineProgress) => void; - modelRegistry?: ModelRegistry; - settings?: Settings; -} - -export interface PipelineProgress { - iteration: number; - targetCount: number; - currentWave: number; - totalWaves: number; - agents: Record; -} - -export interface PipelineResult { - status: "completed" | "failed" | "aborted"; - iterations: number; - agentResults: Map; - errors: string[]; -} - -// ============================================================================ -// Controller -// ============================================================================ - -export class PipelineController { - #def: SwarmDefinition; - #waves: string[][]; - #stateTracker: StateTracker; - - constructor(def: SwarmDefinition, waves: string[][], stateTracker: StateTracker) { - this.#def = def; - this.#waves = waves; - this.#stateTracker = stateTracker; - } - - async run(options: PipelineOptions): Promise { - const { workspace, signal, onProgress, modelRegistry, settings } = options; - const allResults = new Map(); - const errors: string[] = []; - - for (const name of this.#def.agents.keys()) { - allResults.set(name, []); - } - - const targetCount = this.#def.targetCount; - - await this.#stateTracker.appendOrchestratorLog( - `Pipeline '${this.#def.name}' starting: mode=${this.#def.mode} iterations=${targetCount} waves=${this.#waves.length} agents=${this.#def.agents.size}`, - ); - - try { - for (let iteration = 0; iteration < targetCount; iteration++) { - if (signal?.aborted) { - await this.#stateTracker.updatePipeline({ status: "aborted" }); - return { status: "aborted", iterations: iteration, agentResults: allResults, errors }; - } - - await this.#stateTracker.updatePipeline({ iteration }); - await this.#stateTracker.appendOrchestratorLog(`--- Iteration ${iteration + 1}/${targetCount} ---`); - - const emitProgress = (currentWave: number) => { - onProgress?.({ - iteration, - targetCount, - currentWave, - totalWaves: this.#waves.length, - agents: this.#buildProgressSnapshot(), - }); - }; - - const iterationResults = await this.#runIteration(iteration, { - workspace, - signal, - emitProgress, - modelRegistry, - settings, - }); - - for (const [agentName, result] of iterationResults) { - allResults.get(agentName)!.push(result); - if (result.exitCode !== 0) { - errors.push( - `${agentName} (iteration ${iteration + 1}): ${result.error || `exit code ${result.exitCode}`}`, - ); - } - } - } - - const status = errors.length > 0 ? ("failed" as const) : ("completed" as const); - await this.#stateTracker.updatePipeline({ status, completedAt: Date.now() }); - await this.#stateTracker.appendOrchestratorLog(`Pipeline ${status} (${errors.length} errors)`); - return { status, iterations: targetCount, agentResults: allResults, errors }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - await this.#stateTracker.updatePipeline({ status: "failed", completedAt: Date.now() }); - await this.#stateTracker.appendOrchestratorLog(`Pipeline fatal error: ${error}`); - errors.push(error); - return { status: "failed", iterations: 0, agentResults: allResults, errors }; - } - } - - async #runIteration( - iteration: number, - options: { - workspace: string; - signal?: AbortSignal; - emitProgress: (currentWave: number) => void; - modelRegistry?: ModelRegistry; - settings?: Settings; - }, - ): Promise> { - const results = new Map(); - let agentIndex = 0; - - for (let waveIdx = 0; waveIdx < this.#waves.length; waveIdx++) { - const wave = this.#waves[waveIdx]; - - if (options.signal?.aborted) break; - - await this.#stateTracker.appendOrchestratorLog( - `Wave ${waveIdx + 1}/${this.#waves.length}: [${wave.join(", ")}]`, - ); - - // Mark agents in this wave as waiting - for (const agentName of wave) { - await this.#stateTracker.updateAgent(agentName, { - status: "waiting", - iteration, - wave: waveIdx, - }); - } - options.emitProgress(waveIdx); - - // Execute all agents in wave in parallel, catching per-agent errors - const waveResults = await Promise.all( - wave.map(async agentName => { - const agent = this.#def.agents.get(agentName)!; - const currentIndex = agentIndex++; - try { - const result = await executeSwarmAgent(agent, currentIndex, { - workspace: options.workspace, - swarmName: this.#def.name, - iteration, - modelOverride: agent.model ?? this.#def.model, - signal: options.signal, - onProgress: (_name, _progress) => { - options.emitProgress(waveIdx); - }, - modelRegistry: options.modelRegistry, - settings: options.settings, - stateTracker: this.#stateTracker, - }); - return { agentName, result }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - const failResult: SingleResult = { - index: currentIndex, - id: `swarm-${this.#def.name}-${agentName}-${iteration}`, - agent: agentName, - agentSource: "project" as AgentSource, - task: agent.task, - exitCode: 1, - output: "", - stderr: error, - truncated: false, - durationMs: 0, - tokens: 0, - requests: 0, - error, - }; - return { agentName, result: failResult }; - } - }), - ); - - for (const { agentName, result } of waveResults) { - results.set(agentName, result); - } - - options.emitProgress(waveIdx); - } - - return results; - } - - #buildProgressSnapshot(): Record { - const snapshot: Record = {}; - for (const [name, agent] of Object.entries(this.#stateTracker.state.agents)) { - snapshot[name] = { status: agent.status, iteration: agent.iteration }; - } - return snapshot; - } -} diff --git a/packages/swarm-extension/src/swarm/render.ts b/packages/swarm-extension/src/swarm/render.ts deleted file mode 100644 index e17da65a6..000000000 --- a/packages/swarm-extension/src/swarm/render.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * TUI progress rendering for swarm pipeline status. - */ -import { formatDuration, truncate } from "@oh-my-pi/pi-utils"; -import type { AgentState, SwarmState } from "./state"; - -const STATUS_LABELS: Record = { - completed: "[done]", - running: "[....]", - failed: "[FAIL]", - pending: "[ ]", - waiting: "[wait]", - idle: "[idle]", - aborted: "[stop]", -}; - -export function renderSwarmProgress(state: SwarmState): string[] { - const lines: string[] = []; - - const statusLabel = state.status.toUpperCase(); - lines.push(`Swarm: ${state.name} [${statusLabel}]`); - lines.push(`Mode: ${state.mode} | Iteration: ${state.iteration + 1}/${state.targetCount}`); - lines.push(""); - - const agents: AgentState[] = Object.values(state.agents); - if (agents.length === 0) { - lines.push(" (no agents)"); - return lines; - } - - for (const agent of agents) { - const icon = STATUS_LABELS[agent.status] ?? "[????]"; - const duration = formatAgentDuration(agent); - const errorSuffix = agent.error ? ` - ${truncate(agent.error, 60)}` : ""; - lines.push(` ${icon} ${agent.name}: ${agent.status}${duration}${errorSuffix}`); - } - - // Summary line - const completed = agents.filter(a => a.status === "completed").length; - const failed = agents.filter(a => a.status === "failed").length; - const running = agents.filter(a => a.status === "running").length; - - lines.push(""); - const parts = [`${completed}/${agents.length} done`]; - if (running > 0) parts.push(`${running} running`); - if (failed > 0) parts.push(`${failed} failed`); - if (state.startedAt) { - parts.push(`elapsed: ${formatDuration(Date.now() - state.startedAt)}`); - } - lines.push(` ${parts.join(" | ")}`); - - return lines; -} - -function formatAgentDuration(agent: { startedAt?: number; completedAt?: number; status: string }): string { - if (agent.startedAt && agent.completedAt) { - return ` (${formatDuration(agent.completedAt - agent.startedAt)})`; - } - if (agent.startedAt && (agent.status === "running" || agent.status === "waiting")) { - return ` (${formatDuration(Date.now() - agent.startedAt)}...)`; - } - return ""; -} diff --git a/packages/swarm-extension/src/swarm/schema.ts b/packages/swarm-extension/src/swarm/schema.ts deleted file mode 100644 index 567b94349..000000000 --- a/packages/swarm-extension/src/swarm/schema.ts +++ /dev/null @@ -1,157 +0,0 @@ -// ============================================================================ -// Raw YAML shape (snake_case, optional fields) -// ============================================================================ - -interface RawSwarmAgentConfig { - role: string; - task: string; - extra_context?: string; - reports_to?: string[]; - waits_for?: string[]; - model?: string; -} - -interface RawSwarmConfig { - name: string; - workspace: string; - mode?: string; - target_count?: number; - model?: string; - agents: Record; -} - -// ============================================================================ -// Normalized types (camelCase, defaults applied) -// ============================================================================ - -export type SwarmMode = "pipeline" | "parallel" | "sequential"; - -export interface SwarmAgent { - name: string; - role: string; - task: string; - extraContext?: string; - reportsTo: string[]; - waitsFor: string[]; - model?: string; -} - -export interface SwarmDefinition { - name: string; - workspace: string; - mode: SwarmMode; - targetCount: number; - model?: string; - agents: Map; - /** Preserves YAML declaration order for implicit pipeline sequencing. */ - agentOrder: string[]; -} - -// ============================================================================ -// Parsing -// ============================================================================ - -const VALID_MODES = new Set(["pipeline", "parallel", "sequential"]); -const VALID_SWARM_NAME = /^[a-zA-Z0-9._-]+$/; - -export function parseSwarmYaml(content: string): SwarmDefinition { - const raw = Bun.YAML.parse(content) as { swarm?: RawSwarmConfig } | null; - if (!raw?.swarm) { - throw new Error("YAML must have a top-level 'swarm' key"); - } - const swarm = raw.swarm; - - if (!swarm.name || typeof swarm.name !== "string") { - throw new Error("swarm.name is required and must be a string"); - } - if (!VALID_SWARM_NAME.test(swarm.name)) { - throw new Error("swarm.name may only contain letters, numbers, dot, underscore, and dash"); - } - if (!swarm.workspace || typeof swarm.workspace !== "string") { - throw new Error("swarm.workspace is required and must be a string"); - } - if (!swarm.agents || typeof swarm.agents !== "object" || Object.keys(swarm.agents).length === 0) { - throw new Error("swarm.agents must contain at least one agent"); - } - - const mode = swarm.mode ?? "sequential"; - if (!VALID_MODES.has(mode)) { - throw new Error(`Invalid mode '${mode}'. Must be one of: ${[...VALID_MODES].join(", ")}`); - } - - const agentOrder: string[] = []; - const agents = new Map(); - - for (const [name, config] of Object.entries(swarm.agents)) { - if (!config.role || typeof config.role !== "string") { - throw new Error(`Agent '${name}': 'role' is required`); - } - if (!config.task || typeof config.task !== "string") { - throw new Error(`Agent '${name}': 'task' is required`); - } - - agentOrder.push(name); - agents.set(name, { - name, - role: config.role, - task: config.task.trim(), - extraContext: config.extra_context?.trim(), - reportsTo: Array.isArray(config.reports_to) ? config.reports_to : [], - model: typeof config.model === "string" ? config.model.trim() : undefined, - waitsFor: Array.isArray(config.waits_for) ? config.waits_for : [], - }); - } - - return { - name: swarm.name, - workspace: swarm.workspace, - mode: mode as SwarmMode, - targetCount: swarm.target_count ?? 1, - model: typeof swarm.model === "string" ? swarm.model.trim() : undefined, - agents, - agentOrder, - }; -} - -// ============================================================================ -// Validation (semantic — references, constraints) -// ============================================================================ - -export function validateSwarmDefinition(def: SwarmDefinition): string[] { - const errors: string[] = []; - const agentNames = new Set(def.agents.keys()); - - if (def.model !== undefined && def.model.length === 0) { - errors.push("swarm.model must not be empty when provided"); - } - for (const [name, agent] of def.agents) { - for (const dep of agent.waitsFor) { - if (!agentNames.has(dep)) { - errors.push(`Agent '${name}' waits_for unknown agent '${dep}'`); - } - if (dep === name) { - errors.push(`Agent '${name}' cannot wait for itself`); - } - } - for (const target of agent.reportsTo) { - if (!agentNames.has(target)) { - errors.push(`Agent '${name}' reports_to unknown agent '${target}'`); - } - if (target === name) { - errors.push(`Agent '${name}' cannot report to itself`); - } - } - if (agent.model !== undefined && agent.model.length === 0) { - errors.push(`Agent '${name}' model must not be empty when provided`); - } - } - - if (def.targetCount < 1) { - errors.push("target_count must be at least 1"); - } - if (def.mode !== "pipeline" && def.targetCount !== 1) { - errors.push("target_count is only supported in pipeline mode"); - } - - return errors; -} diff --git a/packages/swarm-extension/src/swarm/state.ts b/packages/swarm-extension/src/swarm/state.ts deleted file mode 100644 index 4d818991d..000000000 --- a/packages/swarm-extension/src/swarm/state.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Filesystem state tracker for swarm pipeline execution. - * - * Persists pipeline and per-agent state to `.swarm_/` in the workspace. - * Supports resumability by loading state from disk. - */ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; - -// ============================================================================ -// State types -// ============================================================================ - -export type PipelineStatus = "idle" | "running" | "completed" | "failed" | "aborted"; -export type AgentStatus = "pending" | "waiting" | "running" | "completed" | "failed"; - -export interface AgentState { - name: string; - status: AgentStatus; - iteration: number; - wave: number; - startedAt?: number; - completedAt?: number; - error?: string; -} - -export interface SwarmState { - name: string; - status: PipelineStatus; - mode: string; - iteration: number; - targetCount: number; - agents: Record; - startedAt: number; - completedAt?: number; -} - -// ============================================================================ -// State tracker -// ============================================================================ - -export class StateTracker { - #swarmDir: string; - #state: SwarmState; - - constructor(workspaceDir: string, name: string) { - this.#swarmDir = path.join(workspaceDir, `.swarm_${name}`); - this.#state = { - name, - status: "idle", - mode: "sequential", - iteration: 0, - targetCount: 1, - agents: {}, - startedAt: Date.now(), - }; - } - - get swarmDir(): string { - return this.#swarmDir; - } - - get state(): Readonly { - return this.#state; - } - - async init(agentNames: string[], targetCount: number, mode: string): Promise { - await fs.mkdir(path.join(this.#swarmDir, "state"), { recursive: true }); - await fs.mkdir(path.join(this.#swarmDir, "logs"), { recursive: true }); - await fs.mkdir(path.join(this.#swarmDir, "context"), { recursive: true }); - - this.#state.targetCount = targetCount; - this.#state.mode = mode; - this.#state.status = "running"; - this.#state.startedAt = Date.now(); - - for (const name of agentNames) { - this.#state.agents[name] = { - name, - status: "pending", - iteration: 0, - wave: 0, - }; - } - - await this.#persist(); - } - - async updateAgent(name: string, update: Partial): Promise { - const agent = this.#state.agents[name]; - if (!agent) return; - Object.assign(agent, update); - await this.#persist(); - } - - async updatePipeline(update: Partial): Promise { - Object.assign(this.#state, update); - await this.#persist(); - } - - async appendLog(agentName: string, message: string): Promise { - const logPath = path.join(this.#swarmDir, "logs", `${agentName}.log`); - const timestamp = new Date().toISOString(); - await fs.appendFile(logPath, `[${timestamp}] ${message}\n`); - } - - async appendOrchestratorLog(message: string): Promise { - const logPath = path.join(this.#swarmDir, "logs", "orchestrator.log"); - const timestamp = new Date().toISOString(); - await fs.appendFile(logPath, `[${timestamp}] ${message}\n`); - } - - async load(): Promise { - const statePath = path.join(this.#swarmDir, "state", "pipeline.json"); - try { - const content = await Bun.file(statePath).text(); - this.#state = JSON.parse(content) as SwarmState; - return this.#state; - } catch { - return null; - } - } - - async #persist(): Promise { - await Bun.write(path.join(this.#swarmDir, "state", "pipeline.json"), JSON.stringify(this.#state, null, 2)); - } -} diff --git a/packages/swarm-extension/test/swarm/executor.test.ts b/packages/swarm-extension/test/swarm/executor.test.ts deleted file mode 100644 index dc7782d7c..000000000 --- a/packages/swarm-extension/test/swarm/executor.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, 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 { ModelRegistry, SingleResult } from "@oh-my-pi/pi-coding-agent"; -import * as taskExecutor from "@oh-my-pi/pi-coding-agent"; -import { executeSwarmAgent } from "../../src/swarm/executor"; -import { StateTracker } from "../../src/swarm/state"; - -const mockResult = { - index: 0, - id: "test-agent-0", - agent: "test", - agentSource: "project", - task: "test task", - exitCode: 0, - output: "ok", - stderr: "", - truncated: false, - durationMs: 100, - tokens: 0, -} as SingleResult; - -let workspace: string; - -beforeEach(async () => { - workspace = await fs.mkdtemp(path.join(os.tmpdir(), "swarm-test-")); -}); - -afterEach(async () => { - vi.restoreAllMocks(); - await fs.rm(workspace, { recursive: true, force: true }); -}); - -describe("executeSwarmAgent", () => { - it("does not pass authStorage to runSubprocess when modelRegistry is provided", async () => { - const runSubprocessSpy = vi.spyOn(taskExecutor, "runSubprocess").mockResolvedValue(mockResult); - - const mockModelRegistry = { - authStorage: { discover: vi.fn() }, - } as unknown as ModelRegistry; - - const stateTracker = new StateTracker(workspace, "test-swarm"); - await stateTracker.init(["test-agent"], 1, "parallel"); - - const agent = { - name: "test-agent", - role: "tester", - task: "do something", - reportsTo: [], - waitsFor: [], - }; - - await executeSwarmAgent(agent, 0, { - workspace, - swarmName: "test-swarm", - iteration: 0, - modelRegistry: mockModelRegistry, - stateTracker, - }); - - expect(runSubprocessSpy).toHaveBeenCalledTimes(1); - const passedOptions = runSubprocessSpy.mock.calls[0][0]; - const { authStorage, modelRegistry } = passedOptions; - expect(authStorage).toBeUndefined(); - expect(modelRegistry).toBe(mockModelRegistry); - }); -}); diff --git a/packages/swarm-extension/tsconfig.json b/packages/swarm-extension/tsconfig.json deleted file mode 100644 index d9a6e62ad..000000000 --- a/packages/swarm-extension/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../tsconfig.workspace.json", - "include": [ - "src" - ] -} diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a3da37fb2..98fdcca42 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Fixed + +- Fixed table borders (and adjacent cells) inheriting an open inline-code color when a cell's content wraps mid-code-span, by terminating each wrapped cell line's SGR state before the border glyphs ([#7575](https://github.com/can1357/oh-my-pi/issues/7575)). +- Fixed inline images not rendering under WSL + Windows Terminal: the SIXEL capability probe gated on `process.platform === "win32"`, but WSL reports `linux`, so the probe never ran and images fell back to the text placeholder even on Sixel-capable Windows Terminal. The probe now runs on any ConPTY host (native win32 or WSL) ([#6009](https://github.com/can1357/oh-my-pi/issues/6009)). + ## [17.2.5] - 2026-08-03 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 1a4c7e81d..54f41a8be 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.2.8", + "version": "17.2.9", "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/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index c82bfd574..447f053ae 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -34,7 +34,7 @@ export class CancellableLoader extends Loader { } } - dispose(): void { + override dispose(): void { this.stop(); } } diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 935b3cb49..8c5c0daaf 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -55,7 +55,7 @@ export class Loader extends Text { this.start(); } - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const source = super.render(width); if (source !== this.#layoutSource) { const paddingX = getPaddingX(1); diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a5dec34bd..64ee150fc 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -804,11 +804,11 @@ markdownParser.use({ // (no `m` flag), and stickiness only removes the futile later attempts. The // flags/anchor guard below skips any rule a future marked version changes. class AnchoredAtZero extends RegExp { - exec(str: string): RegExpExecArray | null { + override exec(str: string): RegExpExecArray | null { this.lastIndex = 0; // sticky matches set lastIndex; rules are shared return super.exec(str); } - test(str: string): boolean { + override test(str: string): boolean { this.lastIndex = 0; return super.test(str); } @@ -2816,7 +2816,13 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") { wrapped.pop(); } - return wrapped; + // The native wrap deliberately leaves fg color and bold/italic open at + // line ends so continuation lines can re-open them. Table rows splice + // every cell line between unstyled border glyphs, so an open style + // (e.g. mdCode) would bleed into the "│" and the following cells. + // Terminate each line at default fg, clearing bold/italic but keeping + // any ambient background (message-bg rendering) intact. + return wrapped.map(line => `${line}\x1b[22m\x1b[23m\x1b[39m`); } /** diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 7b309b8af..22c4cd8e2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1670,7 +1670,10 @@ export class TUI extends Container { #querySixelSupport(): void { if (TERMINAL.imageProtocol) return; - if (process.platform !== "win32") return; + // win32 native or WSL under Windows Terminal — both are ConPTY-hosted and + // reach the same WT graphics negotiation. WSL reports process.platform + // "linux", so a bare win32 check silently skips the probe there (#6009). + if (!isConPTYHosted()) return; if (!Bun.env.WT_SESSION) return; if (!process.stdin.isTTY || !process.stdout.isTTY) return; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 709ba234f..35057dfa9 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -466,6 +466,56 @@ describe("Markdown component", () => { } }); + it("does not leak inline-code color into table borders when cells wrap", () => { + const markdown = new Markdown( + `| Command | Notes | +| --- | --- | +| \`config.setupgrading(pendingRequests, emptyFlag)\` | plain | +| short | other |`, + 0, + 0, + defaultMarkdownTheme, + ); + + // Narrow enough to force the long codespan to wrap mid-run. + const lines = markdown.render(24); + const joinedOutput = lines.join("\n"); + expect(joinedOutput.includes("\x1b[33m"), "Inline code should be styled (yellow)").toBeTruthy(); + expect(lines.filter(line => line.includes("|")).length).toBeGreaterThan(3); + + // Walk SGR state through every table row: the "|" border glyphs (and + // everything after them on the line) must never be rendered under an + // open fg color or bold attribute. + for (const line of lines) { + if (!line.includes("|")) continue; + let bold = false; + let fgOpen = false; + let i = 0; + while (i < line.length) { + if (line[i] === "\x1b") { + const seq = line.slice(i).match(/^\x1b\[([0-9;]*)m/); + expect(seq, `unparseable SGR in: ${JSON.stringify(line)}`).not.toBeNull(); + for (const p of seq![1]!.split(";")) { + if (p === "1") bold = true; + else if (p === "22") bold = false; + else if (p === "0") { + bold = false; + fgOpen = false; + } else if (p === "39") fgOpen = false; + else if (p === "38" || /^3[0-7]$/.test(p) || /^9[0-7]$/.test(p)) fgOpen = true; + } + i += seq![0].length; + continue; + } + if (line[i] === "|") { + expect(fgOpen, `Border inherits fg color in: ${JSON.stringify(line)}`).toBe(false); + expect(bold, `Border inherits bold in: ${JSON.stringify(line)}`).toBe(false); + } + i++; + } + } + }); + it("should handle extremely narrow width gracefully", () => { const markdown = new Markdown( `| A | B | C | diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 364b1b660..6ec257d96 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -87,7 +87,7 @@ class WrappingLinesComponent implements Component { } class UnknownViewportTerminal extends VirtualTerminal { - isNativeViewportAtBottom(): undefined { + override isNativeViewportAtBottom(): undefined { return undefined; } } @@ -96,7 +96,7 @@ class StaleBottomViewportTerminal extends VirtualTerminal { #previous: boolean | undefined; #returnStale = false; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { const current = super.isNativeViewportAtBottom(); if (this.#returnStale) { this.#returnStale = false; @@ -113,18 +113,18 @@ class StaleBottomViewportTerminal extends VirtualTerminal { class CountingViewportTerminal extends VirtualTerminal { viewportProbeCount = 0; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { this.viewportProbeCount += 1; return super.isNativeViewportAtBottom(); } } class LegacyKeyboardVirtualTerminal extends VirtualTerminal { - get keyboardEnhancementEnterSequence(): string | null { + override get keyboardEnhancementEnterSequence(): string | null { return undefined as unknown as string | null; } - get keyboardEnhancementExitSequence(): string | null { + override get keyboardEnhancementExitSequence(): string | null { return undefined as unknown as string | null; } } diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 7962af8e2..cd64b950a 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -425,7 +425,7 @@ const BURST_STEP_METADATA = { } satisfies Record; class UnknownViewportTerminal extends VirtualTerminal { - isNativeViewportAtBottom(): undefined { + override isNativeViewportAtBottom(): undefined { return undefined; } } @@ -433,7 +433,7 @@ class UnknownViewportTerminal extends VirtualTerminal { class IntermittentUnknownViewportTerminal extends VirtualTerminal { #probeCount = 0; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { this.#probeCount += 1; return this.#probeCount % 3 === 0 ? undefined : super.isNativeViewportAtBottom(); } @@ -443,7 +443,7 @@ class StaleBottomTerminal extends VirtualTerminal { #previous: boolean | undefined; #returnStale = false; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { const current = super.isNativeViewportAtBottom(); if (this.#returnStale) { this.#returnStale = false; diff --git a/packages/tui/test/sixel-probe.test.ts b/packages/tui/test/sixel-probe.test.ts index 8c532908a..45767aeeb 100644 --- a/packages/tui/test/sixel-probe.test.ts +++ b/packages/tui/test/sixel-probe.test.ts @@ -9,6 +9,8 @@ type MutableTerminalInfo = { const terminalInfo = TERMINAL as unknown as MutableTerminalInfo; const originalProtocol = TERMINAL.imageProtocol; const originalWtSession = Bun.env.WT_SESSION; +const originalWslDistro = Bun.env.WSL_DISTRO_NAME; +const originalWslInterop = Bun.env.WSL_INTEROP; const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); @@ -29,6 +31,10 @@ describe("TUI SIXEL capability probe", () => { terminalInfo.imageProtocol = originalProtocol; if (originalWtSession === undefined) delete Bun.env.WT_SESSION; else Bun.env.WT_SESSION = originalWtSession; + if (originalWslDistro === undefined) delete Bun.env.WSL_DISTRO_NAME; + else Bun.env.WSL_DISTRO_NAME = originalWslDistro; + if (originalWslInterop === undefined) delete Bun.env.WSL_INTEROP; + else Bun.env.WSL_INTEROP = originalWslInterop; restoreIsTty(process.stdin, stdinIsTtyDescriptor); restoreIsTty(process.stdout, stdoutIsTtyDescriptor); }); @@ -102,4 +108,27 @@ describe("TUI SIXEL capability probe", () => { expect(TERMINAL.imageProtocol).toBeNull(); tui.stop(); }); + + it("enables SIXEL under WSL + Windows Terminal (process.platform is linux)", () => { + // Regression for #6009: inside WSL, process.platform reports "linux" even + // though the host is Windows Terminal. The probe used to gate on + // process.platform === "win32", so WSL sessions never negotiated SIXEL and + // fell back to the text image card. It now gates on isConPTYHosted(), which + // treats WSL (WSL_DISTRO_NAME/WSL_INTEROP) as a Windows host. + if (process.platform !== "linux") return; + setTerminalImageProtocol(null); + terminalInfo.imageProtocol = null; + Bun.env.WT_SESSION = "test-wt-session"; + Bun.env.WSL_DISTRO_NAME = "Ubuntu"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.start(); + terminal.sendInput("\x1b[?1;2;4c"); + + expect(TERMINAL.imageProtocol).toBe(ImageProtocol.Sixel); + tui.stop(); + }); }); diff --git a/packages/typescript-edit-benchmark/src/mutations.ts b/packages/typescript-edit-benchmark/src/mutations.ts index 27d308e55..9f1fecb96 100644 --- a/packages/typescript-edit-benchmark/src/mutations.ts +++ b/packages/typescript-edit-benchmark/src/mutations.ts @@ -489,7 +489,7 @@ class CallArgumentSwapMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); @@ -722,7 +722,7 @@ class IdentifierMultiEditMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); @@ -1036,7 +1036,7 @@ class SwapAdjacentLinesMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index c195b1d2c..6e093b703 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + +### Added + +- Added a public `compareVersions` utility (`@oh-my-pi/pi-utils`) that compares two version strings with SemVer-2.0 prerelease ordering, build-metadata stripping, and numeric segment comparison without float overflow; never throws. + +### Fixed + +- Honor the current process `PATH` when caching executable lookups, preventing stale tool paths after environment reloads. +- Parsed account-cap reset windows such as “Your limit will reset in 13 minutes” so credential backoff honors the provider's full reset duration. + ## [17.2.6] - 2026-08-03 ### Added @@ -17,6 +28,9 @@ ### Changed - Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations. +### Added + +- Added postmortem fatal recovery hint providers so applications can print actionable recovery commands before cleanup starts. ## [17.2.4] - 2026-08-01 diff --git a/packages/utils/package.json b/packages/utils/package.json index 80822c8f0..ba1617713 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.2.8", + "version": "17.2.9", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/utils/src/fetch-retry.ts b/packages/utils/src/fetch-retry.ts index afe7138e3..51263af31 100644 --- a/packages/utils/src/fetch-retry.ts +++ b/packages/utils/src/fetch-retry.ts @@ -10,6 +10,8 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i; // "try again in 5 min" / "try again in ~158 min." / "try again in 2h" / // "try again in 90 minutes" / "try again in 1 hour" const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i; +// "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h" +const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i; /** * Server-suggested retry delay extraction. Merges the patterns historically used @@ -83,7 +85,11 @@ export function extractRetryHint(source: Response | Headers | null | undefined, if (totalMs > 0) return totalMs; } } - for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) { + // Account-reset hints ("will reset in …") take precedence over short + // retry hints ("please retry in 5s"): a body carrying both must honour the + // longer account window, not the shorter generic one. QUOTA_RESET_PATTERN + // ("reset after …") above already runs first and stays first. + for (const pattern of [WILL_RESET_IN_PATTERN, PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) { const match = pattern.exec(body); if (match?.[1]) { const value = Number.parseFloat(match[1]); diff --git a/packages/utils/src/frontmatter.ts b/packages/utils/src/frontmatter.ts index 8178172f1..0aad83593 100644 --- a/packages/utils/src/frontmatter.ts +++ b/packages/utils/src/frontmatter.ts @@ -71,7 +71,7 @@ export class FrontmatterError extends Error { this.name = "FrontmatterError"; } - toString(): string { + override toString(): string { // Format the error with stack and detail, including the error message, stack, and source if present const details: string[] = [this.message]; if (this.source !== undefined) { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8a2ba3b8c..7d1363b7d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -34,6 +34,7 @@ export * from "./tab-spacing"; export * from "./temp"; export * from "./tls-fetch"; export * from "./type-guards"; +export * from "./version"; export * from "./which"; function isPlainObject(val: object): val is Record { diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 62d3c76b4..63fa30d02 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -67,6 +67,17 @@ function exitProcess(code: number): never { let cleanupPromise: Promise | undefined; let stdioDisconnectRegistrations = 0; +/** User-facing command printed before fatal cleanup so interrupted work can be resumed. */ +export interface FatalRecoveryHint { + /** Stable label identifying the recoverable session or process. */ + label: string; + /** Complete shell command the user can execute to resume the interrupted work. */ + command: string; +} + +type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined; +const fatalRecoveryHintProviders = new Set(); + /** * Internal: runs all registered cleanup callbacks for the given reason. * Ensures each callback is invoked at most once. Handles errors and prevents reentrancy. @@ -196,6 +207,38 @@ export function interceptUnhandledRejections(interceptor: (reason: unknown) => b return () => rejectionInterceptors.delete(interceptor); } +/** + * Register a synchronous recovery command to print when the process exits + * through an uncaught exception or unhandled rejection. + */ +export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void { + fatalRecoveryHintProviders.add(provider); + return () => fatalRecoveryHintProviders.delete(provider); +} + +function escapeFatalHintText(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => { + const code = char.codePointAt(0) ?? 0; + return `\\u${code.toString(16).padStart(4, "0")}`; + }); +} + +function formatFatalRecoveryHints(): string { + const lines: string[] = []; + const seenCommands = new Set(); + for (const provider of fatalRecoveryHintProviders) { + try { + const hint = provider(); + if (!hint?.command || seenCommands.has(hint.command)) continue; + seenCommands.add(hint.command); + lines.push(` ${escapeFatalHintText(hint.label)}: ${escapeFatalHintText(hint.command)}`); + } catch (err) { + logger.warn("Fatal recovery hint provider failed", { err }); + } + } + return lines.length > 0 ? `\n[Recovery]\n${lines.join("\n")}\n` : ""; +} + function formatFatalError(label: string, err: Error): string { const name = err.name || "Error"; const message = err.message || "(no message)"; @@ -212,7 +255,7 @@ async function exitAfterFatal(label: string, logMessage: string, err: Error, rea // A revoked terminal can make stream writes raise another fatal error. Use // the descriptor directly so failure stays synchronous and contained. try { - fs.writeSync(2, formatFatalError(label, err)); + fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`); } catch {} logger.error(logMessage, { err }); await runCleanup(reason); diff --git a/packages/utils/src/version.ts b/packages/utils/src/version.ts new file mode 100644 index 000000000..15088117e --- /dev/null +++ b/packages/utils/src/version.ts @@ -0,0 +1,99 @@ +const DIGITS = /^\d+$/; + +/** + * Compare two version strings. + * + * Canonical comparator that supersedes the historical in-repo copies + * (update-cli, hackage scraper, release scripts): + * - inputs are trimmed and at most one leading `v`/`V` is stripped + * - dot-separated segments are compared numerically, missing trailing + * segments count as 0, so `1.2` === `1.2.0` and any segment count works + * - a SemVer-2.0 prerelease suffix sorts before the plain release + * (`1.0.0-beta` < `1.0.0`); prerelease identifiers follow SemVer order + * (numeric < alphanumeric, numeric compared by value, alphanumeric + * compared lexically, longer sets of equal fields win) + * - SemVer build metadata begins at the first `+` and does not participate + * in precedence; it is stripped before core/prerelease parsing + * - malformed numeric segments compare as 0 (`1.2.x` === `1.2.0`) + * - never throws; returns only -1 | 0 | 1 + */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + + const core = compareNumericParts(pa.core, pb.core); + if (core !== 0) return core; + + return comparePrerelease(pa.prerelease, pb.prerelease); +} + +interface ParsedVersion { + core: string[]; + prerelease: string[] | null; +} + +function parseVersion(version: string): ParsedVersion { + const trimmed = version.trim(); + const stripped = trimmed.startsWith("v") || trimmed.startsWith("V") ? trimmed.slice(1) : trimmed; + const plusIndex = stripped.indexOf("+"); + const withoutBuild = plusIndex === -1 ? stripped : stripped.slice(0, plusIndex); + const dashIndex = withoutBuild.indexOf("-"); + if (dashIndex === -1) { + return { core: withoutBuild.split("."), prerelease: null }; + } + return { + core: withoutBuild.slice(0, dashIndex).split("."), + prerelease: withoutBuild.slice(dashIndex + 1).split("."), + }; +} + +/** Compare dot-separated numeric segments; missing/malformed segments count as 0. */ +function compareNumericParts(a: string[], b: string[]): number { + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + // Missing or malformed segments compare as 0. + const sa = a[i]; + const sb = b[i]; + const result = compareDigits( + sa !== undefined && DIGITS.test(sa) ? sa : "0", + sb !== undefined && DIGITS.test(sb) ? sb : "0", + ); + if (result !== 0) return result; + } + return 0; +} + +/** Exact integer comparison of digit strings, avoiding float overflow. */ +function compareDigits(a: string, b: string): number { + const na = a.replace(/^0+/, "") || "0"; + const nb = b.replace(/^0+/, "") || "0"; + if (na.length !== nb.length) return na.length < nb.length ? -1 : 1; + if (na < nb) return -1; + if (na > nb) return 1; + return 0; +} + +/** SemVer-2.0 prerelease ordering; null means a plain release, which wins. */ +function comparePrerelease(a: string[] | null, b: string[] | null): number { + if (a === null || b === null) { + return a === b ? 0 : a === null ? 1 : -1; + } + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + const ia = a[i]; + const ib = b[i]; + if (ia === undefined) return -1; + if (ib === undefined) return 1; + const aNumeric = DIGITS.test(ia); + const bNumeric = DIGITS.test(ib); + if (aNumeric && bNumeric) { + const result = compareDigits(ia, ib); + if (result !== 0) return result; + } else if (aNumeric !== bNumeric) { + return aNumeric ? -1 : 1; + } else if (ia !== ib) { + return ia < ib ? -1 : 1; + } + } + return 0; +} diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index e1facff59..327c60fb4 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -183,8 +183,8 @@ export interface WhichOptions extends Bun.WhichOptions { // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH. // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses. -function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null { - const regular = Bun.which(command); +function darwinWhich(command: string, options?: Bun.WhichOptions): string | null { + const regular = Bun.which(command, options); if (regular) return regular; if (isXcodeBin(command)) { return getMacosToolPaths().get(command) ?? null; @@ -214,17 +214,19 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey { */ export function $which(command: string, options?: WhichOptions): string | null { const cachePolicy = options?.cache ?? WhichCachePolicy.Cached; + const lookupOptions = + options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH }; let key: CacheKey | undefined; if (cachePolicy !== WhichCachePolicy.Bypass) { - key = cacheKey(command, options); + key = cacheKey(command, lookupOptions); if (cachePolicy !== WhichCachePolicy.Fresh) { const cached = toolCache.get(key); if (cached !== undefined) return cached; } } - const result = whichFresh(command, options); + const result = whichFresh(command, lookupOptions); if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) { toolCache.set(key, result); } diff --git a/packages/utils/test/fetch-retry.test.ts b/packages/utils/test/fetch-retry.test.ts index cceaf3649..5c8be8e06 100644 --- a/packages/utils/test/fetch-retry.test.ts +++ b/packages/utils/test/fetch-retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { fetchWithRetry } from "@oh-my-pi/pi-utils/fetch-retry"; +import { extractRetryHint, fetchWithRetry } from "@oh-my-pi/pi-utils/fetch-retry"; describe("fetchWithRetry", () => { it("routes requests through the `fetch` override when provided", async () => { @@ -79,3 +79,29 @@ describe("fetchWithRetry", () => { expect(attempt).toBe(1); }); }); + +describe("extractRetryHint", () => { + // Devin returns HTTP 403 with "Your limit will reset in 13 minutes" for an + // account-scoped message rate cap. Without recognizing "will reset in", the + // credential is blocked for the 1-minute default instead of 13 minutes and + // can be reselected and hammered while the cap remains active. + it("parses Devin 'Your limit will reset in 13 minutes' as 13 minutes", () => { + expect(extractRetryHint(undefined, "Your limit will reset in 13 minutes")).toBe(13 * 60_000); + }); + + it("parses bare 'reset in 13 minutes' phrasing", () => { + expect(extractRetryHint(undefined, "reset in 13 minutes")).toBe(13 * 60_000); + }); + + it("parses 'will reset in 2h' phrasing", () => { + expect(extractRetryHint(undefined, "will reset in 2h")).toBe(2 * 60 * 60_000); + }); + + // A quota body can carry both a generic retry hint and the account reset + // window ("Please retry in 5s. Your limit will reset in 13 minutes"). The + // account-reset hint must take precedence so the exhausted credential stays + // blocked for the full stated window instead of the short generic retry. + it("prefers the account reset window over a shorter retry hint", () => { + expect(extractRetryHint(undefined, "Please retry in 5s. Your limit will reset in 13 minutes")).toBe(13 * 60_000); + }); +}); diff --git a/packages/utils/test/fixtures/logger-fixed-date-preload.ts b/packages/utils/test/fixtures/logger-fixed-date-preload.ts index 987b09f4b..314e9bac6 100644 --- a/packages/utils/test/fixtures/logger-fixed-date-preload.ts +++ b/packages/utils/test/fixtures/logger-fixed-date-preload.ts @@ -13,7 +13,7 @@ class FixedDate extends NativeDate { super(value === undefined ? fixtureNow() : value); } - static now(): number { + static override now(): number { return fixtureNow(); } } diff --git a/packages/utils/test/postmortem-cleanup-error.test.ts b/packages/utils/test/postmortem-cleanup-error.test.ts index 840b0e760..fc8c93dcc 100644 --- a/packages/utils/test/postmortem-cleanup-error.test.ts +++ b/packages/utils/test/postmortem-cleanup-error.test.ts @@ -123,6 +123,23 @@ describe("postmortem expected cleanup errors", () => { expect(result.stderr).toContain("[Unhandled Rejection] Error: unexpected cleanup rejection"); }); + it("prints registered recovery commands before fatal cleanup", async () => { + const result = await runPostmortemProbe(` + import { postmortem } from "${postmortemModuleUrl}"; + + postmortem.registerFatalRecoveryHint(() => ({ + label: "Main", + command: "omp --resume 019cafe0-dead-beef", + })); + Promise.reject(new Error("session crashed")); + await Promise.resolve(); + `); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("[Unhandled Rejection] Error: session crashed"); + expect(result.stderr).toContain("[Recovery]\n Main: omp --resume 019cafe0-dead-beef"); + }); + it("exits after an uncaught exception when terminal stderr is revoked", async () => { const result = await runPostmortemProbe(` import { spyOn } from "bun:test"; diff --git a/packages/utils/test/version.test.ts b/packages/utils/test/version.test.ts new file mode 100644 index 000000000..bb6d485d3 --- /dev/null +++ b/packages/utils/test/version.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { compareVersions } from "../src/version"; + +describe("compareVersions", () => { + it("trims whitespace and strips one leading v/V", () => { + expect(compareVersions(" 1.2.3 ", "1.2.3")).toBe(0); + expect(compareVersions("v1.2.3", "1.2.3")).toBe(0); + expect(compareVersions("V1.2.3", "v1.2.3")).toBe(0); + expect(compareVersions(" v1.2.3 ", "1.2.3")).toBe(0); + }); + + it("zero-pads missing trailing segments", () => { + expect(compareVersions("1.2", "1.2.0")).toBe(0); + expect(compareVersions("1", "1.0.0.0")).toBe(0); + expect(compareVersions("1.2", "1.2.1")).toBe(-1); + expect(compareVersions("1.2.3", "1.2")).toBe(1); + }); + + it("supports arbitrary segment counts", () => { + expect(compareVersions("1.2.3.4.5", "1.2.3.4.5")).toBe(0); + expect(compareVersions("1.2.3.4", "1.2.3.5")).toBe(-1); + expect(compareVersions("1.0.0.1", "1.0.0")).toBe(1); + }); + + it("orders SemVer prereleases before the plain release", () => { + expect(compareVersions("1.0.0-beta", "1.0.0")).toBe(-1); + expect(compareVersions("1.0.0", "1.0.0-rc.1")).toBe(1); + expect(compareVersions("v1.0.0-beta", "1.0.0")).toBe(-1); + expect(compareVersions("1.1.0-alpha", "1.0.0-beta")).toBe(1); + }); + + it("compares prerelease identifiers per SemVer 2.0", () => { + expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBe(-1); + expect(compareVersions("1.0.0-rc.1", "1.0.0-rc.2")).toBe(-1); + // numeric, not lexical: rc.10 > rc.9 + expect(compareVersions("1.0.0-rc.9", "1.0.0-rc.10")).toBe(-1); + // a larger set of equal fields has higher precedence + expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBe(-1); + // numeric identifiers sort before alphanumeric ones + expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBe(-1); + expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1); + }); + + it("strips SemVer build metadata before comparing", () => { + // build metadata does not affect precedence + expect(compareVersions("1.0.1+linux", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0-rc.1+abc", "1.0.0-rc.1+xyz")).toBe(0); + expect(compareVersions("1.0.0+build1", "1.0.0+build2")).toBe(0); + expect(compareVersions("1.0.0+linux", "1.0.0+mac")).toBe(0); + expect(compareVersions("1.0.0+linux", "1.0.1+linux")).toBe(-1); + expect(compareVersions("v1.2.3+meta", "1.2.3")).toBe(0); + expect(compareVersions(" 1.0.0+meta ", "1.0.0")).toBe(0); + }); + + it("compares malformed numeric segments as 0", () => { + expect(compareVersions("1.2.x", "1.2.0")).toBe(0); + expect(compareVersions("1.x", "1.0")).toBe(0); + expect(compareVersions("1.2.x", "1.2.1")).toBe(-1); + }); + + it("never throws and always returns -1, 0, or 1", () => { + expect(compareVersions("not_a_version", "0")).toBe(0); + expect(compareVersions("", "")).toBe(0); + expect(compareVersions("v", "")).toBe(0); + // hyphenated garbage parses as a prerelease suffix and loses to the release + expect(compareVersions("not-a-version", "1.0.0")).toBe(-1); + expect(compareVersions("3.0.0", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0", "3.0.0")).toBe(-1); + // exact numeric comparison beyond float precision + expect(compareVersions("1.2.99999999999999999999", "1.2.100000000000000000000")).toBe(-1); + }); +}); diff --git a/packages/utils/test/which.test.ts b/packages/utils/test/which.test.ts new file mode 100644 index 000000000..e054975c2 --- /dev/null +++ b/packages/utils/test/which.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $which } from "../src/which"; + +describe("$which", () => { + const originalPath = process.env.PATH; + const tempDirs: string[] = []; + + afterEach(() => { + process.env.PATH = originalPath; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("uses the current process PATH for each cached lookup", () => { + const firstDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-which-first-")); + const secondDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-which-second-")); + tempDirs.push(firstDir, secondDir); + + const command = `omp-which-${process.pid}`; + const firstExecutable = path.join(firstDir, command); + const secondExecutable = path.join(secondDir, command); + fs.writeFileSync(firstExecutable, "#!/bin/sh\n"); + fs.writeFileSync(secondExecutable, "#!/bin/sh\n"); + fs.chmodSync(firstExecutable, 0o755); + fs.chmodSync(secondExecutable, 0o755); + + process.env.PATH = firstDir; + expect($which(command)).toBe(firstExecutable); + + process.env.PATH = secondDir; + expect($which(command)).toBe(secondExecutable); + }); +}); diff --git a/packages/wire/package.json b/packages/wire/package.json index 0a7bf77ff..3d389164b 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.2.8", + "version": "17.2.9", "description": "Shared wire protocol types for Oh My Pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/scripts/ci-release-notes.ts b/scripts/ci-release-notes.ts index 1228d39dc..0f2b7d1fa 100755 --- a/scripts/ci-release-notes.ts +++ b/scripts/ci-release-notes.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun + /** * Generate aggregated release notes from per-package CHANGELOG.md files. * @@ -29,6 +30,7 @@ */ import { $, Glob } from "bun"; +import { compareVersions } from "../packages/utils/src/version"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const REPO = process.env.OMP_REPO ?? process.env.GITHUB_REPOSITORY ?? "can1357/oh-my-pi"; @@ -37,22 +39,6 @@ const REPO = process.env.OMP_REPO ?? process.env.GITHUB_REPOSITORY ?? "can1357/o // alphabetically after these. const CATEGORY_ORDER = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const; -/** Compare two `X.Y.Z` (or `vX.Y.Z`) version strings; non-semver returns 0. */ -export function compareVersions(a: string, b: string): number { - const am = a - .replace(/^v/, "") - .trim() - .match(/^(\d+)\.(\d+)\.(\d+)$/); - const bm = b - .replace(/^v/, "") - .trim() - .match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!am || !bm) return 0; - if (am[1] !== bm[1]) return Number(am[1]) - Number(bm[1]); - if (am[2] !== bm[2]) return Number(am[2]) - Number(bm[2]); - return Number(am[3]) - Number(bm[3]); -} - export interface ChangelogVersionSpan { version: string; /** 0-indexed line of the `## [X.Y.Z]` heading. */ diff --git a/scripts/install-tests/native-version.test.ts b/scripts/install-tests/native-version.test.ts new file mode 100644 index 000000000..97718f1cb --- /dev/null +++ b/scripts/install-tests/native-version.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "bun:test"; +import { nativeVersionFromExports } from "./native-version"; + +describe("native addon release sentinel", () => { + it("normalizes the unique version sentinel", () => { + expect(nativeVersionFromExports(["load", "__piNativesV17_2_6", "other"])).toBe("17.2.6"); + }); + + it("rejects missing or ambiguous sentinels", () => { + expect(nativeVersionFromExports(["load"])).toBeUndefined(); + expect(nativeVersionFromExports(["__piNativesV17_2_6", "__piNativesV17_2_7"])).toBeUndefined(); + }); +}); diff --git a/scripts/install-tests/native-version.ts b/scripts/install-tests/native-version.ts new file mode 100644 index 000000000..d00f8535c --- /dev/null +++ b/scripts/install-tests/native-version.ts @@ -0,0 +1,22 @@ +import { createRequire } from "node:module"; + +const VERSION_SENTINEL_RE = /^__piNativesV(\d+)_(\d+)_(\d+)$/; + +/** Return the sole release version advertised by a native addon's exports. */ +export function nativeVersionFromExports(exports: readonly string[]): string | undefined { + const versions = exports + .map(name => VERSION_SENTINEL_RE.exec(name)) + .filter((match): match is RegExpExecArray => match !== null) + .map(match => `${match[1]}.${match[2]}.${match[3]}`); + return versions.length === 1 ? versions[0] : undefined; +} + +if (import.meta.main) { + const addonPath = process.argv[2]; + if (!addonPath) throw new Error("Usage: bun scripts/install-tests/native-version.ts "); + const require = createRequire(import.meta.url); + const bindings = require(addonPath) as Record; + const version = nativeVersionFromExports(Object.keys(bindings)); + if (!version) throw new Error(`Native addon has no unique release version sentinel: ${addonPath}`); + process.stdout.write(version); +} diff --git a/scripts/install-tests/run-ci.sh b/scripts/install-tests/run-ci.sh index 31ee8eee2..8621d25e5 100755 --- a/scripts/install-tests/run-ci.sh +++ b/scripts/install-tests/run-ci.sh @@ -7,7 +7,15 @@ WORK_DIR="$(mktemp -d)" TMP_WORK_DIR="$WORK_DIR/tmp" mkdir -p "$TMP_WORK_DIR" export TMPDIR="$TMP_WORK_DIR" -trap 'rm -rf "$WORK_DIR"' EXIT + +NATIVES_PACKAGE="$ROOT_DIR/packages/natives/package.json" +NATIVES_PACKAGE_INITIAL="$WORK_DIR/natives-package.initial.json" +cp "$NATIVES_PACKAGE" "$NATIVES_PACKAGE_INITIAL" +restore_workspace() { + cp "$NATIVES_PACKAGE_INITIAL" "$NATIVES_PACKAGE" + rm -rf "$WORK_DIR" +} +trap restore_workspace EXIT section() { echo "" @@ -42,10 +50,42 @@ find_tarball() { echo "${matches[0]}" } +align_native_manifest() { + local addon_version="" + local addon + local candidate_version + local candidates=() + shopt -s nullglob + candidates=("$ROOT_DIR"/packages/natives/native/pi_natives.*.node) + shopt -u nullglob + + if [ "${#candidates[@]}" -eq 0 ]; then + echo "No native addon found for install smoke" >&2 + exit 1 + fi + for addon in "${candidates[@]}"; do + candidate_version="$(bun "$ROOT_DIR/scripts/install-tests/native-version.ts" "$addon")" || exit 1 + if [ -z "$addon_version" ]; then + addon_version="$candidate_version" + elif [ "$addon_version" != "$candidate_version" ]; then + echo "Native addon version mismatch: $addon_version vs $candidate_version ($addon)" >&2 + exit 1 + fi + done + + local declared_version + declared_version="$(jq -r '.version' "$NATIVES_PACKAGE")" + if [ "$declared_version" = "$addon_version" ]; then return; fi + + echo "Aligning install smoke native manifest $declared_version → $addon_version" + jq --arg version "$addon_version" '.version = $version' "$NATIVES_PACKAGE" > "$WORK_DIR/natives-package.aligned.json" + mv "$WORK_DIR/natives-package.aligned.json" "$NATIVES_PACKAGE" +} section "Binary install smoke" if [ "${OMP_INSTALL_TEST_SKIP_NATIVE_BUILD:-0}" != "1" ]; then bun --cwd=packages/natives run build fi +align_native_manifest bun --cwd=packages/coding-agent run build BINARY_DIR="$WORK_DIR/binary-bin" diff --git a/scripts/release.test.ts b/scripts/release.test.ts new file mode 100644 index 000000000..bc37ffc8b --- /dev/null +++ b/scripts/release.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { validateExplicitVersion } from "./release"; + +describe("validateExplicitVersion", () => { + test("rejects malformed versions", () => { + expect(validateExplicitVersion("999.bad")).toBe(null); + expect(validateExplicitVersion("17")).toBe(null); + expect(validateExplicitVersion("17.2")).toBe(null); + expect(validateExplicitVersion("17.2.8.9")).toBe(null); + expect(validateExplicitVersion("v17.2.8.9")).toBe(null); + expect(validateExplicitVersion("abc")).toBe(null); + expect(validateExplicitVersion("")).toBe(null); + expect(validateExplicitVersion("v")).toBe(null); + expect(validateExplicitVersion("17.2.8-")).toBe(null); + }); + + test("rejects leading zeroes in numeric segments", () => { + expect(validateExplicitVersion("018.0.0")).toBe(null); + expect(validateExplicitVersion("v018.0.0")).toBe(null); + expect(validateExplicitVersion("18.00.0")).toBe(null); + expect(validateExplicitVersion("18.0.00")).toBe(null); + }); + + test("rejects prerelease suffixes (not supported by this release path)", () => { + // Prereleases would be published as npm `latest` because the downstream + // publish runs `npm publish` with no `--tag`. + expect(validateExplicitVersion("17.2.8-rc.1")).toBe(null); + expect(validateExplicitVersion("v17.2.8-beta")).toBe(null); + expect(validateExplicitVersion("1.0.0-alpha")).toBe(null); + expect(validateExplicitVersion("1.0.0-alpha.1.2")).toBe(null); + expect(validateExplicitVersion("1.0.0-0.3.7")).toBe(null); + expect(validateExplicitVersion("1.0.0-x.7.z.92")).toBe(null); + }); + + test("accepts bare three-segment numeric versions and returns them unchanged", () => { + expect(validateExplicitVersion("17.2.8")).toBe("17.2.8"); + expect(validateExplicitVersion("0.0.0")).toBe("0.0.0"); + expect(validateExplicitVersion("1.0.0")).toBe("1.0.0"); + }); + + test("accepts leading v prefix and normalizes to the bare version", () => { + expect(validateExplicitVersion("v17.2.8")).toBe("17.2.8"); + expect(validateExplicitVersion("V17.2.8")).toBe(null); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 0399fce85..00188044e 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -9,11 +9,30 @@ * Example: bun scripts/release.ts minor */ import { $, Glob } from "bun"; +import { compareVersions } from "../packages/utils/src/version.ts"; import { runChangelogFixer } from "./fix-changelogs"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); const cargoTomlGlob = new Glob("crates/*/Cargo.toml"); +/** + * Strict explicit-version guard: three numeric dot-segments with an optional + * leading `v` and NO prerelease suffix. Prereleases are rejected because the + * downstream publish (`scripts/ci-release-publish.ts`) runs `npm publish` with + * no `--tag`, which would promote a prerelease to the npm `latest` dist-tag — + * hitting every unqualified install and the `/latest` endpoint `omp update` + * reads. Bump keywords (major/minor/patch) are handled separately and must not + * be routed through this check. + * + * Returns the normalized bare version (leading `v` stripped) when accepted, or + * `null` when rejected. Callers must use the returned value for all writes so + * no downstream manifest (package.json, Cargo.toml, tag) ever sees a `v` + * prefix — Cargo rejects `version = "v17.2.8"`. + */ +export function validateExplicitVersion(version: string): string | null { + const match = /^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/.exec(version); + return match ? match[1] : null; +} function git(args: readonly string[]) { return $`git -c core.fsmonitor=false -c core.untrackedCache=false -c fetch.pruneTags=false ${args}`; @@ -186,16 +205,23 @@ function bumpVersion(current: string, bump: "major" | "minor" | "patch"): string } } -function compareVersions(a: string, b: string): number { - const [aMajor, aMinor, aPatch] = parseVersion(a); - const [bMajor, bMinor, bPatch] = parseVersion(b); - if (aMajor !== bMajor) return aMajor - bMajor; - if (aMinor !== bMinor) return aMinor - bMinor; - return aPatch - bPatch; -} - async function cmdRelease(versionOrBump: string): Promise { console.log("\n=== Release Script ===\n"); + // Validate explicit versions before any compare: the shared compareVersions + // never throws, so without this guard garbage like "999.bad" would be + // accepted and written into every package.json / Cargo.toml / tag. The + // validator also normalizes a leading `v` to the bare version so every + // downstream write (manifests, Cargo.toml, tag) uses `17.2.8`, not `v17.2.8`. + if (versionOrBump !== "major" && versionOrBump !== "minor" && versionOrBump !== "patch") { + const normalized = validateExplicitVersion(versionOrBump); + if (normalized === null) { + console.error( + `Error: Invalid version "${versionOrBump}". Expected a semver like 17.2.8 or v17.2.8 (prereleases such as 17.2.8-rc.1 are not supported by this release path), or a bump keyword (major/minor/patch).`, + ); + process.exit(1); + } + versionOrBump = normalized; + } // 1. Pre-flight checks console.log("Pre-flight checks..."); @@ -397,23 +423,25 @@ async function cmdRelease(versionOrBump: string): Promise { // Main // ============================================================================= -const arg = process.argv[2]; +if (import.meta.main) { + const arg = process.argv[2]; -if (!arg) { - console.error("Usage:"); - console.error(" bun scripts/release.ts Full release"); - console.error(" bun scripts/release.ts watch Watch CI for current commit"); - process.exit(1); -} + if (!arg) { + console.error("Usage:"); + console.error(" bun scripts/release.ts Full release"); + console.error(" bun scripts/release.ts watch Watch CI for current commit"); + process.exit(1); + } -if (arg === "watch") { - await cmdWatch(); -} else if (arg === "major" || arg === "minor" || arg === "patch" || /^\d+\.\d+\.\d+$/.test(arg)) { - await cmdRelease(arg); -} else { - console.error(`Unknown command or invalid version: ${arg}`); - console.error("Usage:"); - console.error(" bun scripts/release.ts Full release"); - console.error(" bun scripts/release.ts watch Watch CI for current commit"); - process.exit(1); + if (arg === "watch") { + await cmdWatch(); + } else if (arg === "major" || arg === "minor" || arg === "patch" || validateExplicitVersion(arg) !== null) { + await cmdRelease(arg); + } else { + console.error(`Unknown command or invalid version: ${arg}`); + console.error("Usage:"); + console.error(" bun scripts/release.ts Full release"); + console.error(" bun scripts/release.ts watch Watch CI for current commit"); + process.exit(1); + } } diff --git a/scripts/setup-npm-trust.ts b/scripts/setup-npm-trust.ts index f4959c53c..238369de3 100755 --- a/scripts/setup-npm-trust.ts +++ b/scripts/setup-npm-trust.ts @@ -36,6 +36,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { $ } from "bun"; import { LEAF_TARGETS } from "../packages/natives/scripts/gen-npm-packages.ts"; +import { compareVersions } from "../packages/utils/src/version.ts"; import { packages } from "./ci-release-publish.ts"; const repoRoot = path.join(import.meta.dir, ".."); @@ -179,17 +180,6 @@ async function collectTargets(): Promise<{ names: string[]; repoFromManifest: st return { names, repoFromManifest }; } -/** Compare dotted version numbers; true when `version` >= `minimum`. */ -function meetsMinimum(version: string, minimum: string): boolean { - const a = version.split(".").map(Number); - const b = minimum.split(".").map(Number); - for (let i = 0; i < Math.max(a.length, b.length); i++) { - const diff = (a[i] ?? 0) - (b[i] ?? 0); - if (diff !== 0) return diff > 0; - } - return true; -} - /** Run npm with the terminal attached so the web 2FA flow stays interactive. */ function npmInteractive(args: readonly string[]): Promise { return Bun.spawn(["npm", ...args], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }).exited; @@ -321,7 +311,7 @@ async function main(): Promise { console.error("Could not determine npm version. Is npm installed and on PATH?"); process.exit(1); } - if (!meetsMinimum(npmVersion, MIN_NPM)) { + if (compareVersions(npmVersion, MIN_NPM) < 0) { console.error(`npm ${MIN_NPM}+ is required for trusted publishing (found ${npmVersion}).`); console.error("Upgrade with: npm install -g npm@latest"); process.exit(1); diff --git a/tsconfig.base.json b/tsconfig.base.json index 0639244e8..2a9cbf211 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,6 +6,8 @@ "moduleResolution": "Bundler", "moduleDetection": "force", "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "allowArbitraryExtensions": true, "verbatimModuleSyntax": true,